ohmyapi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ohmyapi-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: ohmyapi
3
+ Version: 0.1.0
4
+ Summary: A Django-like but async web-framework based on FastAPI and TortoiseORM.
5
+ License-Expression: MIT
6
+ Keywords: fastapi,tortoise,orm,async,web-framework
7
+ Author: Brian Wiborg
8
+ Author-email: me@brianwib.org
9
+ Requires-Python: >=3.13
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: aerich (>=0.9.1,<0.10.0)
14
+ Requires-Dist: argon2-cffi (>=25.1.0,<26.0.0)
15
+ Requires-Dist: crypto (>=1.4.1,<2.0.0)
16
+ Requires-Dist: fastapi (>=0.117.1,<0.118.0)
17
+ Requires-Dist: ipython (>=9.5.0,<10.0.0)
18
+ Requires-Dist: jinja2 (>=3.1.6,<4.0.0)
19
+ Requires-Dist: passlib (>=1.7.4,<2.0.0)
20
+ Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
21
+ Requires-Dist: python-multipart (>=0.0.20,<0.0.21)
22
+ Requires-Dist: tortoise-orm (>=0.25.1,<0.26.0)
23
+ Requires-Dist: typer (>=0.19.1,<0.20.0)
24
+ Requires-Dist: uvicorn (>=0.36.0,<0.37.0)
25
+ Description-Content-Type: text/markdown
26
+
27
+ # OhMyAPI
28
+
29
+ > OhMyAPI == Application scaffolding for FastAPI+TortoiseORM.
30
+
31
+ OhMyAPI is a blazingly fast, async Python web application framework with batteries included.
32
+ It is built around FastAPI and TortoiseORM and is thus 100% async.
33
+
34
+ Features:
35
+
36
+ - Django-like project-layout and -structure
37
+ - Django-like settings.py
38
+ - Django-like models via TortoiseORM
39
+ - Django-like model.Meta class for model configuration
40
+ - Django-like advanced permissions system
41
+ - Django-like migrations (makemigrations & migrate) via Aerich
42
+ - Django-like CLI for interfacing with your projects (startproject, startapp, shell, serve, etc)
43
+ - various optional builtin apps
44
+ - highly configurable and customizable
45
+ - 100% async
46
+
47
+ ## Getting started
48
+
49
+ **Creating a Project**
50
+
51
+ ```
52
+ pip install ohmyapi # TODO: not yet published
53
+ ohmyapi startproject myproject
54
+ cd myproject
55
+ ```
56
+
57
+ This will create the following directory structure:
58
+
59
+ ```
60
+ myproject/
61
+ - pyproject.toml
62
+ - settings.py
63
+ ```
64
+
65
+ Run your project with:
66
+
67
+ ```
68
+ ohmyapi serve
69
+ ```
70
+
71
+ In your browser go to:
72
+ - http://localhost:8000/docs
73
+
74
+ **Creating an App**
75
+
76
+ Create a new app by:
77
+
78
+ ```
79
+ ohmyapi startapp myapp
80
+ ```
81
+
82
+ This will lead to the following directory structure:
83
+
84
+ ```
85
+ myproject/
86
+ - myapp/
87
+ - __init__.py
88
+ - models.py
89
+ - routes.py
90
+ - pyproject.toml
91
+ - settings.py
92
+ ```
93
+
94
+ Add 'myapp' to your `INSTALLED_APPS` in `settings.py`.
95
+
96
+ Write your first model in `myapp/models.py`:
97
+
98
+ ```python
99
+ from ohmyapi.db import Model, field
100
+
101
+
102
+ class Person(Model):
103
+ id: int = field.IntField(min=1, pk=True)
104
+ name: str = field.CharField(min_length=1, max_length=255)
105
+ username: str = field.CharField(min_length=1, max_length=255, unique=True)
106
+ age: int = field.IntField(min=0)
107
+ ```
108
+
109
+ Next, create your endpoints in `myapp/routes.py`:
110
+
111
+ ```python
112
+ from fastapi import APIRouter, HTTPException
113
+ from tortoise.exceptions import DoesNotExist
114
+
115
+ from .models import Person
116
+
117
+ router = APIRouter(prefix="/myapp")
118
+
119
+
120
+ @router.get("/")
121
+ async def list():
122
+ return await Person.all()
123
+
124
+
125
+ @router.get("/:id")
126
+ async def get(id: int):
127
+ try:
128
+ await Person.get(pk=id)
129
+ except DoesNotExist:
130
+ raise HTTPException(status_code=404, detail="item not found")
131
+
132
+ ...
133
+ ```
134
+
135
+ ## Migrations
136
+
137
+ Before we can run the app, we need to create and initialize the database.
138
+
139
+ Similar to Django, first run:
140
+
141
+ ```
142
+ ohmyapi makemigrations [ <app> ] # no app means all INSTALLED_APPS
143
+ ```
144
+
145
+ And the apply your migrations via:
146
+
147
+ ```
148
+ ohmyapi migrate [ <app> ] # no app means all INSTALLED_APPS
149
+ ```
150
+
151
+ Run your project:
152
+
153
+ ```
154
+ ohmyapi serve
155
+ ```
156
+
157
+ ## Shell
158
+
159
+ Similar to Django, you can attach to an interactive shell with your project already loaded inside.
160
+
161
+ ```
162
+ ohmyapi shell
163
+ ```
164
+
165
+ ## Authentication
166
+
167
+ A builtin auth app is available.
168
+ Simply add `ohmyapi_auth` to your INSTALLED_APPS and define a JWT_SECRET in your `settings.py`.
169
+ Remember to `makemigrations` and `migrate` for the auth tables to be created in the database.
170
+
171
+ `settings.py`:
172
+
173
+ ```
174
+ INSTALLED_APPS = [
175
+ 'ohmyapi_auth',
176
+ ...
177
+ ]
178
+
179
+ JWT_SECRET = "t0ps3cr3t"
180
+ ```
181
+
@@ -0,0 +1,154 @@
1
+ # OhMyAPI
2
+
3
+ > OhMyAPI == Application scaffolding for FastAPI+TortoiseORM.
4
+
5
+ OhMyAPI is a blazingly fast, async Python web application framework with batteries included.
6
+ It is built around FastAPI and TortoiseORM and is thus 100% async.
7
+
8
+ Features:
9
+
10
+ - Django-like project-layout and -structure
11
+ - Django-like settings.py
12
+ - Django-like models via TortoiseORM
13
+ - Django-like model.Meta class for model configuration
14
+ - Django-like advanced permissions system
15
+ - Django-like migrations (makemigrations & migrate) via Aerich
16
+ - Django-like CLI for interfacing with your projects (startproject, startapp, shell, serve, etc)
17
+ - various optional builtin apps
18
+ - highly configurable and customizable
19
+ - 100% async
20
+
21
+ ## Getting started
22
+
23
+ **Creating a Project**
24
+
25
+ ```
26
+ pip install ohmyapi # TODO: not yet published
27
+ ohmyapi startproject myproject
28
+ cd myproject
29
+ ```
30
+
31
+ This will create the following directory structure:
32
+
33
+ ```
34
+ myproject/
35
+ - pyproject.toml
36
+ - settings.py
37
+ ```
38
+
39
+ Run your project with:
40
+
41
+ ```
42
+ ohmyapi serve
43
+ ```
44
+
45
+ In your browser go to:
46
+ - http://localhost:8000/docs
47
+
48
+ **Creating an App**
49
+
50
+ Create a new app by:
51
+
52
+ ```
53
+ ohmyapi startapp myapp
54
+ ```
55
+
56
+ This will lead to the following directory structure:
57
+
58
+ ```
59
+ myproject/
60
+ - myapp/
61
+ - __init__.py
62
+ - models.py
63
+ - routes.py
64
+ - pyproject.toml
65
+ - settings.py
66
+ ```
67
+
68
+ Add 'myapp' to your `INSTALLED_APPS` in `settings.py`.
69
+
70
+ Write your first model in `myapp/models.py`:
71
+
72
+ ```python
73
+ from ohmyapi.db import Model, field
74
+
75
+
76
+ class Person(Model):
77
+ id: int = field.IntField(min=1, pk=True)
78
+ name: str = field.CharField(min_length=1, max_length=255)
79
+ username: str = field.CharField(min_length=1, max_length=255, unique=True)
80
+ age: int = field.IntField(min=0)
81
+ ```
82
+
83
+ Next, create your endpoints in `myapp/routes.py`:
84
+
85
+ ```python
86
+ from fastapi import APIRouter, HTTPException
87
+ from tortoise.exceptions import DoesNotExist
88
+
89
+ from .models import Person
90
+
91
+ router = APIRouter(prefix="/myapp")
92
+
93
+
94
+ @router.get("/")
95
+ async def list():
96
+ return await Person.all()
97
+
98
+
99
+ @router.get("/:id")
100
+ async def get(id: int):
101
+ try:
102
+ await Person.get(pk=id)
103
+ except DoesNotExist:
104
+ raise HTTPException(status_code=404, detail="item not found")
105
+
106
+ ...
107
+ ```
108
+
109
+ ## Migrations
110
+
111
+ Before we can run the app, we need to create and initialize the database.
112
+
113
+ Similar to Django, first run:
114
+
115
+ ```
116
+ ohmyapi makemigrations [ <app> ] # no app means all INSTALLED_APPS
117
+ ```
118
+
119
+ And the apply your migrations via:
120
+
121
+ ```
122
+ ohmyapi migrate [ <app> ] # no app means all INSTALLED_APPS
123
+ ```
124
+
125
+ Run your project:
126
+
127
+ ```
128
+ ohmyapi serve
129
+ ```
130
+
131
+ ## Shell
132
+
133
+ Similar to Django, you can attach to an interactive shell with your project already loaded inside.
134
+
135
+ ```
136
+ ohmyapi shell
137
+ ```
138
+
139
+ ## Authentication
140
+
141
+ A builtin auth app is available.
142
+ Simply add `ohmyapi_auth` to your INSTALLED_APPS and define a JWT_SECRET in your `settings.py`.
143
+ Remember to `makemigrations` and `migrate` for the auth tables to be created in the database.
144
+
145
+ `settings.py`:
146
+
147
+ ```
148
+ INSTALLED_APPS = [
149
+ 'ohmyapi_auth',
150
+ ...
151
+ ]
152
+
153
+ JWT_SECRET = "t0ps3cr3t"
154
+ ```
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "ohmyapi"
3
+ version = "0.1.0"
4
+ description = "A Django-like but async web-framework based on FastAPI and TortoiseORM."
5
+ license = "MIT"
6
+ keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"]
7
+ authors = [
8
+ {name = "Brian Wiborg", email = "me@brianwib.org"}
9
+ ]
10
+ readme = "README.md"
11
+ requires-python = ">=3.13"
12
+ dependencies = [
13
+ "typer (>=0.19.1,<0.20.0)",
14
+ "jinja2 (>=3.1.6,<4.0.0)",
15
+ "fastapi (>=0.117.1,<0.118.0)",
16
+ "tortoise-orm (>=0.25.1,<0.26.0)",
17
+ "aerich (>=0.9.1,<0.10.0)",
18
+ "uvicorn (>=0.36.0,<0.37.0)",
19
+ "ipython (>=9.5.0,<10.0.0)",
20
+ "passlib (>=1.7.4,<2.0.0)",
21
+ "pyjwt (>=2.10.1,<3.0.0)",
22
+ "python-multipart (>=0.0.20,<0.0.21)",
23
+ "crypto (>=1.4.1,<2.0.0)",
24
+ "argon2-cffi (>=25.1.0,<26.0.0)",
25
+ ]
26
+
27
+ [tool.poetry]
28
+ packages = [{include = "ohmyapi", from = "src"}]
29
+
30
+ [tool.poetry.scripts]
31
+ ohmyapi = "ohmyapi.cli:main"
32
+
33
+ [build-system]
34
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
35
+ build-backend = "poetry.core.masonry.api"
36
+
@@ -0,0 +1,2 @@
1
+ from . import db
2
+
@@ -0,0 +1,4 @@
1
+ from . import models
2
+ from . import routes
3
+ from . import permissions
4
+
@@ -0,0 +1,36 @@
1
+ from typing import Optional, List
2
+ from ohmyapi.db import Model, field
3
+ from passlib.context import CryptContext
4
+
5
+ pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
6
+
7
+
8
+ class Group(Model):
9
+ id = field.IntField(pk=True)
10
+ name = field.CharField(max_length=42)
11
+
12
+
13
+ class User(Model):
14
+ id = field.IntField(pk=True)
15
+ username = field.CharField(max_length=150, unique=True)
16
+ password_hash = field.CharField(max_length=128)
17
+ is_admin = field.BooleanField(default=False)
18
+ is_staff = field.BooleanField(default=False)
19
+ groups: Optional[List[Group]] = field.ManyToManyField("ohmyapi_auth.Group", related_name="users")
20
+
21
+ def set_password(self, raw_password: str) -> None:
22
+ """Hash and store the password."""
23
+ self.password_hash = pwd_context.hash(raw_password)
24
+
25
+ def verify_password(self, raw_password: str) -> bool:
26
+ """Verify a plaintext password against the stored hash."""
27
+ return pwd_context.verify(raw_password, self.password_hash)
28
+
29
+ @classmethod
30
+ async def authenticate(cls, username: str, password: str) -> Optional["User"]:
31
+ """Authenticate a user by username and password."""
32
+ user = await cls.filter(username=username).first()
33
+ if user and user.verify_password(password):
34
+ return user
35
+ return None
36
+
@@ -0,0 +1,6 @@
1
+ from .routes import (
2
+ get_current_user,
3
+ require_authenticated,
4
+ require_admin,
5
+ require_staff,
6
+ )
@@ -0,0 +1,136 @@
1
+ import time
2
+ from typing import Dict
3
+
4
+ import jwt
5
+ from fastapi import APIRouter, Body, Depends, Header, HTTPException, status
6
+ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
7
+ from pydantic import BaseModel
8
+
9
+ from ohmyapi.builtin.auth.models import User
10
+
11
+ import settings
12
+
13
+ # Router
14
+ router = APIRouter(prefix="/auth", tags=["auth"])
15
+
16
+ # Secrets & config (should come from settings/env in real projects)
17
+ JWT_SECRET = getattr(settings, "JWT_SECRET", "changeme")
18
+ JWT_ALGORITHM = getattr(settings, "JWT_ALGORITHM", "HS256")
19
+ ACCESS_TOKEN_EXPIRE_SECONDS = getattr(settings, "JWT_ACCESS_TOKEN_EXPIRE_SECONDS", 15 * 60)
20
+ REFRESH_TOKEN_EXPIRE_SECONDS = getattr(settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60)
21
+
22
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
23
+
24
+
25
+ def create_token(data: dict, expires_in: int) -> str:
26
+ to_encode = data.copy()
27
+ to_encode.update({"exp": int(time.time()) + expires_in})
28
+ token = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM)
29
+ if isinstance(token, bytes):
30
+ token = token.decode("utf-8")
31
+ return token
32
+
33
+
34
+ def decode_token(token: str) -> Dict:
35
+ try:
36
+ return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
37
+ except jwt.ExpiredSignatureError:
38
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
39
+ except jwt.InvalidTokenError:
40
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
41
+
42
+
43
+ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
44
+ """Dependency: extract user from access token."""
45
+ payload = decode_token(token)
46
+ username = payload.get("sub")
47
+ if username is None:
48
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
49
+
50
+ user = await User.filter(username=username).first()
51
+ if not user:
52
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
53
+ return user
54
+
55
+
56
+ async def require_authenticated(current_user: User = Depends(get_current_user)) -> User:
57
+ """Ensure the current user is an admin."""
58
+ if not current_user:
59
+ raise HTTPException(403, "Authentication required")
60
+ return current_user
61
+
62
+
63
+ async def require_admin(current_user: User = Depends(get_current_user)) -> User:
64
+ """Ensure the current user is an admin."""
65
+ if not current_user.is_admin:
66
+ raise HTTPException(403, "Admin privileges required")
67
+ return current_user
68
+
69
+
70
+ async def require_staff(current_user: User = Depends(get_current_user)) -> User:
71
+ """Ensure the current user is a staff member."""
72
+ if not current_user.is_staff:
73
+ raise HTTPException(403, "Staff privileges required")
74
+ return current_user
75
+
76
+
77
+ async def require_group(
78
+ group_name: str,
79
+ current_user: User = Depends(get_current_user)
80
+ ) -> User:
81
+ """Ensure the current user belongs to the given group."""
82
+ user_groups = await current_user.groups.all()
83
+ if not any(g.name == group_name for g in user_groups):
84
+ raise HTTPException(
85
+ status_code=403,
86
+ detail=f"User must belong to group '{group_name}'"
87
+ )
88
+ return current_user
89
+
90
+
91
+ class LoginRequest(BaseModel):
92
+ username: str
93
+ password: str
94
+
95
+
96
+ @router.post("/login")
97
+ async def login(form_data: LoginRequest = Body(...)):
98
+ """Login with username & password, returns access and refresh tokens."""
99
+ user = await User.authenticate(form_data.username, form_data.password)
100
+ if not user:
101
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
102
+
103
+ access_token = create_token({"sub": user.username, "type": "access"}, ACCESS_TOKEN_EXPIRE_SECONDS)
104
+ refresh_token = create_token({"sub": user.username, "type": "refresh"}, REFRESH_TOKEN_EXPIRE_SECONDS)
105
+
106
+ return {
107
+ "access_token": access_token,
108
+ "refresh_token": refresh_token,
109
+ "token_type": "bearer"
110
+ }
111
+
112
+
113
+ @router.post("/refresh")
114
+ async def refresh_token(refresh_token: str):
115
+ """Exchange refresh token for new access token."""
116
+ payload = decode_token(refresh_token)
117
+ if payload.get("type") != "refresh":
118
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
119
+
120
+ username = payload.get("sub")
121
+ user = await User.filter(username=username).first()
122
+ if not user:
123
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
124
+
125
+ new_access = create_token({"sub": user.username, "type": "access"}, ACCESS_TOKEN_EXPIRE_SECONDS)
126
+ return {"access_token": new_access, "token_type": "bearer"}
127
+
128
+
129
+ @router.get("/me")
130
+ async def me(current_user: User = Depends(get_current_user)):
131
+ """Return the currently authenticated user."""
132
+ return {
133
+ "username": current_user.username,
134
+ "is_admin": current_user.is_admin,
135
+ "is_staff": current_user.is_staff,
136
+ }
@@ -0,0 +1,114 @@
1
+ import asyncio
2
+ import importlib
3
+ import sys
4
+ import typer
5
+ import uvicorn
6
+
7
+ from getpass import getpass
8
+ from ohmyapi.core import scaffolding, runtime
9
+ from pathlib import Path
10
+
11
+ app = typer.Typer(help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM.")
12
+ banner = """OhMyAPI Shell | Project: {project_name}"""
13
+
14
+
15
+ @app.command()
16
+ def startproject(name: str):
17
+ """Create a new OhMyAPI project in the given directory"""
18
+ scaffolding.startproject(name)
19
+
20
+
21
+ @app.command()
22
+ def startapp(app_name: str, root: str = "."):
23
+ """Create a new app with the given name in your OhMyAPI project"""
24
+ scaffolding.startapp(app_name, root)
25
+
26
+
27
+ @app.command()
28
+ def serve(root: str = ".", host="127.0.0.1", port=8000):
29
+ """
30
+ Run this project in via uvicorn.
31
+ """
32
+ project_path = Path(root)
33
+ project = runtime.Project(project_path)
34
+ app_instance = project.app()
35
+ uvicorn.run(app_instance, host=host, port=int(port), reload=False)
36
+
37
+
38
+ @app.command()
39
+ def shell(root: str = "."):
40
+ """
41
+ Launch an interactive IPython shell with the project and apps loaded.
42
+ """
43
+ project_path = Path(root).resolve()
44
+ project = runtime.Project(project_path)
45
+
46
+ try:
47
+ from IPython import start_ipython
48
+ shell_vars = {
49
+ "settings": project.settings,
50
+ "project": Path(project_path).resolve(),
51
+ }
52
+ from traitlets.config.loader import Config
53
+ c = Config()
54
+ c.TerminalIPythonApp.display_banner = True
55
+ c.TerminalInteractiveShell.banner1 = banner.format(**{
56
+ "project_name": f"{f'{project.settings.PROJECT_NAME} ' if getattr(project.settings, 'PROJECT_NAME', '') else ''}[{Path(project_path).resolve()}]",
57
+ })
58
+ c.TerminalInteractiveShell.banner2 = " "
59
+ start_ipython(argv=[], user_ns=shell_vars, config=c)
60
+ except ImportError:
61
+ typer.echo("IPython is not installed. Falling back to built-in Python shell.")
62
+ import code
63
+ code.interact(local={"settings": project.settings})
64
+
65
+
66
+ @app.command()
67
+ def makemigrations(app: str = "*", name: str = "auto", root: str = "."):
68
+ """
69
+ Create a DB migration based on your models.
70
+ """
71
+ project_path = Path(root).resolve()
72
+ project = runtime.Project(project_path)
73
+ if app == "*":
74
+ for app in project.apps.keys():
75
+ asyncio.run(project.makemigrations(app_label=app, name=name))
76
+ else:
77
+ asyncio.run(project.makemigrations(app_label=app, name=name))
78
+
79
+
80
+ @app.command()
81
+ def migrate(app: str = "*", root: str = "."):
82
+ """
83
+ Run all DB migrations.
84
+ """
85
+ project_path = Path(root).resolve()
86
+ project = runtime.Project(project_path)
87
+ if app == "*":
88
+ for app in project.apps.keys():
89
+ asyncio.run(project.migrate(app))
90
+ else:
91
+ asyncio.run(project.migrate(app))
92
+
93
+
94
+ @app.command()
95
+ def createsuperuser(root: str = "."):
96
+ project_path = Path(root).resolve()
97
+ project = runtime.Project(project_path)
98
+ if not project.is_app_installed("ohmyapi_auth"):
99
+ print("Auth app not installed! Please add 'ohmyapi_auth' to your INSTALLED_APPS.")
100
+ return
101
+
102
+ import asyncio
103
+ import ohmyapi_auth
104
+ username = input("Username: ")
105
+ password = getpass("Password: ")
106
+ user = ohmyapi_auth.models.User(username=username, is_staff=True, is_admin=True)
107
+ user.set_password(password)
108
+ asyncio.run(project.init_orm())
109
+ asyncio.run(user.save())
110
+ asyncio.run(project.close_orm())
111
+
112
+ def main():
113
+ app()
114
+
File without changes
@@ -0,0 +1,246 @@
1
+ # ohmyapi/core/runtime.py
2
+ import copy
3
+ import importlib
4
+ import importlib.util
5
+ import pkgutil
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional
9
+
10
+ import click
11
+ from aerich import Command as AerichCommand
12
+ from aerich.exceptions import NotInitedError
13
+ from tortoise import Tortoise
14
+ from fastapi import FastAPI, APIRouter
15
+ from ohmyapi.db.model import Model
16
+
17
+
18
+ class App:
19
+ """App container holding runtime data like detected models and routes."""
20
+
21
+ def __init__(self, project: "OhMyAPI Project", name: str):
22
+ self.project = project
23
+ self.name = name
24
+
25
+ # The list of module paths (e.g. "ohmyapi_auth.models") for Tortoise and Aerich
26
+ self.model_modules: List[str] = []
27
+
28
+ # The APIRouter
29
+ self.router: Optional[APIRouter] = None
30
+
31
+ # Import the app, so its __init__.py runs.
32
+ importlib.import_module(self.name)
33
+
34
+ # Load the models
35
+ try:
36
+ models_mod = importlib.import_module(f"{self.name}.models")
37
+ self.model_modules.append(f"{self.name}.models")
38
+ except ModuleNotFoundError:
39
+ pass
40
+
41
+ # Locate the APIRouter
42
+ try:
43
+ routes_mod = importlib.import_module(f"{self.name}.routes")
44
+ router = getattr(routes_mod, "router", None)
45
+ if isinstance(router, APIRouter):
46
+ self.router = router
47
+ except ModuleNotFoundError:
48
+ pass
49
+
50
+ def __repr__(self):
51
+ out = ""
52
+ out += f"App: {self.name}\n"
53
+ out += f"Models:\n"
54
+ for model in self.models:
55
+ out += f" - {model.__name__}\n"
56
+ out += "Routes:\n"
57
+ for route in (self.routes or []):
58
+ out += f" - {route}\n"
59
+ return out
60
+
61
+ def __str__(self):
62
+ return self.__repr__()
63
+
64
+ @property
65
+ def models(self) -> List[Model]:
66
+ models: List[Model] = []
67
+ for mod in self.model_modules:
68
+ models_mod = importlib.import_module(mod)
69
+ for obj in models_mod.__dict__.values():
70
+ if isinstance(obj, type) and getattr(obj, "_meta", None) is not None and obj.__name__ != 'Model':
71
+ models.append(obj)
72
+ return models
73
+
74
+ @property
75
+ def routes(self):
76
+ return self.router.routes
77
+
78
+
79
+ class Project:
80
+ """
81
+ Project runtime loader + Tortoise/Aerich integration.
82
+
83
+ - injects builtin apps as ohmyapi_<name>
84
+ - builds unified tortoise config for runtime
85
+ - provides makemigrations/migrate methods using Aerich Command API
86
+ """
87
+
88
+ def __init__(self, project_path: str):
89
+ self.project_path = Path(project_path).resolve()
90
+ self._apps: Dict[str, App] = {}
91
+ self.migrations_dir = self.project_path / "migrations"
92
+
93
+ if str(self.project_path) not in sys.path:
94
+ sys.path.insert(0, str(self.project_path))
95
+
96
+ # Pre-register builtin apps as ohmyapi_<name>.
97
+ # This makes all builtin apps easily loadable via f"ohmyapi_{app_name}".
98
+ spec = importlib.util.find_spec("ohmyapi.builtin")
99
+ if spec and spec.submodule_search_locations:
100
+ for _, modname, _ in pkgutil.iter_modules(spec.submodule_search_locations):
101
+ full = f"ohmyapi.builtin.{modname}"
102
+ alias = f"ohmyapi_{modname}"
103
+ if alias not in sys.modules:
104
+ orig = importlib.import_module(full)
105
+ sys.modules[alias] = orig
106
+ try:
107
+ sys.modules[f"{alias}.models"] = importlib.import_module(f"{full}.models")
108
+ except ModuleNotFoundError:
109
+ pass
110
+
111
+ # Load settings.py
112
+ try:
113
+ self.settings = importlib.import_module("settings")
114
+ except Exception as e:
115
+ raise RuntimeError(f"Failed to import project settings from {self.project_path}") from e
116
+
117
+ # Load installed apps
118
+ for app_name in getattr(self.settings, "INSTALLED_APPS", []):
119
+ self._apps[app_name] = App(self, name=app_name)
120
+
121
+ @property
122
+ def apps(self):
123
+ return self._apps
124
+
125
+ def is_app_installed(self, name: str) -> bool:
126
+ return name in getattr(self.settings, "INSTALLED_APPS", [])
127
+
128
+ def app(self, generate_schemas: bool = False) -> FastAPI:
129
+ """
130
+ Create a FastAPI app, attach all APIRouters from registered apps,
131
+ and register ORM lifecycle event handlers.
132
+ """
133
+ app = FastAPI(title=getattr(self.settings, "PROJECT_NAME", "OhMyAPI Project"))
134
+
135
+ # Attach routers from apps
136
+ for app_name, app_def in self._apps.items():
137
+ if app_def.router:
138
+ app.include_router(app_def.router)
139
+
140
+ # Startup / shutdown events
141
+ @app.on_event("startup")
142
+ async def _startup():
143
+ await self.init_orm(generate_schemas=generate_schemas)
144
+
145
+ @app.on_event("shutdown")
146
+ async def _shutdown():
147
+ await self.close_orm()
148
+
149
+ return app
150
+
151
+ # --- Config builders ---
152
+ def build_tortoise_config(self, db_url: Optional[str] = None) -> dict:
153
+ """
154
+ Build unified Tortoise config for all registered apps.
155
+ """
156
+ db = db_url or getattr(self.settings, "DATABASE_URL", "sqlite://db.sqlite3")
157
+ config = {
158
+ "connections": {"default": db},
159
+ "apps": {},
160
+ "tortoise": "Tortoise",
161
+ "migrations_dir": str(self.migrations_dir),
162
+ }
163
+
164
+ for app_name, app in self._apps.items():
165
+ modules = list(dict.fromkeys(app.model_modules))
166
+ if modules:
167
+ config["apps"][app_name] = {"models": modules, "default_connection": "default"}
168
+
169
+ return config
170
+
171
+ def build_aerich_command(self, app_label: str, db_url: Optional[str] = None) -> AerichCommand:
172
+ # Resolve label to flat_label
173
+ if app_label in self._apps:
174
+ flat_label = app_label
175
+ else:
176
+ candidate = app_label.replace(".", "_")
177
+ if candidate in self._apps:
178
+ flat_label = candidate
179
+ else:
180
+ raise RuntimeError(f"App '{app_label}' is not registered")
181
+
182
+ # Get a fresh copy of the config (without aerich.models anywhere)
183
+ tortoise_cfg = copy.deepcopy(self.build_tortoise_config(db_url=db_url))
184
+
185
+ # Append aerich.models to the models list of the target app only
186
+ if flat_label in tortoise_cfg["apps"]:
187
+ tortoise_cfg["apps"][flat_label]["models"].append("aerich.models")
188
+
189
+ return AerichCommand(
190
+ tortoise_config=tortoise_cfg,
191
+ app=flat_label,
192
+ location=str(self.migrations_dir)
193
+ )
194
+
195
+ # --- ORM lifecycle ---
196
+ async def init_orm(self, generate_schemas: bool = False) -> None:
197
+ if not Tortoise.apps:
198
+ cfg = self.build_tortoise_config()
199
+ await Tortoise.init(config=cfg)
200
+ if generate_schemas:
201
+ await Tortoise.generate_schemas(safe=True)
202
+
203
+ async def close_orm(self) -> None:
204
+ await Tortoise.close_connections()
205
+
206
+ # --- Migration helpers ---
207
+ async def makemigrations(self, app_label: str, name: str = "auto", db_url: Optional[str] = None) -> None:
208
+ cmd = self.build_aerich_command(app_label, db_url=db_url)
209
+ async with cmd as c:
210
+ await c.init()
211
+ try:
212
+ await c.init_db(safe=True)
213
+ except FileExistsError:
214
+ pass
215
+ try:
216
+ await c.migrate(name=name)
217
+ except (NotInitedError, click.UsageError):
218
+ await c.init_db(safe=True)
219
+ await c.migrate(name=name)
220
+
221
+ async def migrate(self, app_label: Optional[str] = None, db_url: Optional[str] = None) -> None:
222
+ labels: List[str]
223
+ if app_label:
224
+ if app_label in self._apps:
225
+ labels = [app_label]
226
+ else:
227
+ raise RuntimeError(f"Unknown app '{app_label}'")
228
+ else:
229
+ labels = list(self._apps.keys())
230
+
231
+ for lbl in labels:
232
+ cmd = self.build_aerich_command(lbl, db_url=db_url)
233
+ async with cmd as c:
234
+ await c.init()
235
+ try:
236
+ await c.init_db(safe=True)
237
+ except FileExistsError:
238
+ pass
239
+
240
+ try:
241
+ # Try to apply migrations
242
+ await c.upgrade()
243
+ except (NotInitedError, click.UsageError):
244
+ # No migrations yet, initialize then retry upgrade
245
+ await c.init_db(safe=True)
246
+ await c.upgrade()
@@ -0,0 +1,61 @@
1
+ import os
2
+ from pathlib import Path
3
+ from jinja2 import Environment, FileSystemLoader
4
+
5
+ # Base templates directory
6
+ TEMPLATE_DIR = Path(__file__).parent / "templates"
7
+ env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
8
+
9
+
10
+ def render_template_file(template_path: Path, context: dict, output_path: Path):
11
+ """Render a single Jinja2 template file to disk."""
12
+ template = env.get_template(str(template_path.relative_to(TEMPLATE_DIR)).replace("\\", "/"))
13
+ content = template.render(**context)
14
+ os.makedirs(output_path.parent, exist_ok=True)
15
+ with open(output_path, "w", encoding="utf-8") as f:
16
+ f.write(content)
17
+
18
+
19
+ def render_template_dir(template_subdir: str, target_dir: Path, context: dict, subdir_name: str | None = None):
20
+ """
21
+ Recursively render all *.j2 templates from TEMPLATE_DIR/template_subdir into target_dir.
22
+ If subdir_name is given, files are placed inside target_dir/subdir_name.
23
+ """
24
+ template_dir = TEMPLATE_DIR / template_subdir
25
+ for root, _, files in os.walk(template_dir):
26
+ root_path = Path(root)
27
+ rel_root = root_path.relative_to(template_dir) # path relative to template_subdir
28
+
29
+ for f in files:
30
+ if not f.endswith(".j2"):
31
+ continue
32
+
33
+ template_rel_path = rel_root / f
34
+ output_rel_path = Path(*template_rel_path.parts).with_suffix("") # remove .j2
35
+
36
+ # optionally wrap in subdir_name
37
+ if subdir_name:
38
+ output_path = target_dir / subdir_name / output_rel_path
39
+ else:
40
+ output_path = target_dir / output_rel_path
41
+
42
+ render_template_file(template_dir / template_rel_path, context, output_path)
43
+
44
+
45
+ def startproject(name: str):
46
+ """Create a new project: flat structure, all project templates go into <name>/"""
47
+ target_dir = Path(name).resolve()
48
+ os.makedirs(target_dir, exist_ok=True)
49
+ render_template_dir("project", target_dir, {"project_name": name})
50
+ print(f"✅ Project '{name}' created successfully.")
51
+ print(f"🔧 Next, configure your project in {target_dir / 'settings.py'}")
52
+
53
+
54
+ def startapp(name: str, project: str):
55
+ """Create a new app inside a project: templates go into <project_dir>/<name>/"""
56
+ target_dir = Path(project)
57
+ os.makedirs(target_dir, exist_ok=True)
58
+ render_template_dir("app", target_dir, {"project_name": project, "app_name": name}, subdir_name=name)
59
+ print(f"✅ App '{name}' created in project '{target_dir}' successfully.")
60
+ print(f"🔧 Remember to add '{name}' to your INSTALLED_APPS!")
61
+
@@ -0,0 +1,3 @@
1
+ from . import models
2
+ from . import routes
3
+
@@ -0,0 +1,6 @@
1
+ from ohmyapi.db import Model, field
2
+
3
+
4
+ class MyModel(Model):
5
+ id: int = field.IntField(min=1, pk=True)
6
+ ...
@@ -0,0 +1,13 @@
1
+ from ohmyapi.router import APIRouter
2
+ from . import models
3
+
4
+ router = APIRouter(prefix="/{{ app_name }}")
5
+
6
+
7
+ @router.get("/")
8
+ def ping():
9
+ return {
10
+ "project": "{{ project_name }}",
11
+ "app": "{{ app_name }}",
12
+ }
13
+
@@ -0,0 +1,13 @@
1
+ [tool.poetry]
2
+ name = "{{ project_name }}"
3
+ version = "0.1.0"
4
+ description = "OhMyAPI project"
5
+ authors = ["You <you@example.com>"]
6
+
7
+ [tool.poetry.dependencies]
8
+ python = "^3.10"
9
+ fastapi = "^0.115"
10
+ uvicorn = "^0.30"
11
+ tortoise-orm = "^0.20"
12
+ aerich = "^0.7"
13
+
@@ -0,0 +1,5 @@
1
+ # {{ project_name }} settings.py
2
+ PROJECT_NAME = "MyProject"
3
+ DATABASE_URL = "sqlite://db.sqlite3"
4
+ INSTALLED_APPS = []
5
+
@@ -0,0 +1,3 @@
1
+ from tortoise import fields as field
2
+ from .model import Model
3
+
@@ -0,0 +1,90 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+ from aerich import Command
4
+ from ohmyapi.core import runtime
5
+
6
+
7
+ class MigrationManager:
8
+ def __init__(self, project):
9
+ self.project = project
10
+ self._commands = {}
11
+ # Compute tortoise_config grouped by app module
12
+ self._tortoise_config = self._build_tortoise_config()
13
+
14
+ def _build_tortoise_config(self) -> dict:
15
+ """
16
+ Build Tortoise config from the flat model_registry,
17
+ grouping models by app module for Aerich compatibility.
18
+ """
19
+ db_url = self.project.settings.DATABASE_URL
20
+ registry = self.project.model_registry # flat: model_path -> class
21
+
22
+ apps_modules = {}
23
+ for model_path, model_cls in registry.items():
24
+ if not isinstance(model_cls, type):
25
+ raise TypeError(f"Registry value must be a class, got {type(model_cls)}: {model_cls}")
26
+ # Extract app module by removing the model class name
27
+ # Example: 'ohmyapi.apps.auth.User' -> 'ohmyapi.apps.auth'
28
+ app_module = ".".join(model_path.split(".")[:-1])
29
+ apps_modules.setdefault(app_module, []).append(model_cls)
30
+
31
+ # Build Tortoise config
32
+ apps_config = {}
33
+ for app_module, models in apps_modules.items():
34
+ modules_set = set(m.__module__ for m in models)
35
+ apps_config[app_module] = {
36
+ "models": list(modules_set),
37
+ "default_connection": "default",
38
+ }
39
+
40
+ return {
41
+ "connections": {"default": db_url},
42
+ "apps": apps_config,
43
+ }
44
+
45
+ def get_apps(self):
46
+ """Return app modules extracted from the registry"""
47
+ return list(self._tortoise_config["apps"].keys())
48
+
49
+ def get_migration_location(self, app_module: str) -> str:
50
+ """Return the path to the app's migrations folder"""
51
+ try:
52
+ module = __import__(app_module, fromlist=["migrations"])
53
+ if not hasattr(module, "__file__") or module.__file__ is None:
54
+ raise ValueError(f"Cannot determine filesystem path for app '{app_module}'")
55
+ app_path = Path(module.__file__).parent
56
+ migrations_path = app_path / "migrations"
57
+ migrations_path.mkdir(exist_ok=True)
58
+ return str(migrations_path)
59
+ except ModuleNotFoundError:
60
+ raise ValueError(f"App module '{app_module}' cannot be imported")
61
+
62
+ async def init_app_command(self, app_module: str) -> Command:
63
+ """Initialize Aerich command for a specific app module"""
64
+ location = self.get_migration_location(app_module)
65
+ cmd = Command(
66
+ tortoise_config=self._tortoise_config,
67
+ app=app_module,
68
+ location=location,
69
+ )
70
+ await cmd.init()
71
+ self._commands[app_module] = cmd
72
+ return cmd
73
+
74
+ async def makemigrations(self, app_module: str):
75
+ """Generate migrations for a specific app"""
76
+ cmd = self._commands.get(app_module) or await self.init_app_command(app_module)
77
+ await cmd.migrate()
78
+
79
+ async def migrate(self, app_module: str = None):
80
+ """Apply migrations. If app_module is None, migrate all apps"""
81
+ apps_to_migrate = [app_module] if app_module else self.get_apps()
82
+ for app in apps_to_migrate:
83
+ cmd = self._commands.get(app) or await self.init_app_command(app)
84
+ await cmd.upgrade()
85
+
86
+ async def show_migrations(self, app_module: str):
87
+ """List migrations for an app"""
88
+ cmd = self._commands.get(app_module) or await self.init_app_command(app_module)
89
+ await cmd.history()
90
+
@@ -0,0 +1 @@
1
+ from .model import Model, fields
@@ -0,0 +1,80 @@
1
+ from tortoise import fields
2
+ from tortoise.models import Model as TortoiseModel
3
+ from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator
4
+
5
+
6
+ class Model(TortoiseModel):
7
+ """
8
+ Base Tortoise model with attached Pydantic schema generators via .Schema
9
+ """
10
+
11
+ class Schema:
12
+ """
13
+ Provides convenient access to auto-generated Pydantic schemas.
14
+ """
15
+
16
+ def __init__(self, model_cls):
17
+ self.model_cls = model_cls
18
+
19
+ @property
20
+ def id(self):
21
+ # Minimal schema with just the primary key field
22
+ pk_field = self.model_cls._meta.pk_attr
23
+ return pydantic_model_creator(
24
+ self.model_cls, name=f"{self.model_cls.__name__}SchemaId", include=(pk_field,)
25
+ )
26
+
27
+ @property
28
+ def get(self):
29
+ # Full schema for reading
30
+ return pydantic_model_creator(
31
+ self.model_cls, name=f"{self.model_cls.__name__}SchemaGet"
32
+ )
33
+
34
+ @property
35
+ def post(self):
36
+ # Input schema for creation (no readonly fields like ID/PK)
37
+ return pydantic_model_creator(
38
+ self.model_cls,
39
+ name=f"{self.model_cls.__name__}SchemaPost",
40
+ exclude_readonly=True,
41
+ )
42
+
43
+ @property
44
+ def put(self):
45
+ # Input schema for updating
46
+ return pydantic_model_creator(
47
+ self.model_cls,
48
+ name=f"{self.model_cls.__name__}SchemaPut",
49
+ exclude_readonly=True,
50
+ )
51
+
52
+ @property
53
+ def delete(self):
54
+ # Schema for delete operations (just PK)
55
+ pk_field = self.model_cls._meta.pk_attr
56
+ return pydantic_model_creator(
57
+ self.model_cls, name=f"{self.model_cls.__name__}SchemaDelete", include=(pk_field,)
58
+ )
59
+
60
+ @property
61
+ def list(self):
62
+ # Schema for list endpoints
63
+ return pydantic_queryset_creator(self.model_cls)
64
+
65
+ def from_fields(self, *fields: str):
66
+ # Generate schema restricted to given fields
67
+ valid = [f for f in fields if f in self.model_cls._meta.fields_map]
68
+ return pydantic_model_creator(
69
+ self.model_cls,
70
+ name=f"{self.model_cls.__name__}SchemaFields",
71
+ include=valid,
72
+ )
73
+
74
+ def __init_subclass__(cls, **kwargs):
75
+ """
76
+ Automatically attach .Schema to all subclasses
77
+ """
78
+ super().__init_subclass__(**kwargs)
79
+ cls.Schema = cls.Schema(cls)
80
+
@@ -0,0 +1,2 @@
1
+ from fastapi import APIRouter, Depends
2
+