ohmyapi 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl

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.
@@ -11,7 +11,7 @@ from pydantic import BaseModel
11
11
  from ohmyapi.builtin.auth.models import Group, User
12
12
 
13
13
  # Router
14
- router = APIRouter(prefix="/auth", tags=["auth"])
14
+ router = APIRouter(prefix="/auth", tags=["Auth"])
15
15
 
16
16
  # Secrets & config (should come from settings/env in real projects)
17
17
  JWT_SECRET = getattr(settings, "JWT_SECRET", "changeme")
@@ -1,2 +1 @@
1
- from . import models
2
- from . import routes
1
+ from . import models, routes
@@ -1,10 +1,11 @@
1
- from ohmyapi.db import Model, field
2
- from ohmyapi_auth.models import User
3
-
4
1
  from datetime import datetime
5
2
  from decimal import Decimal
6
3
  from uuid import UUID
7
4
 
5
+ from ohmyapi_auth.models import User
6
+
7
+ from ohmyapi.db import Model, field
8
+
8
9
 
9
10
  class Team(Model):
10
11
  id: UUID = field.data.UUIDField(primary_key=True)
@@ -1,19 +1,17 @@
1
- from ohmyapi.router import APIRouter, HTTPException, HTTPStatus
1
+ from typing import List
2
+
2
3
  from ohmyapi.db.exceptions import DoesNotExist
4
+ from ohmyapi.router import APIRouter, HTTPException, HTTPStatus
3
5
 
4
6
  from . import models
5
7
 
6
- from typing import List
7
-
8
8
  # Expose your app's routes via `router = fastapi.APIRouter`.
9
9
  # Use prefixes wisely to avoid cross-app namespace-collisions.
10
10
  # Tags improve the UX of the OpenAPI docs at /docs.
11
11
  router = APIRouter(prefix="/tournament")
12
12
 
13
13
 
14
- @router.get(
15
- "/", tags=["tournament"], response_model=List[models.Tournament.Schema()]
16
- )
14
+ @router.get("/", tags=["tournament"], response_model=List[models.Tournament.Schema()])
17
15
  async def list():
18
16
  """List all tournaments."""
19
17
  return await models.Tournament.Schema().from_queryset(models.Tournament.all())
@@ -30,9 +28,7 @@ async def post(tournament: models.Tournament.Schema(readonly=True)):
30
28
  @router.get("/{id}", tags=["tournament"], response_model=models.Tournament.Schema())
31
29
  async def get(id: str):
32
30
  """Get tournament by id."""
33
- return await models.Tournament.Schema().from_queryset(
34
- models.Tournament.get(id=id)
35
- )
31
+ return await models.Tournament.Schema().from_queryset(models.Tournament.get(id=id))
36
32
 
37
33
 
38
34
  @router.put(
ohmyapi/core/runtime.py CHANGED
@@ -1,12 +1,13 @@
1
1
  # ohmyapi/core/runtime.py
2
- import copy
3
2
  import importlib
4
3
  import importlib.util
5
4
  import json
6
5
  import pkgutil
7
6
  import sys
7
+ from http import HTTPStatus
8
8
  from pathlib import Path
9
- from typing import Any, Dict, Generator, List, Optional
9
+ from types import ModuleType
10
+ from typing import Any, Dict, Generator, List, Optional, Type
10
11
 
11
12
  import click
12
13
  from aerich import Command as AerichCommand
@@ -21,8 +22,9 @@ class Project:
21
22
  """
22
23
  Project runtime loader + Tortoise/Aerich integration.
23
24
 
24
- - injects builtin apps as ohmyapi_<name>
25
- - builds unified tortoise config for runtime
25
+ - aliases builtin apps as ohmyapi_<name>
26
+ - loads all INSTALLED_APPS into scope
27
+ - builds unified tortoise config for ORM runtime
26
28
  - provides makemigrations/migrate methods using Aerich Command API
27
29
  """
28
30
 
@@ -34,8 +36,8 @@ class Project:
34
36
  if str(self.project_path) not in sys.path:
35
37
  sys.path.insert(0, str(self.project_path))
36
38
 
37
- # Pre-register builtin apps as ohmyapi_<name>.
38
- # This makes all builtin apps easily loadable via f"ohmyapi_{app_name}".
39
+ # Alias builtin apps as ohmyapi_<name>.
40
+ # We need this, because Tortoise app-names may not include dots `.`.
39
41
  spec = importlib.util.find_spec("ohmyapi.builtin")
40
42
  if spec and spec.submodule_search_locations:
41
43
  for _, modname, _ in pkgutil.iter_modules(spec.submodule_search_locations):
@@ -107,7 +109,7 @@ class Project:
107
109
  }
108
110
 
109
111
  for app_name, app in self._apps.items():
110
- modules = list(dict.fromkeys(app.model_modules))
112
+ modules = list(app.models.keys())
111
113
  if modules:
112
114
  config["apps"][app_name] = {
113
115
  "models": modules,
@@ -119,11 +121,20 @@ class Project:
119
121
  def build_aerich_command(
120
122
  self, app_label: str, db_url: Optional[str] = None
121
123
  ) -> AerichCommand:
124
+ """
125
+ Build Aerich command for app with given app_label.
126
+
127
+ Aerich needs to see only the app of interest, but with the extra model
128
+ "aerich.models".
129
+ """
122
130
  if app_label not in self._apps:
123
131
  raise RuntimeError(f"App '{app_label}' is not registered")
124
132
 
125
133
  # Get a fresh copy of the config (without aerich.models anywhere)
126
- tortoise_cfg = copy.deepcopy(self.build_tortoise_config(db_url=db_url))
134
+ tortoise_cfg = self.build_tortoise_config(db_url=db_url)
135
+
136
+ # Prevent leaking other app's models to Aerich.
137
+ tortoise_cfg["apps"] = {app_label: tortoise_cfg["apps"][app_label]}
127
138
 
128
139
  # Append aerich.models to the models list of the target app only
129
140
  tortoise_cfg["apps"][app_label]["models"].append("aerich.models")
@@ -199,33 +210,18 @@ class App:
199
210
  self.project = project
200
211
  self.name = name
201
212
 
202
- # The list of module paths (e.g. "ohmyapi_auth.models") for Tortoise and Aerich
203
- self.model_modules: List[str] = []
213
+ # Reference to this app's models modules. Tortoise needs to know the
214
+ # modules where to lookup models for this app.
215
+ self._models: Dict[str, ModuleType] = {}
204
216
 
205
- # The APIRouter
206
- self.router: APIRouter = APIRouter()
217
+ # Reference to this app's routes modules.
218
+ self._routers: Dict[str, ModuleType] = {}
207
219
 
208
220
  # Import the app, so its __init__.py runs.
209
- importlib.import_module(self.name)
210
-
211
- # Load the models
212
- try:
213
- models_mod = importlib.import_module(f"{self.name}.models")
214
- self.model_modules.append(f"{self.name}.models")
215
- except ModuleNotFoundError:
216
- pass
221
+ mod: ModuleType = importlib.import_module(name)
217
222
 
218
- # Locate the APIRouter
219
- try:
220
- routes_mod = importlib.import_module(f"{self.name}.routes")
221
- for attr_name in dir(routes_mod):
222
- if attr_name.startswith("__"):
223
- continue
224
- attr = getattr(routes_mod, attr_name)
225
- if isinstance(attr, APIRouter):
226
- self.router.include_router(attr)
227
- except ModuleNotFoundError:
228
- pass
223
+ self.__load_models(f"{self.name}.models")
224
+ self.__load_routes(f"{self.name}.routes")
229
225
 
230
226
  def __repr__(self):
231
227
  return json.dumps(self.dict(), indent=2)
@@ -233,42 +229,127 @@ class App:
233
229
  def __str__(self):
234
230
  return self.__repr__()
235
231
 
236
- def _serialize_route(self, route):
237
- """Convert APIRoute to JSON-serializable dict."""
232
+ def __load_models(self, mod_name: str):
233
+ """
234
+ Recursively scan through a module and collect all models.
235
+ If the module is a package, iterate through its submodules.
236
+ """
237
+
238
+ # An app may come without any models.
239
+ try:
240
+ importlib.import_module(mod_name)
241
+ except ModuleNotFoundError:
242
+ print(f"no models detected: {mod_name}")
243
+ return
244
+
245
+ # Acoid duplicates.
246
+ visited: set[str] = set()
247
+
248
+ def walk(mod_name: str):
249
+ mod = importlib.import_module(mod_name)
250
+ if mod_name in visited:
251
+ return
252
+ visited.add(mod_name)
253
+
254
+ for name, value in vars(mod).copy().items():
255
+ if (
256
+ isinstance(value, type)
257
+ and issubclass(value, Model)
258
+ and not name == Model.__name__
259
+ ):
260
+ self._models[mod_name] = self._models.get(mod_name, []) + [value]
261
+
262
+ # if it's a package, recurse into submodules
263
+ if hasattr(mod, "__path__"):
264
+ for _, subname, _ in pkgutil.iter_modules(
265
+ mod.__path__, mod.__name__ + "."
266
+ ):
267
+ walk(subname)
268
+
269
+ # Walk the walk.
270
+ walk(mod_name)
271
+
272
+ def __load_routes(self, mod_name: str):
273
+ """
274
+ Recursively scan through a module and collect all APIRouters.
275
+ If the module is a package, iterate through all its submodules.
276
+ """
277
+
278
+ # An app may come without any routes.
279
+ try:
280
+ importlib.import_module(mod_name)
281
+ except ModuleNotFound:
282
+ print(f"no routes detected: {mod_name}")
283
+ return
284
+
285
+ # Avoid duplicates.
286
+ visited: set[str] = set()
287
+
288
+ def walk(mod_name: str):
289
+ mod = importlib.import_module(mod_name)
290
+ if mod.__name__ in visited:
291
+ return
292
+ visited.add(mod.__name__)
293
+
294
+ for name, value in vars(mod).copy().items():
295
+ if isinstance(value, APIRouter) and not name == APIRouter.__name__:
296
+ self._routers[mod_name] = self._routers.get(mod_name, []) + [value]
297
+
298
+ # if it's a package, recurse into submodules
299
+ if hasattr(mod, "__path__"):
300
+ for _, subname, _ in pkgutil.iter_modules(
301
+ mod.__path__, mod.__name__ + "."
302
+ ):
303
+ submod = importlib.import_module(subname)
304
+ walk(submod)
305
+
306
+ # Walk the walk.
307
+ walk(mod_name)
308
+
309
+ def __serialize_route(self, route):
310
+ """
311
+ Convert APIRoute to JSON-serializable dict.
312
+ """
238
313
  return {
239
314
  "path": route.path,
240
- "name": route.name,
241
- "methods": list(route.methods),
242
- "endpoint": route.endpoint.__name__, # just the function name
243
- "response_model": (
244
- getattr(route, "response_model", None).__name__
245
- if getattr(route, "response_model", None)
246
- else None
247
- ),
248
- "tags": getattr(route, "tags", None),
315
+ "method": list(route.methods)[0],
316
+ "endpoint": f"{route.endpoint.__module__}.{route.endpoint.__name__}",
249
317
  }
250
318
 
251
- def _serialize_router(self):
252
- return [self._serialize_route(route) for route in self.routes]
319
+ def __serialize_router(self):
320
+ return [self.__serialize_route(route) for route in self.routes]
253
321
 
254
- def dict(self) -> Dict[str, Any]:
322
+ @property
323
+ def models(self) -> List[ModuleType]:
324
+ """
325
+ Return a list of all loaded models.
326
+ """
327
+ out = []
328
+ for module in self._models:
329
+ for model in self._models[module]:
330
+ out.append(model)
255
331
  return {
256
- "models": [m.__name__ for m in self.models],
257
- "routes": self._serialize_router(),
332
+ module: out,
258
333
  }
259
334
 
260
- @property
261
- def models(self) -> Generator[Model, None, None]:
262
- for mod in self.model_modules:
263
- models_mod = importlib.import_module(mod)
264
- for obj in models_mod.__dict__.values():
265
- if (
266
- isinstance(obj, type)
267
- and getattr(obj, "_meta", None) is not None
268
- and obj.__name__ != "Model"
269
- ):
270
- yield obj
271
-
272
335
  @property
273
336
  def routes(self):
274
- return self.router.routes
337
+ """
338
+ Return an APIRouter with all loaded routes.
339
+ """
340
+ router = APIRouter()
341
+ for routes_mod in self._routers:
342
+ for r in self._routers[routes_mod]:
343
+ router.include_router(r)
344
+ return router.routes
345
+
346
+ def dict(self) -> Dict[str, Any]:
347
+ """
348
+ Convenience method for serializing the runtime data.
349
+ """
350
+ return {
351
+ "models": [
352
+ f"{self.name}.{m.__name__}" for m in self.models[f"{self.name}.models"]
353
+ ],
354
+ "routes": self.__serialize_router(),
355
+ }
ohmyapi/db/model/model.py CHANGED
@@ -30,16 +30,16 @@ UUID.__get_pydantic_core_schema__ = classmethod(__uuid_schema_monkey_patch)
30
30
 
31
31
 
32
32
  class ModelMeta(type(TortoiseModel)):
33
- def __new__(cls, name, bases, attrs):
34
- new_cls = super().__new__(cls, name, bases, attrs)
33
+ def __new__(mcls, name, bases, attrs):
34
+ # Grab the Schema class for further processing.
35
+ schema_opts = attrs.get("Schema", None)
35
36
 
36
- schema_opts = getattr(new_cls, "Schema", None)
37
+ # Let Tortoise's Metaclass do it's thing.
38
+ new_cls = super().__new__(mcls, name, bases, attrs)
37
39
 
38
40
  class BoundSchema:
39
41
  def __call__(self, readonly: bool = False):
40
- if readonly:
41
- return self.readonly
42
- return self.model
42
+ return self.get(readonly)
43
43
 
44
44
  @property
45
45
  def model(self):
@@ -66,6 +66,11 @@ class ModelMeta(type(TortoiseModel)):
66
66
  exclude_readonly=True,
67
67
  )
68
68
 
69
+ def get(self, readonly: bool = False):
70
+ if readonly:
71
+ return self.readonly
72
+ return self.model
73
+
69
74
  new_cls.Schema = BoundSchema()
70
75
  return new_cls
71
76
 
ohmyapi/router.py CHANGED
@@ -1,2 +1,3 @@
1
- from fastapi import APIRouter, Depends, HTTPException
2
1
  from http import HTTPStatus
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: ohmyapi
3
+ Version: 0.2.2
4
+ Summary: Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations
5
+ License-Expression: MIT
6
+ Keywords: fastapi,tortoise,orm,pydantic,async,web-framework
7
+ Author: Brian Wiborg
8
+ Author-email: me@brianwib.org
9
+ Requires-Python: >=3.11
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Provides-Extra: auth
16
+ Requires-Dist: aerich (>=0.9.1,<0.10.0)
17
+ Requires-Dist: argon2-cffi (>=25.1.0,<26.0.0)
18
+ Requires-Dist: argon2-cffi ; extra == "auth"
19
+ Requires-Dist: crypto (>=1.4.1,<2.0.0)
20
+ Requires-Dist: crypto ; extra == "auth"
21
+ Requires-Dist: fastapi (>=0.117.1,<0.118.0)
22
+ Requires-Dist: ipython (>=9.5.0,<10.0.0)
23
+ Requires-Dist: jinja2 (>=3.1.6,<4.0.0)
24
+ Requires-Dist: passlib (>=1.7.4,<2.0.0)
25
+ Requires-Dist: passlib ; extra == "auth"
26
+ Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
27
+ Requires-Dist: pyjwt ; extra == "auth"
28
+ Requires-Dist: python-multipart (>=0.0.20,<0.0.21)
29
+ Requires-Dist: python-multipart ; extra == "auth"
30
+ Requires-Dist: tortoise-orm (>=0.25.1,<0.26.0)
31
+ Requires-Dist: typer (>=0.19.1,<0.20.0)
32
+ Requires-Dist: uvicorn (>=0.36.0,<0.37.0)
33
+ Description-Content-Type: text/markdown
34
+
35
+ # OhMyAPI
36
+
37
+ OhMyAPI is a web-application scaffolding framework and management layer built around `FastAPI`, `TortoiseORM` and `Aerich` migrations.
38
+
39
+ > *Think: Django RestFramework, but less clunky and 100% async.*
40
+
41
+ It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***!
42
+
43
+ **Features**
44
+
45
+ - Django-like project structure and application directories
46
+ - Django-like per-app migrations (makemigrations & migrate) via Aerich
47
+ - Django-like CLI tooling (startproject, startapp, shell, serve, etc)
48
+ - Customizable pydantic model serializer built-in
49
+ - Various optional built-in apps you can hook into your project
50
+ - Highly configurable and customizable
51
+ - 100% async
52
+
53
+ **Goals**
54
+
55
+ - combine FastAPI, TortoiseORM, Aerich migrations and Pydantic into a high-productivity web-application framework
56
+ - tie everything neatly together into a concise and straight-forward API
57
+ - AVOID adding any abstractions on top, unless they make things extremely convenient
58
+
59
+ ## Installation
60
+
61
+ ```
62
+ pipx install ohmyapi
63
+ ```
64
+
65
+
66
+ ## Docs
67
+
68
+ See: `docs/`
69
+
70
+ - [Projects](docs/projects.md)
71
+ - [Apps](docs/apps.md)
72
+ - [Models](docs/models.md)
73
+ - [Migrations](docs/migrations.md)
74
+ - [Routes](docs/routes.md)
75
+
@@ -3,13 +3,13 @@ ohmyapi/__main__.py,sha256=wcCrL4PjG51r5wVKqJhcoJPTLfHW0wNbD31DrUN0MWI,28
3
3
  ohmyapi/builtin/auth/__init__.py,sha256=vOVCSJX8BALzs8h5ZW9507bjoscP37bncMjdMmBXcMM,42
4
4
  ohmyapi/builtin/auth/models.py,sha256=8az4TKdC6PUha3_HyslKinDT2-yAN-aBoYUQcjxm7Js,1728
5
5
  ohmyapi/builtin/auth/permissions.py,sha256=mxsnhF_UGesTFle7v1JHORkNODtQ0qanAL3FtOcMCEY,145
6
- ohmyapi/builtin/auth/routes.py,sha256=ZNtGUZahmUboTNT_LB2F9sbjpSPp86DtgkOOj73mKlc,6304
7
- ohmyapi/builtin/demo/__init__.py,sha256=k1rGtOmMPVZJ1fMPELY0v3k70WyzSp18pstJTkCdFr0,42
8
- ohmyapi/builtin/demo/models.py,sha256=N3LnHLEa5wYBvaQBImCR4SdZvRYGuwM_iyLCeh9QY8s,1403
9
- ohmyapi/builtin/demo/routes.py,sha256=JZbW_ZBirzNEO6PuJO_ZPV4k__cH8rvU9JN6CDhocWc,1821
6
+ ohmyapi/builtin/auth/routes.py,sha256=r887BWea20vinX88QBuxGzQc9BrIi-LqyStM0V1cl1o,6304
7
+ ohmyapi/builtin/demo/__init__.py,sha256=44Yo3mYmlKSKEwVp6O9urr-C_3qDQzCYLMn6B9i6wew,29
8
+ ohmyapi/builtin/demo/models.py,sha256=r06rfuhPJaI2fYsQ24L1JCOd67f2GQNfGnkgKAptOX8,1404
9
+ ohmyapi/builtin/demo/routes.py,sha256=1VTlEttrez6Qnhrz_9sTA-emtfXem0s0BkPVcLvg3k0,1801
10
10
  ohmyapi/cli.py,sha256=dJVNgpW5S4rCc619AEEKBKuEIAmQs153Ls0ZVaea48w,4173
11
11
  ohmyapi/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- ohmyapi/core/runtime.py,sha256=tdFSOu9fV2BjcE5BIfGfm3jjeGw6Tw8jCM6-hk5Be2s,9264
12
+ ohmyapi/core/runtime.py,sha256=6MejxdFxY9zfmAEcFMIly-R5JEhIlzf3hlx9dZWBZrY,11748
13
13
  ohmyapi/core/scaffolding.py,sha256=SA0SYFd7VcqkOn9xuXgj-yOoVqCZMJo68GGFbm34GE4,2663
14
14
  ohmyapi/core/templates/app/__init__.py.j2,sha256=QwVIQVUGZVhdH1d4NrvL7NTsK4-T4cihzYs8UVX2dt4,43
15
15
  ohmyapi/core/templates/app/models.py.j2,sha256=_3w-vFJ5fgsmncsCv34k_wyCMF78jufbSSglns4gbb0,119
@@ -20,9 +20,9 @@ ohmyapi/core/templates/project/settings.py.j2,sha256=So6w1OiL_jU-FyeT8IHueDjGNuE
20
20
  ohmyapi/db/__init__.py,sha256=5QKUycxnN83DOUD_Etoee9tEOYjnZ74deqrSOOx_MiQ,204
21
21
  ohmyapi/db/exceptions.py,sha256=vb4IIUoeYAY6sK42zRtjMy-39IFVi_Qb6mWySTY0jYw,34
22
22
  ohmyapi/db/model/__init__.py,sha256=k3StTNuKatpwZo_Z5JBFa-927eJrzibFE8U4SA82asc,32
23
- ohmyapi/db/model/model.py,sha256=MQAXVDYpCjGFWfn3u2XP4Q6OZzIJrJBWOWcg5sBDhlw,2581
24
- ohmyapi/router.py,sha256=6Exv6sVPVyiIYxxAQbxQhFRX74MKTUPWXIBwC7UZ-ww,82
25
- ohmyapi-0.2.0.dist-info/METADATA,sha256=vXWbYbDs2N51-xAToeAFj0hUDMtp6PMmU48JhRErBeg,10280
26
- ohmyapi-0.2.0.dist-info/WHEEL,sha256=M5asmiAlL6HEcOq52Yi5mmk9KmTVjY2RDPtO4p9DMrc,88
27
- ohmyapi-0.2.0.dist-info/entry_points.txt,sha256=wb3lw8-meAlpiv1mqcQ3m25ukL7djagU_w89GkrC37k,43
28
- ohmyapi-0.2.0.dist-info/RECORD,,
23
+ ohmyapi/db/model/model.py,sha256=ui4g78c5xoS06Dj8Cdk7QgTjRnE68zKeL-AdmeYYPuQ,2776
24
+ ohmyapi/router.py,sha256=5g0U59glu4hxxnIoTSFzb2S2offkOT3eE39aprzVxwo,83
25
+ ohmyapi-0.2.2.dist-info/METADATA,sha256=73UvH4QOy4q4OgAom0k8d7dtSgeqYSG6VhYRGu0WXDA,2556
26
+ ohmyapi-0.2.2.dist-info/WHEEL,sha256=M5asmiAlL6HEcOq52Yi5mmk9KmTVjY2RDPtO4p9DMrc,88
27
+ ohmyapi-0.2.2.dist-info/entry_points.txt,sha256=wb3lw8-meAlpiv1mqcQ3m25ukL7djagU_w89GkrC37k,43
28
+ ohmyapi-0.2.2.dist-info/RECORD,,
@@ -1,436 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: ohmyapi
3
- Version: 0.2.0
4
- Summary: Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations
5
- License-Expression: MIT
6
- Keywords: fastapi,tortoise,orm,pydantic,async,web-framework
7
- Author: Brian Wiborg
8
- Author-email: me@brianwib.org
9
- Requires-Python: >=3.11
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.11
12
- Classifier: Programming Language :: Python :: 3.12
13
- Classifier: Programming Language :: Python :: 3.13
14
- Classifier: Programming Language :: Python :: 3.14
15
- Provides-Extra: auth
16
- Requires-Dist: aerich (>=0.9.1,<0.10.0)
17
- Requires-Dist: argon2-cffi (>=25.1.0,<26.0.0)
18
- Requires-Dist: argon2-cffi ; extra == "auth"
19
- Requires-Dist: crypto (>=1.4.1,<2.0.0)
20
- Requires-Dist: crypto ; extra == "auth"
21
- Requires-Dist: fastapi (>=0.117.1,<0.118.0)
22
- Requires-Dist: ipython (>=9.5.0,<10.0.0)
23
- Requires-Dist: jinja2 (>=3.1.6,<4.0.0)
24
- Requires-Dist: passlib (>=1.7.4,<2.0.0)
25
- Requires-Dist: passlib ; extra == "auth"
26
- Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
27
- Requires-Dist: pyjwt ; extra == "auth"
28
- Requires-Dist: python-multipart (>=0.0.20,<0.0.21)
29
- Requires-Dist: python-multipart ; extra == "auth"
30
- Requires-Dist: tortoise-orm (>=0.25.1,<0.26.0)
31
- Requires-Dist: typer (>=0.19.1,<0.20.0)
32
- Requires-Dist: uvicorn (>=0.36.0,<0.37.0)
33
- Description-Content-Type: text/markdown
34
-
35
- # OhMyAPI
36
-
37
- > Think: Django RestFramework, but less clunky and 100% async.
38
-
39
- OhMyAPI is a Django-flavored web-application scaffolding framework and management layer,
40
- built around FastAPI and TortoiseORM and is thus 100% async.
41
-
42
- It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***!
43
-
44
- **Features**
45
-
46
- - Django-like project structure and application directories
47
- - Django-like per-app migrations (`makemigrations` & `migrate`) via Aerich
48
- - Django-like CLI tooling (`startproject`, `startapp`, `shell`, `serve`, etc)
49
- - Customizable pydantic model serializer built-in
50
- - Various optional built-in apps you can hook into your project
51
- - Highly configurable and customizable
52
- - 100% async
53
-
54
- **Goals**
55
-
56
- - combine `FastAPI`, `TortoiseORM`, `Aerich` migrations and `Pydantic` into a high-productivity web-application framework
57
- - tie everything neatly together into a concise and straight-forward API
58
- - ***AVOID*** adding any abstractions on top, unless they make things extremely convenient
59
-
60
- ---
61
-
62
- ## Getting started
63
-
64
- **Creating a Project**
65
-
66
- ```
67
- pipx install ohmyapi
68
- ohmyapi startproject myproject
69
- cd myproject
70
- ```
71
-
72
- This will create the following directory structure:
73
-
74
- ```
75
- myproject/
76
- - pyproject.toml
77
- - README.md
78
- - settings.py
79
- ```
80
-
81
- Run your project with:
82
-
83
- ```
84
- ohmyapi serve
85
- ```
86
-
87
- In your browser go to:
88
- - http://localhost:8000/docs
89
-
90
- **Creating an App**
91
-
92
- Create a new app by:
93
-
94
- ```
95
- ohmyapi startapp tournament
96
- ```
97
-
98
- This will create the following directory structure:
99
-
100
- ```
101
- myproject/
102
- - tournament/
103
- - __init__.py
104
- - models.py
105
- - routes.py
106
- - pyproject.toml
107
- - README.md
108
- - settings.py
109
- ```
110
-
111
- Add 'tournament' to your `INSTALLED_APPS` in `settings.py`.
112
-
113
- ### Models
114
-
115
- Write your first model in `turnament/models.py`:
116
-
117
- ```python
118
- from ohmyapi.db import Model, field
119
-
120
- from datetime import datetime
121
- from decimal import Decimal
122
- from uuid import UUID
123
-
124
-
125
- class Tournament(Model):
126
- id: UUID = field.data.UUIDField(primary_key=True)
127
- name: str = field.TextField()
128
- created: datetime = field.DatetimeField(auto_now_add=True)
129
-
130
- def __str__(self):
131
- return self.name
132
-
133
-
134
- class Event(Model):
135
- id: UUID = field.data.UUIDField(primary_key=True)
136
- name: str = field.TextField()
137
- tournament: UUID = field.ForeignKeyField('tournament.Tournament', related_name='events')
138
- participants: field.ManyToManyRelation[Team] = field.ManyToManyField('tournament.Team', related_name='events', through='event_team')
139
- modified: datetime = field.DatetimeField(auto_now=True)
140
- prize: Decimal = field.DecimalField(max_digits=10, decimal_places=2, null=True)
141
-
142
- def __str__(self):
143
- return self.name
144
-
145
-
146
- class Team(Model):
147
- id: UUID = field.data.UUIDField(primary_key=True)
148
- name: str = field.TextField()
149
-
150
- def __str__(self):
151
- return self.name
152
- ```
153
-
154
- ### API Routes
155
-
156
- Next, create your endpoints in `tournament/routes.py`:
157
-
158
- ```python
159
- from ohmyapi.router import APIRouter, HTTPException, HTTPStatus
160
- from ohmyapi.db.exceptions import DoesNotExist
161
-
162
- from typing import List
163
-
164
- from .models import Tournament
165
-
166
- # OhMyAPI will automatically pick up all instances of `fastapi.APIRouter` and
167
- # add their routes to the main project router.
168
- #
169
- # Note:
170
- # Use prefixes wisely to avoid cross-app namespace-collisions!
171
- # Tags improve the UX of the OpenAPI docs at /docs.
172
- #
173
- tournament_router = APIRouter(prefix="/tournament", tags=['Tournament'])
174
-
175
-
176
- @tournament_router.get("/", response_model=List[Tournament.Schema()])
177
- async def list():
178
- queryset = Tournament.all()
179
- return await Tournament.Schema.model.from_queryset(queryset)
180
-
181
-
182
- @tournament_router.post("/", status_code=HTTPStatus.CREATED)
183
- async def post(tournament: Tournament.Schema(readonly=True)):
184
- queryset = Tournament.create(**payload.model_dump())
185
- return await Tournament.Schema().from_queryset(queryset)
186
-
187
-
188
- @tournament_router.get("/:id", response_model=Tournament.Schema())
189
- async def get(id: str):
190
- try:
191
- queryset = Tournament.get(id=id)
192
- return await Tournament.Schema().from_queryset_single(tournament)
193
- except DoesNotExist:
194
- raise HTTPException(status_code=404, detail="not found")
195
-
196
-
197
- @tournament_router.delete("/:id")
198
- async def delete(id: str):
199
- try:
200
- tournament = await Tournament.get(id=id)
201
- return await Tournament.Schema.model.from_queryset(tournament.delete())
202
- except DoesNotExist:
203
- raise HTTPException(status_code=404, detail="not found")
204
-
205
-
206
- ...
207
- ```
208
-
209
- ## Migrations
210
-
211
- Before we can run the app, we need to create and initialize the database.
212
-
213
- Similar to Django, first run:
214
-
215
- ```
216
- ohmyapi makemigrations [ <app> ] # no app means all INSTALLED_APPS
217
- ```
218
-
219
- This will create a `migrations/` folder in you project root.
220
-
221
- ```
222
- myproject/
223
- - tournament/
224
- - __init__.py
225
- - models.py
226
- - routes.py
227
- - migrations/
228
- - tournament/
229
- - pyproject.toml
230
- - README.md
231
- - settings.py
232
- ```
233
-
234
- Apply your migrations via:
235
-
236
- ```
237
- ohmyapi migrate [ <app> ] # no app means all INSTALLED_APPS
238
- ```
239
-
240
- Run your project:
241
-
242
- ```
243
- ohmyapi serve
244
- ```
245
-
246
- ## Authentication
247
-
248
- A builtin auth app is available.
249
-
250
- Simply add `ohmyapi_auth` to your INSTALLED_APPS and define a JWT_SECRET in your `settings.py`.
251
- Remember to `makemigrations` and `migrate` for the necessary tables to be created in the database.
252
-
253
- `settings.py`:
254
-
255
- ```
256
- INSTALLED_APPS = [
257
- 'ohmyapi_auth',
258
- ...
259
- ]
260
-
261
- JWT_SECRET = "t0ps3cr3t"
262
- ```
263
-
264
- After restarting your project you will have access to the `ohmyapi_auth` app.
265
- It comes with a `User` and `Group` model, as well as endpoints for JWT auth.
266
-
267
- You can use the models as `ForeignKeyField` in your application models:
268
-
269
- ```python
270
- from ohmyapi.db import Model, field
271
- from ohmyapi_auth.models import User
272
-
273
-
274
- class Team(Model):
275
- [...]
276
- members: field.ManyToManyRelation[User] = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams')
277
- [...]
278
- ```
279
-
280
- Remember to run `makemigrations` and `migrate` in order for your model changes to take effect in the database.
281
-
282
- Create a super-user:
283
-
284
- ```
285
- ohmyapi createsuperuser
286
- ```
287
-
288
- ## Permissions
289
-
290
- ### API-Level Permissions
291
-
292
- Use FastAPI's `Depends` pattern to implement API-level access-control.
293
-
294
-
295
- In your `routes.py`:
296
-
297
- ```python
298
- from ohmyapi.router import APIRouter, Depends
299
- from ohmyapi_auth import (
300
- models as auth,
301
- permissions,
302
- )
303
-
304
- from .models import Tournament
305
-
306
- router = APIRouter(prefix="/tournament", tags=["Tournament"])
307
-
308
-
309
- @router.get("/")
310
- async def list(user: auth.User = Depends(permissions.require_authenticated)):
311
- queryset = Tournament.all()
312
- return await Tournament.Schema().from_queryset(queryset)
313
-
314
-
315
- ...
316
- ```
317
-
318
- ### Model-Level Permissions
319
-
320
- Use Tortoise's `Manager` to implement model-level permissions.
321
-
322
- ```python
323
- from ohmyapi.db import Manager
324
- from ohmyapi_auth.models import User
325
-
326
-
327
- class TeamManager(Manager):
328
- async def for_user(self, user: User):
329
- return await self.filter(members=user).all()
330
-
331
-
332
- class Team(Model):
333
- [...]
334
-
335
- class Meta:
336
- manager = TeamManager()
337
- ```
338
-
339
- Use the custom manager in your FastAPI route handler:
340
-
341
- ```python
342
- from ohmyapi.router import APIRouter
343
- from ohmyapi_auth import (
344
- models as auth,
345
- permissions,
346
- )
347
-
348
- router = APIRouter(prefix="/tournament", tags=["Tournament"])
349
-
350
-
351
- @router.get("/teams")
352
- async def teams(user: auth.User = Depends(permissions.require_authenticated)):
353
- queryset = Team.for_user(user)
354
- return await Tournament.Schema().from_queryset(queryset)
355
- ```
356
-
357
- ## Shell
358
-
359
- Similar to Django, you can attach to an interactive shell with your project already loaded inside.
360
-
361
- ```
362
- ohmyapi shell
363
-
364
- Python 3.13.7 (main, Aug 15 2025, 12:34:02) [GCC 15.2.1 20250813]
365
- Type 'copyright', 'credits' or 'license' for more information
366
- IPython 9.5.0 -- An enhanced Interactive Python. Type '?' for help.
367
-
368
- OhMyAPI Shell | Project: {{ project_name }} [{{ project_path }}]
369
- Find your loaded project singleton via identifier: `p`
370
- ```
371
-
372
- ```python
373
- In [1]: p
374
- Out[1]: <ohmyapi.core.runtime.Project at 0x7f00c43dbcb0>
375
-
376
- In [2]: p.apps
377
- Out[2]:
378
- {'ohmyapi_auth': {
379
- "models": [
380
- "Group",
381
- "User"
382
- ],
383
- "routes": [
384
- {
385
- "path": "/auth/login",
386
- "name": "login",
387
- "methods": [
388
- "POST"
389
- ],
390
- "endpoint": "login",
391
- "response_model": null,
392
- "tags": [
393
- "auth"
394
- ]
395
- },
396
- {
397
- "path": "/auth/refresh",
398
- "name": "refresh_token",
399
- "methods": [
400
- "POST"
401
- ],
402
- "endpoint": "refresh_token",
403
- "response_model": null,
404
- "tags": [
405
- "auth"
406
- ]
407
- },
408
- {
409
- "path": "/auth/introspect",
410
- "name": "introspect",
411
- "methods": [
412
- "GET"
413
- ],
414
- "endpoint": "introspect",
415
- "response_model": null,
416
- "tags": [
417
- "auth"
418
- ]
419
- },
420
- {
421
- "path": "/auth/me",
422
- "name": "me",
423
- "methods": [
424
- "GET"
425
- ],
426
- "endpoint": "me",
427
- "response_model": null,
428
- "tags": [
429
- "auth"
430
- ]
431
- }
432
- ]
433
- }}
434
- ```
435
-
436
-