🎉 Let's go!
This commit is contained in:
commit
6f85f24232
24 changed files with 2563 additions and 0 deletions
176
.gitignore
vendored
Normal file
176
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||
154
README.md
Normal file
154
README.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# OhMyAPI
|
||||
|
||||
> OhMyAPI == Application scaffolding for FastAPI+TortoiseORM.
|
||||
|
||||
OhMyAPI is a blazingly fast, async Python web application framework with batteries included.
|
||||
It is built around FastAPI and TortoiseORM and is thus 100% async.
|
||||
|
||||
Features:
|
||||
|
||||
- Django-like project-layout and -structure
|
||||
- Django-like settings.py
|
||||
- Django-like models via TortoiseORM
|
||||
- Django-like model.Meta class for model configuration
|
||||
- Django-like advanced permissions system
|
||||
- Django-like migrations (makemigrations & migrate) via Aerich
|
||||
- Django-like CLI for interfacing with your projects (startproject, startapp, shell, serve, etc)
|
||||
- various optional builtin apps
|
||||
- highly configurable and customizable
|
||||
- 100% async
|
||||
|
||||
## Getting started
|
||||
|
||||
**Creating a Project**
|
||||
|
||||
```
|
||||
pip install ohmyapi # TODO: not yet published
|
||||
ohmyapi startproject myproject
|
||||
cd myproject
|
||||
```
|
||||
|
||||
This will create the following directory structure:
|
||||
|
||||
```
|
||||
myproject/
|
||||
- pyproject.toml
|
||||
- settings.py
|
||||
```
|
||||
|
||||
Run your project with:
|
||||
|
||||
```
|
||||
ohmyapi serve
|
||||
```
|
||||
|
||||
In your browser go to:
|
||||
- http://localhost:8000/docs
|
||||
|
||||
**Creating an App**
|
||||
|
||||
Create a new app by:
|
||||
|
||||
```
|
||||
ohmyapi startapp myapp
|
||||
```
|
||||
|
||||
This will lead to the following directory structure:
|
||||
|
||||
```
|
||||
myproject/
|
||||
- myapp/
|
||||
- __init__.py
|
||||
- models.py
|
||||
- routes.py
|
||||
- pyproject.toml
|
||||
- settings.py
|
||||
```
|
||||
|
||||
Add 'myapp' to your `INSTALLED_APPS` in `settings.py`.
|
||||
|
||||
Write your first model in `myapp/models.py`:
|
||||
|
||||
```python
|
||||
from ohmyapi.db import Model, field
|
||||
|
||||
|
||||
class Person(Model):
|
||||
id: int = field.IntField(min=1, pk=True)
|
||||
name: str = field.CharField(min_length=1, max_length=255)
|
||||
username: str = field.CharField(min_length=1, max_length=255, unique=True)
|
||||
age: int = field.IntField(min=0)
|
||||
```
|
||||
|
||||
Next, create your endpoints in `myapp/routes.py`:
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from tortoise.exceptions import DoesNotExist
|
||||
|
||||
from .models import Person
|
||||
|
||||
router = APIRouter(prefix="/myapp")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list():
|
||||
return await Person.all()
|
||||
|
||||
|
||||
@router.get("/:id")
|
||||
async def get(id: int):
|
||||
try:
|
||||
await Person.get(pk=id)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=404, detail="item not found")
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
Before we can run the app, we need to create and initialize the database.
|
||||
|
||||
Similar to Django, first run:
|
||||
|
||||
```
|
||||
ohmyapi makemigrations [ <app> ] # no app means all INSTALLED_APPS
|
||||
```
|
||||
|
||||
And the apply your migrations via:
|
||||
|
||||
```
|
||||
ohmyapi migrate [ <app> ] # no app means all INSTALLED_APPS
|
||||
```
|
||||
|
||||
Run your project:
|
||||
|
||||
```
|
||||
ohmyapi serve
|
||||
```
|
||||
|
||||
## Shell
|
||||
|
||||
Similar to Django, you can attach to an interactive shell with your project already loaded inside.
|
||||
|
||||
```
|
||||
ohmyapi shell
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
A builtin auth app is available.
|
||||
Simply add `ohmyapi_auth` to your INSTALLED_APPS and define a JWT_SECRET in your `settings.py`.
|
||||
Remember to `makemigrations` and `migrate` for the auth tables to be created in the database.
|
||||
|
||||
`settings.py`:
|
||||
|
||||
```
|
||||
INSTALLED_APPS = [
|
||||
'ohmyapi_auth',
|
||||
...
|
||||
]
|
||||
|
||||
JWT_SECRET = "t0ps3cr3t"
|
||||
```
|
||||
1378
poetry.lock
generated
Normal file
1378
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
34
pyproject.toml
Normal file
34
pyproject.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
[project]
|
||||
name = "ohmyapi"
|
||||
version = "0.1.0"
|
||||
description = "A Django-like but async web-framework based on FastAPI and TortoiseORM."
|
||||
authors = [
|
||||
{name = "Brian Wiborg", email = "me@brianwib.org"}
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"typer (>=0.19.1,<0.20.0)",
|
||||
"jinja2 (>=3.1.6,<4.0.0)",
|
||||
"fastapi (>=0.117.1,<0.118.0)",
|
||||
"tortoise-orm (>=0.25.1,<0.26.0)",
|
||||
"aerich (>=0.9.1,<0.10.0)",
|
||||
"uvicorn (>=0.36.0,<0.37.0)",
|
||||
"ipython (>=9.5.0,<10.0.0)",
|
||||
"passlib (>=1.7.4,<2.0.0)",
|
||||
"pyjwt (>=2.10.1,<3.0.0)",
|
||||
"python-multipart (>=0.0.20,<0.0.21)",
|
||||
"crypto (>=1.4.1,<2.0.0)",
|
||||
"argon2-cffi (>=25.1.0,<26.0.0)",
|
||||
]
|
||||
|
||||
[tool.poetry]
|
||||
packages = [{include = "ohmyapi", from = "src"}]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
ohmyapi = "ohmyapi.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
2
src/ohmyapi/__init__.py
Normal file
2
src/ohmyapi/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import db
|
||||
|
||||
4
src/ohmyapi/builtin/auth/__init__.py
Normal file
4
src/ohmyapi/builtin/auth/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from . import models
|
||||
from . import routes
|
||||
from . import permissions
|
||||
|
||||
36
src/ohmyapi/builtin/auth/models.py
Normal file
36
src/ohmyapi/builtin/auth/models.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from typing import Optional, List
|
||||
from ohmyapi.db import Model, field
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
|
||||
class Group(Model):
|
||||
id = field.IntField(pk=True)
|
||||
name = field.CharField(max_length=42)
|
||||
|
||||
|
||||
class User(Model):
|
||||
id = field.IntField(pk=True)
|
||||
username = field.CharField(max_length=150, unique=True)
|
||||
password_hash = field.CharField(max_length=128)
|
||||
is_admin = field.BooleanField(default=False)
|
||||
is_staff = field.BooleanField(default=False)
|
||||
groups: Optional[List[Group]] = field.ManyToManyField("ohmyapi_auth.Group", related_name="users")
|
||||
|
||||
def set_password(self, raw_password: str) -> None:
|
||||
"""Hash and store the password."""
|
||||
self.password_hash = pwd_context.hash(raw_password)
|
||||
|
||||
def verify_password(self, raw_password: str) -> bool:
|
||||
"""Verify a plaintext password against the stored hash."""
|
||||
return pwd_context.verify(raw_password, self.password_hash)
|
||||
|
||||
@classmethod
|
||||
async def authenticate(cls, username: str, password: str) -> Optional["User"]:
|
||||
"""Authenticate a user by username and password."""
|
||||
user = await cls.filter(username=username).first()
|
||||
if user and user.verify_password(password):
|
||||
return user
|
||||
return None
|
||||
|
||||
6
src/ohmyapi/builtin/auth/permissions.py
Normal file
6
src/ohmyapi/builtin/auth/permissions.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .routes import (
|
||||
get_current_user,
|
||||
require_authenticated,
|
||||
require_admin,
|
||||
require_staff,
|
||||
)
|
||||
136
src/ohmyapi/builtin/auth/routes.py
Normal file
136
src/ohmyapi/builtin/auth/routes.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import time
|
||||
from typing import Dict
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ohmyapi.builtin.auth.models import User
|
||||
|
||||
import settings
|
||||
|
||||
# Router
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
# Secrets & config (should come from settings/env in real projects)
|
||||
JWT_SECRET = getattr(settings, "JWT_SECRET", "changeme")
|
||||
JWT_ALGORITHM = getattr(settings, "JWT_ALGORITHM", "HS256")
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS = getattr(settings, "JWT_ACCESS_TOKEN_EXPIRE_SECONDS", 15 * 60)
|
||||
REFRESH_TOKEN_EXPIRE_SECONDS = getattr(settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60)
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||
|
||||
|
||||
def create_token(data: dict, expires_in: int) -> str:
|
||||
to_encode = data.copy()
|
||||
to_encode.update({"exp": int(time.time()) + expires_in})
|
||||
token = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
if isinstance(token, bytes):
|
||||
token = token.decode("utf-8")
|
||||
return token
|
||||
|
||||
|
||||
def decode_token(token: str) -> Dict:
|
||||
try:
|
||||
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
|
||||
"""Dependency: extract user from access token."""
|
||||
payload = decode_token(token)
|
||||
username = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
|
||||
|
||||
user = await User.filter(username=username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
async def require_authenticated(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Ensure the current user is an admin."""
|
||||
if not current_user:
|
||||
raise HTTPException(403, "Authentication required")
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Ensure the current user is an admin."""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin privileges required")
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_staff(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Ensure the current user is a staff member."""
|
||||
if not current_user.is_staff:
|
||||
raise HTTPException(403, "Staff privileges required")
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_group(
|
||||
group_name: str,
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""Ensure the current user belongs to the given group."""
|
||||
user_groups = await current_user.groups.all()
|
||||
if not any(g.name == group_name for g in user_groups):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"User must belong to group '{group_name}'"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(form_data: LoginRequest = Body(...)):
|
||||
"""Login with username & password, returns access and refresh tokens."""
|
||||
user = await User.authenticate(form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
access_token = create_token({"sub": user.username, "type": "access"}, ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
refresh_token = create_token({"sub": user.username, "type": "refresh"}, REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(refresh_token: str):
|
||||
"""Exchange refresh token for new access token."""
|
||||
payload = decode_token(refresh_token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
|
||||
|
||||
username = payload.get("sub")
|
||||
user = await User.filter(username=username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
|
||||
new_access = create_token({"sub": user.username, "type": "access"}, ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
return {"access_token": new_access, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(current_user: User = Depends(get_current_user)):
|
||||
"""Return the currently authenticated user."""
|
||||
return {
|
||||
"username": current_user.username,
|
||||
"is_admin": current_user.is_admin,
|
||||
"is_staff": current_user.is_staff,
|
||||
}
|
||||
114
src/ohmyapi/cli.py
Normal file
114
src/ohmyapi/cli.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
import typer
|
||||
import uvicorn
|
||||
|
||||
from getpass import getpass
|
||||
from ohmyapi.core import scaffolding, runtime
|
||||
from pathlib import Path
|
||||
|
||||
app = typer.Typer(help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM.")
|
||||
banner = """OhMyAPI Shell | Project: {project_name}"""
|
||||
|
||||
|
||||
@app.command()
|
||||
def startproject(name: str):
|
||||
"""Create a new OhMyAPI project in the given directory"""
|
||||
scaffolding.startproject(name)
|
||||
|
||||
|
||||
@app.command()
|
||||
def startapp(app_name: str, root: str = "."):
|
||||
"""Create a new app with the given name in your OhMyAPI project"""
|
||||
scaffolding.startapp(app_name, root)
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(root: str = ".", host="127.0.0.1", port=8000):
|
||||
"""
|
||||
Run this project in via uvicorn.
|
||||
"""
|
||||
project_path = Path(root)
|
||||
project = runtime.Project(project_path)
|
||||
app_instance = project.app()
|
||||
uvicorn.run(app_instance, host=host, port=int(port), reload=False)
|
||||
|
||||
|
||||
@app.command()
|
||||
def shell(root: str = "."):
|
||||
"""
|
||||
Launch an interactive IPython shell with the project and apps loaded.
|
||||
"""
|
||||
project_path = Path(root).resolve()
|
||||
project = runtime.Project(project_path)
|
||||
|
||||
try:
|
||||
from IPython import start_ipython
|
||||
shell_vars = {
|
||||
"settings": project.settings,
|
||||
"project": Path(project_path).resolve(),
|
||||
}
|
||||
from traitlets.config.loader import Config
|
||||
c = Config()
|
||||
c.TerminalIPythonApp.display_banner = True
|
||||
c.TerminalInteractiveShell.banner1 = banner.format(**{
|
||||
"project_name": f"{f'{project.settings.PROJECT_NAME} ' if getattr(project.settings, 'PROJECT_NAME', '') else ''}[{Path(project_path).resolve()}]",
|
||||
})
|
||||
c.TerminalInteractiveShell.banner2 = " "
|
||||
start_ipython(argv=[], user_ns=shell_vars, config=c)
|
||||
except ImportError:
|
||||
typer.echo("IPython is not installed. Falling back to built-in Python shell.")
|
||||
import code
|
||||
code.interact(local={"settings": project.settings})
|
||||
|
||||
|
||||
@app.command()
|
||||
def makemigrations(app: str = "*", name: str = "auto", root: str = "."):
|
||||
"""
|
||||
Create a DB migration based on your models.
|
||||
"""
|
||||
project_path = Path(root).resolve()
|
||||
project = runtime.Project(project_path)
|
||||
if app == "*":
|
||||
for app in project.apps.keys():
|
||||
asyncio.run(project.makemigrations(app_label=app, name=name))
|
||||
else:
|
||||
asyncio.run(project.makemigrations(app_label=app, name=name))
|
||||
|
||||
|
||||
@app.command()
|
||||
def migrate(app: str = "*", root: str = "."):
|
||||
"""
|
||||
Run all DB migrations.
|
||||
"""
|
||||
project_path = Path(root).resolve()
|
||||
project = runtime.Project(project_path)
|
||||
if app == "*":
|
||||
for app in project.apps.keys():
|
||||
asyncio.run(project.migrate(app))
|
||||
else:
|
||||
asyncio.run(project.migrate(app))
|
||||
|
||||
|
||||
@app.command()
|
||||
def createsuperuser(root: str = "."):
|
||||
project_path = Path(root).resolve()
|
||||
project = runtime.Project(project_path)
|
||||
if not project.is_app_installed("ohmyapi_auth"):
|
||||
print("Auth app not installed! Please add 'ohmyapi_auth' to your INSTALLED_APPS.")
|
||||
return
|
||||
|
||||
import asyncio
|
||||
import ohmyapi_auth
|
||||
username = input("Username: ")
|
||||
password = getpass("Password: ")
|
||||
user = ohmyapi_auth.models.User(username=username, is_staff=True, is_admin=True)
|
||||
user.set_password(password)
|
||||
asyncio.run(project.init_orm())
|
||||
asyncio.run(user.save())
|
||||
asyncio.run(project.close_orm())
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
0
src/ohmyapi/core/__init__.py
Normal file
0
src/ohmyapi/core/__init__.py
Normal file
246
src/ohmyapi/core/runtime.py
Normal file
246
src/ohmyapi/core/runtime.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
# ohmyapi/core/runtime.py
|
||||
import copy
|
||||
import importlib
|
||||
import importlib.util
|
||||
import pkgutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import click
|
||||
from aerich import Command as AerichCommand
|
||||
from aerich.exceptions import NotInitedError
|
||||
from tortoise import Tortoise
|
||||
from fastapi import FastAPI, APIRouter
|
||||
from ohmyapi.db.model import Model
|
||||
|
||||
|
||||
class App:
|
||||
"""App container holding runtime data like detected models and routes."""
|
||||
|
||||
def __init__(self, project: "OhMyAPI Project", name: str):
|
||||
self.project = project
|
||||
self.name = name
|
||||
|
||||
# The list of module paths (e.g. "ohmyapi_auth.models") for Tortoise and Aerich
|
||||
self.model_modules: List[str] = []
|
||||
|
||||
# The APIRouter
|
||||
self.router: Optional[APIRouter] = None
|
||||
|
||||
# Import the app, so its __init__.py runs.
|
||||
importlib.import_module(self.name)
|
||||
|
||||
# Load the models
|
||||
try:
|
||||
models_mod = importlib.import_module(f"{self.name}.models")
|
||||
self.model_modules.append(f"{self.name}.models")
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
# Locate the APIRouter
|
||||
try:
|
||||
routes_mod = importlib.import_module(f"{self.name}.routes")
|
||||
router = getattr(routes_mod, "router", None)
|
||||
if isinstance(router, APIRouter):
|
||||
self.router = router
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
out = ""
|
||||
out += f"App: {self.name}\n"
|
||||
out += f"Models:\n"
|
||||
for model in self.models:
|
||||
out += f" - {model.__name__}\n"
|
||||
out += "Routes:\n"
|
||||
for route in (self.routes or []):
|
||||
out += f" - {route}\n"
|
||||
return out
|
||||
|
||||
def __str__(self):
|
||||
return self.__repr__()
|
||||
|
||||
@property
|
||||
def models(self) -> List[Model]:
|
||||
models: List[Model] = []
|
||||
for mod in self.model_modules:
|
||||
models_mod = importlib.import_module(mod)
|
||||
for obj in models_mod.__dict__.values():
|
||||
if isinstance(obj, type) and getattr(obj, "_meta", None) is not None and obj.__name__ != 'Model':
|
||||
models.append(obj)
|
||||
return models
|
||||
|
||||
@property
|
||||
def routes(self):
|
||||
return self.router.routes
|
||||
|
||||
|
||||
class Project:
|
||||
"""
|
||||
Project runtime loader + Tortoise/Aerich integration.
|
||||
|
||||
- injects builtin apps as ohmyapi_<name>
|
||||
- builds unified tortoise config for runtime
|
||||
- provides makemigrations/migrate methods using Aerich Command API
|
||||
"""
|
||||
|
||||
def __init__(self, project_path: str):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
self._apps: Dict[str, App] = {}
|
||||
self.migrations_dir = self.project_path / "migrations"
|
||||
|
||||
if str(self.project_path) not in sys.path:
|
||||
sys.path.insert(0, str(self.project_path))
|
||||
|
||||
# Pre-register builtin apps as ohmyapi_<name>.
|
||||
# This makes all builtin apps easily loadable via f"ohmyapi_{app_name}".
|
||||
spec = importlib.util.find_spec("ohmyapi.builtin")
|
||||
if spec and spec.submodule_search_locations:
|
||||
for _, modname, _ in pkgutil.iter_modules(spec.submodule_search_locations):
|
||||
full = f"ohmyapi.builtin.{modname}"
|
||||
alias = f"ohmyapi_{modname}"
|
||||
if alias not in sys.modules:
|
||||
orig = importlib.import_module(full)
|
||||
sys.modules[alias] = orig
|
||||
try:
|
||||
sys.modules[f"{alias}.models"] = importlib.import_module(f"{full}.models")
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
# Load settings.py
|
||||
try:
|
||||
self.settings = importlib.import_module("settings")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to import project settings from {self.project_path}") from e
|
||||
|
||||
# Load installed apps
|
||||
for app_name in getattr(self.settings, "INSTALLED_APPS", []):
|
||||
self._apps[app_name] = App(self, name=app_name)
|
||||
|
||||
@property
|
||||
def apps(self):
|
||||
return self._apps
|
||||
|
||||
def is_app_installed(self, name: str) -> bool:
|
||||
return name in getattr(self.settings, "INSTALLED_APPS", [])
|
||||
|
||||
def app(self, generate_schemas: bool = False) -> FastAPI:
|
||||
"""
|
||||
Create a FastAPI app, attach all APIRouters from registered apps,
|
||||
and register ORM lifecycle event handlers.
|
||||
"""
|
||||
app = FastAPI(title=getattr(self.settings, "PROJECT_NAME", "OhMyAPI Project"))
|
||||
|
||||
# Attach routers from apps
|
||||
for app_name, app_def in self._apps.items():
|
||||
if app_def.router:
|
||||
app.include_router(app_def.router)
|
||||
|
||||
# Startup / shutdown events
|
||||
@app.on_event("startup")
|
||||
async def _startup():
|
||||
await self.init_orm(generate_schemas=generate_schemas)
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _shutdown():
|
||||
await self.close_orm()
|
||||
|
||||
return app
|
||||
|
||||
# --- Config builders ---
|
||||
def build_tortoise_config(self, db_url: Optional[str] = None) -> dict:
|
||||
"""
|
||||
Build unified Tortoise config for all registered apps.
|
||||
"""
|
||||
db = db_url or getattr(self.settings, "DATABASE_URL", "sqlite://db.sqlite3")
|
||||
config = {
|
||||
"connections": {"default": db},
|
||||
"apps": {},
|
||||
"tortoise": "Tortoise",
|
||||
"migrations_dir": str(self.migrations_dir),
|
||||
}
|
||||
|
||||
for app_name, app in self._apps.items():
|
||||
modules = list(dict.fromkeys(app.model_modules))
|
||||
if modules:
|
||||
config["apps"][app_name] = {"models": modules, "default_connection": "default"}
|
||||
|
||||
return config
|
||||
|
||||
def build_aerich_command(self, app_label: str, db_url: Optional[str] = None) -> AerichCommand:
|
||||
# Resolve label to flat_label
|
||||
if app_label in self._apps:
|
||||
flat_label = app_label
|
||||
else:
|
||||
candidate = app_label.replace(".", "_")
|
||||
if candidate in self._apps:
|
||||
flat_label = candidate
|
||||
else:
|
||||
raise RuntimeError(f"App '{app_label}' is not registered")
|
||||
|
||||
# Get a fresh copy of the config (without aerich.models anywhere)
|
||||
tortoise_cfg = copy.deepcopy(self.build_tortoise_config(db_url=db_url))
|
||||
|
||||
# Append aerich.models to the models list of the target app only
|
||||
if flat_label in tortoise_cfg["apps"]:
|
||||
tortoise_cfg["apps"][flat_label]["models"].append("aerich.models")
|
||||
|
||||
return AerichCommand(
|
||||
tortoise_config=tortoise_cfg,
|
||||
app=flat_label,
|
||||
location=str(self.migrations_dir)
|
||||
)
|
||||
|
||||
# --- ORM lifecycle ---
|
||||
async def init_orm(self, generate_schemas: bool = False) -> None:
|
||||
if not Tortoise.apps:
|
||||
cfg = self.build_tortoise_config()
|
||||
await Tortoise.init(config=cfg)
|
||||
if generate_schemas:
|
||||
await Tortoise.generate_schemas(safe=True)
|
||||
|
||||
async def close_orm(self) -> None:
|
||||
await Tortoise.close_connections()
|
||||
|
||||
# --- Migration helpers ---
|
||||
async def makemigrations(self, app_label: str, name: str = "auto", db_url: Optional[str] = None) -> None:
|
||||
cmd = self.build_aerich_command(app_label, db_url=db_url)
|
||||
async with cmd as c:
|
||||
await c.init()
|
||||
try:
|
||||
await c.init_db(safe=True)
|
||||
except FileExistsError:
|
||||
pass
|
||||
try:
|
||||
await c.migrate(name=name)
|
||||
except (NotInitedError, click.UsageError):
|
||||
await c.init_db(safe=True)
|
||||
await c.migrate(name=name)
|
||||
|
||||
async def migrate(self, app_label: Optional[str] = None, db_url: Optional[str] = None) -> None:
|
||||
labels: List[str]
|
||||
if app_label:
|
||||
if app_label in self._apps:
|
||||
labels = [app_label]
|
||||
else:
|
||||
raise RuntimeError(f"Unknown app '{app_label}'")
|
||||
else:
|
||||
labels = list(self._apps.keys())
|
||||
|
||||
for lbl in labels:
|
||||
cmd = self.build_aerich_command(lbl, db_url=db_url)
|
||||
async with cmd as c:
|
||||
await c.init()
|
||||
try:
|
||||
await c.init_db(safe=True)
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Try to apply migrations
|
||||
await c.upgrade()
|
||||
except (NotInitedError, click.UsageError):
|
||||
# No migrations yet, initialize then retry upgrade
|
||||
await c.init_db(safe=True)
|
||||
await c.upgrade()
|
||||
61
src/ohmyapi/core/scaffolding.py
Normal file
61
src/ohmyapi/core/scaffolding.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
# Base templates directory
|
||||
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
|
||||
|
||||
|
||||
def render_template_file(template_path: Path, context: dict, output_path: Path):
|
||||
"""Render a single Jinja2 template file to disk."""
|
||||
template = env.get_template(str(template_path.relative_to(TEMPLATE_DIR)).replace("\\", "/"))
|
||||
content = template.render(**context)
|
||||
os.makedirs(output_path.parent, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def render_template_dir(template_subdir: str, target_dir: Path, context: dict, subdir_name: str | None = None):
|
||||
"""
|
||||
Recursively render all *.j2 templates from TEMPLATE_DIR/template_subdir into target_dir.
|
||||
If subdir_name is given, files are placed inside target_dir/subdir_name.
|
||||
"""
|
||||
template_dir = TEMPLATE_DIR / template_subdir
|
||||
for root, _, files in os.walk(template_dir):
|
||||
root_path = Path(root)
|
||||
rel_root = root_path.relative_to(template_dir) # path relative to template_subdir
|
||||
|
||||
for f in files:
|
||||
if not f.endswith(".j2"):
|
||||
continue
|
||||
|
||||
template_rel_path = rel_root / f
|
||||
output_rel_path = Path(*template_rel_path.parts).with_suffix("") # remove .j2
|
||||
|
||||
# optionally wrap in subdir_name
|
||||
if subdir_name:
|
||||
output_path = target_dir / subdir_name / output_rel_path
|
||||
else:
|
||||
output_path = target_dir / output_rel_path
|
||||
|
||||
render_template_file(template_dir / template_rel_path, context, output_path)
|
||||
|
||||
|
||||
def startproject(name: str):
|
||||
"""Create a new project: flat structure, all project templates go into <name>/"""
|
||||
target_dir = Path(name).resolve()
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
render_template_dir("project", target_dir, {"project_name": name})
|
||||
print(f"✅ Project '{name}' created successfully.")
|
||||
print(f"🔧 Next, configure your project in {target_dir / 'settings.py'}")
|
||||
|
||||
|
||||
def startapp(name: str, project: str):
|
||||
"""Create a new app inside a project: templates go into <project_dir>/<name>/"""
|
||||
target_dir = Path(project)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
render_template_dir("app", target_dir, {"project_name": project, "app_name": name}, subdir_name=name)
|
||||
print(f"✅ App '{name}' created in project '{target_dir}' successfully.")
|
||||
print(f"🔧 Remember to add '{name}' to your INSTALLED_APPS!")
|
||||
|
||||
3
src/ohmyapi/core/templates/app/__init__.py.j2
Normal file
3
src/ohmyapi/core/templates/app/__init__.py.j2
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from . import models
|
||||
from . import routes
|
||||
|
||||
6
src/ohmyapi/core/templates/app/models.py.j2
Normal file
6
src/ohmyapi/core/templates/app/models.py.j2
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from ohmyapi.db import Model, field
|
||||
|
||||
|
||||
class MyModel(Model):
|
||||
id: int = field.IntField(min=1, pk=True)
|
||||
...
|
||||
13
src/ohmyapi/core/templates/app/routes.py.j2
Normal file
13
src/ohmyapi/core/templates/app/routes.py.j2
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from ohmyapi.router import APIRouter
|
||||
from . import models
|
||||
|
||||
router = APIRouter(prefix="/{{ app_name }}")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def ping():
|
||||
return {
|
||||
"project": "{{ project_name }}",
|
||||
"app": "{{ app_name }}",
|
||||
}
|
||||
|
||||
13
src/ohmyapi/core/templates/project/pyproject.toml.j2
Normal file
13
src/ohmyapi/core/templates/project/pyproject.toml.j2
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[tool.poetry]
|
||||
name = "{{ project_name }}"
|
||||
version = "0.1.0"
|
||||
description = "OhMyAPI project"
|
||||
authors = ["You <you@example.com>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
fastapi = "^0.115"
|
||||
uvicorn = "^0.30"
|
||||
tortoise-orm = "^0.20"
|
||||
aerich = "^0.7"
|
||||
|
||||
5
src/ohmyapi/core/templates/project/settings.py.j2
Normal file
5
src/ohmyapi/core/templates/project/settings.py.j2
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# {{ project_name }} settings.py
|
||||
PROJECT_NAME = "MyProject"
|
||||
DATABASE_URL = "sqlite://db.sqlite3"
|
||||
INSTALLED_APPS = []
|
||||
|
||||
3
src/ohmyapi/db/__init__.py
Normal file
3
src/ohmyapi/db/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from tortoise import fields as field
|
||||
from .model import Model
|
||||
|
||||
90
src/ohmyapi/db/migration_manager.py
Normal file
90
src/ohmyapi/db/migration_manager.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import asyncio
|
||||
from pathlib import Path
|
||||
from aerich import Command
|
||||
from ohmyapi.core import runtime
|
||||
|
||||
|
||||
class MigrationManager:
|
||||
def __init__(self, project):
|
||||
self.project = project
|
||||
self._commands = {}
|
||||
# Compute tortoise_config grouped by app module
|
||||
self._tortoise_config = self._build_tortoise_config()
|
||||
|
||||
def _build_tortoise_config(self) -> dict:
|
||||
"""
|
||||
Build Tortoise config from the flat model_registry,
|
||||
grouping models by app module for Aerich compatibility.
|
||||
"""
|
||||
db_url = self.project.settings.DATABASE_URL
|
||||
registry = self.project.model_registry # flat: model_path -> class
|
||||
|
||||
apps_modules = {}
|
||||
for model_path, model_cls in registry.items():
|
||||
if not isinstance(model_cls, type):
|
||||
raise TypeError(f"Registry value must be a class, got {type(model_cls)}: {model_cls}")
|
||||
# Extract app module by removing the model class name
|
||||
# Example: 'ohmyapi.apps.auth.User' -> 'ohmyapi.apps.auth'
|
||||
app_module = ".".join(model_path.split(".")[:-1])
|
||||
apps_modules.setdefault(app_module, []).append(model_cls)
|
||||
|
||||
# Build Tortoise config
|
||||
apps_config = {}
|
||||
for app_module, models in apps_modules.items():
|
||||
modules_set = set(m.__module__ for m in models)
|
||||
apps_config[app_module] = {
|
||||
"models": list(modules_set),
|
||||
"default_connection": "default",
|
||||
}
|
||||
|
||||
return {
|
||||
"connections": {"default": db_url},
|
||||
"apps": apps_config,
|
||||
}
|
||||
|
||||
def get_apps(self):
|
||||
"""Return app modules extracted from the registry"""
|
||||
return list(self._tortoise_config["apps"].keys())
|
||||
|
||||
def get_migration_location(self, app_module: str) -> str:
|
||||
"""Return the path to the app's migrations folder"""
|
||||
try:
|
||||
module = __import__(app_module, fromlist=["migrations"])
|
||||
if not hasattr(module, "__file__") or module.__file__ is None:
|
||||
raise ValueError(f"Cannot determine filesystem path for app '{app_module}'")
|
||||
app_path = Path(module.__file__).parent
|
||||
migrations_path = app_path / "migrations"
|
||||
migrations_path.mkdir(exist_ok=True)
|
||||
return str(migrations_path)
|
||||
except ModuleNotFoundError:
|
||||
raise ValueError(f"App module '{app_module}' cannot be imported")
|
||||
|
||||
async def init_app_command(self, app_module: str) -> Command:
|
||||
"""Initialize Aerich command for a specific app module"""
|
||||
location = self.get_migration_location(app_module)
|
||||
cmd = Command(
|
||||
tortoise_config=self._tortoise_config,
|
||||
app=app_module,
|
||||
location=location,
|
||||
)
|
||||
await cmd.init()
|
||||
self._commands[app_module] = cmd
|
||||
return cmd
|
||||
|
||||
async def makemigrations(self, app_module: str):
|
||||
"""Generate migrations for a specific app"""
|
||||
cmd = self._commands.get(app_module) or await self.init_app_command(app_module)
|
||||
await cmd.migrate()
|
||||
|
||||
async def migrate(self, app_module: str = None):
|
||||
"""Apply migrations. If app_module is None, migrate all apps"""
|
||||
apps_to_migrate = [app_module] if app_module else self.get_apps()
|
||||
for app in apps_to_migrate:
|
||||
cmd = self._commands.get(app) or await self.init_app_command(app)
|
||||
await cmd.upgrade()
|
||||
|
||||
async def show_migrations(self, app_module: str):
|
||||
"""List migrations for an app"""
|
||||
cmd = self._commands.get(app_module) or await self.init_app_command(app_module)
|
||||
await cmd.history()
|
||||
|
||||
1
src/ohmyapi/db/model/__init__.py
Normal file
1
src/ohmyapi/db/model/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .model import Model, fields
|
||||
80
src/ohmyapi/db/model/model.py
Normal file
80
src/ohmyapi/db/model/model.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from tortoise import fields
|
||||
from tortoise.models import Model as TortoiseModel
|
||||
from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator
|
||||
|
||||
|
||||
class Model(TortoiseModel):
|
||||
"""
|
||||
Base Tortoise model with attached Pydantic schema generators via .Schema
|
||||
"""
|
||||
|
||||
class Schema:
|
||||
"""
|
||||
Provides convenient access to auto-generated Pydantic schemas.
|
||||
"""
|
||||
|
||||
def __init__(self, model_cls):
|
||||
self.model_cls = model_cls
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
# Minimal schema with just the primary key field
|
||||
pk_field = self.model_cls._meta.pk_attr
|
||||
return pydantic_model_creator(
|
||||
self.model_cls, name=f"{self.model_cls.__name__}SchemaId", include=(pk_field,)
|
||||
)
|
||||
|
||||
@property
|
||||
def get(self):
|
||||
# Full schema for reading
|
||||
return pydantic_model_creator(
|
||||
self.model_cls, name=f"{self.model_cls.__name__}SchemaGet"
|
||||
)
|
||||
|
||||
@property
|
||||
def post(self):
|
||||
# Input schema for creation (no readonly fields like ID/PK)
|
||||
return pydantic_model_creator(
|
||||
self.model_cls,
|
||||
name=f"{self.model_cls.__name__}SchemaPost",
|
||||
exclude_readonly=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def put(self):
|
||||
# Input schema for updating
|
||||
return pydantic_model_creator(
|
||||
self.model_cls,
|
||||
name=f"{self.model_cls.__name__}SchemaPut",
|
||||
exclude_readonly=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def delete(self):
|
||||
# Schema for delete operations (just PK)
|
||||
pk_field = self.model_cls._meta.pk_attr
|
||||
return pydantic_model_creator(
|
||||
self.model_cls, name=f"{self.model_cls.__name__}SchemaDelete", include=(pk_field,)
|
||||
)
|
||||
|
||||
@property
|
||||
def list(self):
|
||||
# Schema for list endpoints
|
||||
return pydantic_queryset_creator(self.model_cls)
|
||||
|
||||
def from_fields(self, *fields: str):
|
||||
# Generate schema restricted to given fields
|
||||
valid = [f for f in fields if f in self.model_cls._meta.fields_map]
|
||||
return pydantic_model_creator(
|
||||
self.model_cls,
|
||||
name=f"{self.model_cls.__name__}SchemaFields",
|
||||
include=valid,
|
||||
)
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
"""
|
||||
Automatically attach .Schema to all subclasses
|
||||
"""
|
||||
super().__init_subclass__(**kwargs)
|
||||
cls.Schema = cls.Schema(cls)
|
||||
|
||||
2
src/ohmyapi/router.py
Normal file
2
src/ohmyapi/router.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Loading…
Add table
Add a link
Reference in a new issue