🎉 Let's go!

This commit is contained in:
Brian Wiborg 2025-09-27 00:29:45 +02:00
commit 6f85f24232
No known key found for this signature in database
24 changed files with 2563 additions and 0 deletions

176
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

34
pyproject.toml Normal file
View 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
View file

@ -0,0 +1,2 @@
from . import db

View file

@ -0,0 +1,4 @@
from . import models
from . import routes
from . import permissions

View 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

View file

@ -0,0 +1,6 @@
from .routes import (
get_current_user,
require_authenticated,
require_admin,
require_staff,
)

View 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
View 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()

View file

246
src/ohmyapi/core/runtime.py Normal file
View 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()

View 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!")

View file

@ -0,0 +1,3 @@
from . import models
from . import routes

View file

@ -0,0 +1,6 @@
from ohmyapi.db import Model, field
class MyModel(Model):
id: int = field.IntField(min=1, pk=True)
...

View 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 }}",
}

View 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"

View file

@ -0,0 +1,5 @@
# {{ project_name }} settings.py
PROJECT_NAME = "MyProject"
DATABASE_URL = "sqlite://db.sqlite3"
INSTALLED_APPS = []

View file

@ -0,0 +1,3 @@
from tortoise import fields as field
from .model import Model

View 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()

View file

@ -0,0 +1 @@
from .model import Model, fields

View 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
View file

@ -0,0 +1,2 @@
from fastapi import APIRouter, Depends

0
tests/__init__.py Normal file
View file