From 2e83e65c7e1131a6e759000836795f99a9336109 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 06:15:47 +0200 Subject: [PATCH 001/172] =?UTF-8?q?=F0=9F=94=A5=20Remove=20commit=20artifa?= =?UTF-8?q?ct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/migration_manager.py | 90 ----------------------------- 1 file changed, 90 deletions(-) delete mode 100644 src/ohmyapi/db/migration_manager.py diff --git a/src/ohmyapi/db/migration_manager.py b/src/ohmyapi/db/migration_manager.py deleted file mode 100644 index 031ab38..0000000 --- a/src/ohmyapi/db/migration_manager.py +++ /dev/null @@ -1,90 +0,0 @@ -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() - From 7cf7d6ccfc305d3c6b5cfde89d275171cacebe02 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 06:16:11 +0200 Subject: [PATCH 002/172] =?UTF-8?q?=F0=9F=94=96=200.1.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a09e28c..2e5b877 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.5" +version = "0.1.6" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 3958c51213f2d114c780f99584994555e6b9f616 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 12:29:45 +0200 Subject: [PATCH 003/172] =?UTF-8?q?=E2=99=BB=EF=B8=8FRefactor=20settings.p?= =?UTF-8?q?y.j2=20to=20new=20project=20syntax?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/templates/project/pyproject.toml.j2 | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/ohmyapi/core/templates/project/pyproject.toml.j2 b/src/ohmyapi/core/templates/project/pyproject.toml.j2 index ea70124..7aa7186 100644 --- a/src/ohmyapi/core/templates/project/pyproject.toml.j2 +++ b/src/ohmyapi/core/templates/project/pyproject.toml.j2 @@ -1,13 +1,37 @@ -[tool.poetry] +[project] name = "{{ project_name }}" version = "0.1.0" description = "OhMyAPI project" -authors = ["You "] +authors = [ + { name = "You", email = "you@you.tld" } +] +requires-python = ">=3.13" +readme = "README.md" +license = { text = "MIT" } -[tool.poetry.dependencies] -python = "^3.10" -fastapi = "^0.115" -uvicorn = "^0.30" -tortoise-orm = "^0.20" -aerich = "^0.7" +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.group.dev.dependencies] +ipython = ">=9.5.0,<10.0.0" + +[project.optional-dependencies] +auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"] + +[tool.poetry] +package-mode = false + +[project.scripts] +{{ project_name }} = "ohmyapi.cli:app" From df2d2fd89cdf35f4abb6b405c49f0608240c461c Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 12:30:28 +0200 Subject: [PATCH 004/172] =?UTF-8?q?=F0=9F=93=9D=20Add=20README.md.j2=20to?= =?UTF-8?q?=20project=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/templates/project/README.md.j2 | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/ohmyapi/core/templates/project/README.md.j2 diff --git a/src/ohmyapi/core/templates/project/README.md.j2 b/src/ohmyapi/core/templates/project/README.md.j2 new file mode 100644 index 0000000..26af5ab --- /dev/null +++ b/src/ohmyapi/core/templates/project/README.md.j2 @@ -0,0 +1,2 @@ +# {{ project_name }} + From 091e8a46053d3054d5f09bceb0b4a7629dd4e4f2 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 12:30:50 +0200 Subject: [PATCH 005/172] =?UTF-8?q?=E2=99=BB=EF=B8=8FRemove=20obsolete=20m?= =?UTF-8?q?ain()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/cli.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index e415832..9aac0da 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -109,6 +109,3 @@ def createsuperuser(root: str = "."): asyncio.run(user.save()) asyncio.run(project.close_orm()) -def main(): - app() - From 0baedd94d901cc8c340ad57bdae036bb73b9c425 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 12:31:13 +0200 Subject: [PATCH 006/172] =?UTF-8?q?=E2=9C=A8=20Add=20=5F=5Fmain=5F=5F.py?= =?UTF-8?q?=20package=20entrypoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/__main__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/ohmyapi/__main__.py diff --git a/src/ohmyapi/__main__.py b/src/ohmyapi/__main__.py new file mode 100644 index 0000000..f8cd76f --- /dev/null +++ b/src/ohmyapi/__main__.py @@ -0,0 +1,3 @@ +from .cli import app + +app() From 8d486001b68d251bd62df3177cc771f940cbbb17 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 12:32:03 +0200 Subject: [PATCH 007/172] =?UTF-8?q?=E2=99=BB=EF=B8=8FTidy=20up=20pyproject?= =?UTF-8?q?.toml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poetry.lock | 63 ++++++++++++++++++++++++++------------------------ pyproject.toml | 42 +++++++++++++++++---------------- 2 files changed, 55 insertions(+), 50 deletions(-) diff --git a/poetry.lock b/poetry.lock index 24df997..2e46d46 100644 --- a/poetry.lock +++ b/poetry.lock @@ -58,14 +58,14 @@ files = [ [[package]] name = "anyio" -version = "4.10.0" +version = "4.11.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, - {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, + {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, + {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, ] [package.dependencies] @@ -73,7 +73,7 @@ idna = ">=2.8" sniffio = ">=1.1" [package.extras] -trio = ["trio (>=0.26.1)"] +trio = ["trio (>=0.31.0)"] [[package]] name = "argon2-cffi" @@ -138,7 +138,7 @@ version = "3.0.0" description = "Annotate AST trees with source code positions" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"}, {file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"}, @@ -382,12 +382,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} [[package]] name = "crypto" @@ -411,7 +411,7 @@ version = "5.2.1" description = "Decorators for Humans" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, @@ -441,7 +441,7 @@ version = "2.2.1" description = "Get the currently executing AST node of a frame, and other information" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, @@ -505,7 +505,7 @@ version = "9.5.0" description = "IPython: Productive Interactive Computing" optional = false python-versions = ">=3.11" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "ipython-9.5.0-py3-none-any.whl", hash = "sha256:88369ffa1d5817d609120daa523a6da06d02518e582347c29f8451732a9c5e72"}, {file = "ipython-9.5.0.tar.gz", hash = "sha256:129c44b941fe6d9b82d36fc7a7c18127ddb1d6f02f78f867f402e2e3adde3113"}, @@ -537,7 +537,7 @@ version = "1.1.1" description = "Defines a variety of Pygments lexers for highlighting IPython code." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"}, {file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"}, @@ -565,7 +565,7 @@ version = "0.19.2" description = "An autocompletion tool for Python that can be used for text editors." optional = false python-versions = ">=3.6" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, @@ -698,7 +698,7 @@ version = "0.1.7" description = "Inline Matplotlib backend for Jupyter" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, @@ -741,7 +741,7 @@ version = "0.8.5" description = "A Python Parser" optional = false python-versions = ">=3.6" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887"}, {file = "parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a"}, @@ -775,7 +775,7 @@ version = "4.9.0" description = "Pexpect allows easy control of interactive console applications." optional = false python-versions = "*" -groups = ["main"] +groups = ["main", "dev"] markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, @@ -791,7 +791,7 @@ version = "3.0.52" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, @@ -806,7 +806,7 @@ version = "0.7.0" description = "Run a subprocess in a pseudo terminal" optional = false python-versions = "*" -groups = ["main"] +groups = ["main", "dev"] markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, @@ -819,7 +819,7 @@ version = "0.2.3" description = "Safely evaluate AST nodes without side effects" optional = false python-versions = "*" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, @@ -981,7 +981,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -1204,7 +1204,7 @@ version = "0.6.3" description = "Extract data from python stack frames and tracebacks for informative displays" optional = false python-versions = "*" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, @@ -1268,7 +1268,7 @@ version = "5.14.3" description = "Traitlets Python configuration system" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, @@ -1280,14 +1280,14 @@ test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0, [[package]] name = "typer" -version = "0.19.1" +version = "0.19.2" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "typer-0.19.1-py3-none-any.whl", hash = "sha256:914b2b39a1da4bafca5f30637ca26fa622a5bf9f515e5fdc772439f306d5682a"}, - {file = "typer-0.19.1.tar.gz", hash = "sha256:cb881433a4b15dacc875bb0583d1a61e78497806741f9aba792abcab390c03e6"}, + {file = "typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9"}, + {file = "typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca"}, ] [package.dependencies] @@ -1343,14 +1343,14 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.36.0" +version = "0.36.1" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "uvicorn-0.36.0-py3-none-any.whl", hash = "sha256:6bb4ba67f16024883af8adf13aba3a9919e415358604ce46780d3f9bdc36d731"}, - {file = "uvicorn-0.36.0.tar.gz", hash = "sha256:527dc68d77819919d90a6b267be55f0e76704dca829d34aea9480be831a9b9d9"}, + {file = "uvicorn-0.36.1-py3-none-any.whl", hash = "sha256:059086ecb470a021553f17bf860fce2095611d92fb8b669c44325b3435a0a654"}, + {file = "uvicorn-0.36.1.tar.gz", hash = "sha256:048e68f2a0fe291cd848ed076f18c026e1b0bc69991495f087634ac9a41e8706"}, ] [package.dependencies] @@ -1366,13 +1366,16 @@ version = "0.2.14" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = ">=3.6" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1"}, {file = "wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605"}, ] +[extras] +auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"] + [metadata] lock-version = "2.1" python-versions = ">=3.13" -content-hash = "b1f2f4159b02bf80e7bf6f995933ce4417ec6e1cff299da5157dda50e7da0bb6" +content-hash = "16ae1b48820c723ca784e71a454fb4b686c94fc9f01fa81b086df5dcaf512074" diff --git a/pyproject.toml b/pyproject.toml index 2e5b877..bb95db0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,28 +9,30 @@ authors = [ ] 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)", + "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.group.dev.dependencies] +ipython = ">=9.5.0,<10.0.0" + +[project.optional-dependencies] +auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"] + [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" +packages = [ { include = "ohmyapi", from = "src" } ] +[project.scripts] +ohmyapi = "ohmyapi.cli:app" From 970117a474b763d1290f30527bbbac39d6a04425 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 12:46:38 +0200 Subject: [PATCH 008/172] =?UTF-8?q?=F0=9F=93=9D=20Improve=20getting=20star?= =?UTF-8?q?ted=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3d1dc88..101d856 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ # OhMyAPI -> OhMyAPI == Application scaffolding for FastAPI+TortoiseORM. +> Django-flavored application scaffolding and management layer around FastAPI+TortoiseORM. -OhMyAPI is a Django-flavored web-application scaffolding framework. +OhMyAPI is a Django-flavored web-application scaffolding framework and management layer. Built around FastAPI and TortoiseORM, it 100% async. -It is blazingly fast and has batteries included. +It is *blazingly* fast and comes with batteries included. Features: - Django-like project-layout and -structure -- Django-like settings.py +- Django-like prject-level settings.py - Django-like models via TortoiseORM -- Django-like model.Meta class for model configuration -- Django-like advanced permissions system +- Django-like `Model.Meta` class for model configuration +- Easily convert your models to `pydantic` models via `Model.Schema` - 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 +- Various optional builtin apps +- Highly configurable and customizable - 100% async ## Getting started @@ -25,6 +25,7 @@ Features: ``` pip install ohmyapi +pip install 'ohmyapi[auth]' # optionally add PyJWT and all necessary crypto deps ohmyapi startproject myproject cd myproject ``` @@ -34,6 +35,7 @@ This will create the following directory structure: ``` myproject/ - pyproject.toml + - README.md - settings.py ``` @@ -63,6 +65,7 @@ myproject/ - models.py - routes.py - pyproject.toml + - README.md - settings.py ``` @@ -94,13 +97,15 @@ router = APIRouter(prefix="/myapp") @router.get("/") async def list(): - return await Person.Schema.many.from_queryset(Person.all()) + queryset = await Person.all() + return await Person.Schema.many.from_queryset(queryset) @router.get("/:id") async def get(id: int): try: - return await Person.Schema.one(Person.get(pk=id)) + queryset = await Person.get(pk=id) + return await Person.Schema.one(queryset) except DoesNotExist: raise HTTPException(status_code=404, detail="item not found") @@ -128,6 +133,7 @@ myproject/ - migrations/ - myapp/ - pyproject.toml + - README.md - settings.py ``` @@ -156,7 +162,7 @@ ohmyapi shell 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. +Remember to `makemigrations` and `migrate` for the necessary tables to be created in the database. `settings.py`: From 82fe75b0c7083137e90ecf7129a805297636fda7 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 13:07:40 +0200 Subject: [PATCH 009/172] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Improve=20ohmyapi-?= =?UTF-8?q?only=20imports=20in=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/__init__.py | 3 +-- src/ohmyapi/db/exceptions.py | 2 ++ src/ohmyapi/db/model/__init__.py | 2 +- src/ohmyapi/db/model/model.py | 2 +- src/ohmyapi/router.py | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 src/ohmyapi/db/exceptions.py diff --git a/src/ohmyapi/db/__init__.py b/src/ohmyapi/db/__init__.py index 6dfff3b..94b339c 100644 --- a/src/ohmyapi/db/__init__.py +++ b/src/ohmyapi/db/__init__.py @@ -1,3 +1,2 @@ -from tortoise import fields as field -from .model import Model +from .model import Model, field diff --git a/src/ohmyapi/db/exceptions.py b/src/ohmyapi/db/exceptions.py new file mode 100644 index 0000000..19d761c --- /dev/null +++ b/src/ohmyapi/db/exceptions.py @@ -0,0 +1,2 @@ +from tortoise.exceptions import * + diff --git a/src/ohmyapi/db/model/__init__.py b/src/ohmyapi/db/model/__init__.py index c55f1df..15f7765 100644 --- a/src/ohmyapi/db/model/__init__.py +++ b/src/ohmyapi/db/model/__init__.py @@ -1 +1 @@ -from .model import Model, fields +from .model import Model, field diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index 9804f24..2bbc557 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -1,4 +1,4 @@ -from tortoise import fields +from tortoise import fields as field from tortoise.models import Model as TortoiseModel from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator diff --git a/src/ohmyapi/router.py b/src/ohmyapi/router.py index dad91d5..f7d5860 100644 --- a/src/ohmyapi/router.py +++ b/src/ohmyapi/router.py @@ -1,2 +1,2 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException From 3165243755f608665519e8a1d94fe0e4d4a83840 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 13:28:37 +0200 Subject: [PATCH 010/172] =?UTF-8?q?=F0=9F=93=9D=20Reflect=20latest=20chang?= =?UTF-8?q?es=20in=20getting=20started=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 54 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 101d856..4de2d3f 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,33 @@ # OhMyAPI -> Django-flavored application scaffolding and management layer around FastAPI+TortoiseORM. +> Think: Micro-Django, but API-first, less clunky and 100% async. OhMyAPI is a Django-flavored web-application scaffolding framework and management layer. -Built around FastAPI and TortoiseORM, it 100% async. -It is *blazingly* fast and comes with batteries included. +Built around FastAPI and TortoiseORM, it is 100% async. -Features: +It is ***blazingly fast***, ***fun*** to use and comes with ***batteries included***! + +**Features** - Django-like project-layout and -structure - Django-like prject-level settings.py - Django-like models via TortoiseORM - Django-like `Model.Meta` class for model configuration -- Easily convert your models to `pydantic` models via `Model.Schema` +- Easily convert your query results to `pydantic` models via `Model.Schema` - Django-like migrations (makemigrations & migrate) via Aerich -- Django-like CLI for interfacing with your projects (startproject, startapp, shell, serve, etc) -- Various optional builtin apps +- Django-like CLI tooling (`startproject`, `startapp`, `shell`, `serve`, etc) +- Various optional builtin apps you can hook into your project - Highly configurable and customizable - 100% async +--- + ## Getting started **Creating a Project** ``` pip install ohmyapi -pip install 'ohmyapi[auth]' # optionally add PyJWT and all necessary crypto deps ohmyapi startproject myproject cd myproject ``` @@ -56,7 +58,7 @@ Create a new app by: ohmyapi startapp myapp ``` -This will lead to the following directory structure: +This will create the following directory structure: ``` myproject/ @@ -71,6 +73,8 @@ myproject/ Add 'myapp' to your `INSTALLED_APPS` in `settings.py`. +### Models + Write your first model in `myapp/models.py`: ```python @@ -84,11 +88,13 @@ class Person(Model): age: int = field.IntField(min=0) ``` +### API Routes + Next, create your endpoints in `myapp/routes.py`: ```python -from fastapi import APIRouter, HTTPException -from tortoise.exceptions import DoesNotExist +from ohmyapi.router import APIRouter, HTTPException +from ohmyapi.db.exceptions import DoesNotExist from .models import Person @@ -175,9 +181,35 @@ INSTALLED_APPS = [ JWT_SECRET = "t0ps3cr3t" ``` +After restarting your project you will have access to the `ohmyapi_auth` app. +It comes with a `User` and `Group` model, as well as endpoints for JWT auth. + Create a super-user: ``` ohmyapi createsuperuser ``` +## Permissions + +### API-Level + +Use FastAPI's `Depends` pattern to implement API-level access-control. + + +In your `routes.py`: + +```python +from ohmyapi.router import APIRouter, Depends + +from ohmyapi_auth.models import User +from ohmyapi_auth.permissions import require_authenticated + +router = APIRouter(prefix="/myapp") + + +@router.get("/") +def must_be_authenticated(user: User = Depends(require_authenticated)): + return {"user": user} +``` + From 8f4648643d25fdcfd6e9fd63efd6c592b6eaefcc Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 13:48:49 +0200 Subject: [PATCH 011/172] =?UTF-8?q?=F0=9F=93=9D=20Use=20Tortoise's=20Tourn?= =?UTF-8?q?ement=20example=20for=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 86 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 4de2d3f..d670cc5 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,14 @@ In your browser go to: Create a new app by: ``` -ohmyapi startapp myapp +ohmyapi startapp tournament ``` This will create the following directory structure: ``` myproject/ - - myapp/ + - tournament/ - __init__.py - models.py - routes.py @@ -71,47 +71,69 @@ myproject/ - settings.py ``` -Add 'myapp' to your `INSTALLED_APPS` in `settings.py`. +Add 'tournament' to your `INSTALLED_APPS` in `settings.py`. ### Models -Write your first model in `myapp/models.py`: +Write your first model in `turnament/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) +class Tournament(Model): + id = field.IntField(primary_key=True) + name = field.TextField() + created = field.DatetimeField(auto_now_add=True) + + def __str__(self): + return self.name + + +class Event(Model): + id = field.IntField(primary_key=True) + name = field.TextField() + tournament = field.ForeignKeyField('tournament.Tournament', related_name='events') + participants = field.ManyToManyField('torunament.Team', related_name='events', through='event_team') + modified = field.DatetimeField(auto_now=True) + prize = field.DecimalField(max_digits=10, decimal_places=2, null=True) + + def __str__(self): + return self.name + + +class Team(Model): + id = field.IntField(primary_key=True) + name = field.TextField() + + def __str__(self): + return self.name ``` ### API Routes -Next, create your endpoints in `myapp/routes.py`: +Next, create your endpoints in `tournament/routes.py`: ```python from ohmyapi.router import APIRouter, HTTPException from ohmyapi.db.exceptions import DoesNotExist -from .models import Person +from .models import Tournament -router = APIRouter(prefix="/myapp") +router = APIRouter(prefix="/tournament") @router.get("/") async def list(): - queryset = await Person.all() - return await Person.Schema.many.from_queryset(queryset) + queryset = Tournament.all() + return await Tournament.Schema.many.from_queryset(queryset) @router.get("/:id") async def get(id: int): try: - queryset = await Person.get(pk=id) - return await Person.Schema.one(queryset) + queryset = Tournament.get(pk=id) + return await Tournament.Schema.one(queryset) except DoesNotExist: raise HTTPException(status_code=404, detail="item not found") @@ -132,12 +154,12 @@ This will create a `migrations/` folder in you project root. ``` myproject/ - - myapp/ + - tournament/ - __init__.py - models.py - routes.py - migrations/ - - myapp/ + - tournament/ - pyproject.toml - README.md - settings.py @@ -184,6 +206,17 @@ JWT_SECRET = "t0ps3cr3t" After restarting your project you will have access to the `ohmyapi_auth` app. It comes with a `User` and `Group` model, as well as endpoints for JWT auth. +You can use the models as `ForeignKeyField` in your application models: + +```python +class Team(Model): + [...] + members = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams') + [...] +``` + +Remember to run `makemigrations` and `migrate` in order for your model changes to take effect in the database. + Create a super-user: ``` @@ -203,13 +236,22 @@ In your `routes.py`: from ohmyapi.router import APIRouter, Depends from ohmyapi_auth.models import User -from ohmyapi_auth.permissions import require_authenticated +from ohmyapi_auth import ( + models as auth, + permissions, +) -router = APIRouter(prefix="/myapp") +from .models import Tournament + +router = APIRouter(prefix="/tournament") @router.get("/") -def must_be_authenticated(user: User = Depends(require_authenticated)): - return {"user": user} +async def list(user: auth.User = Depends(permissions.require_authenticated)): + queryset = Tournament.all() + return await Tournament.Schema.many.from_queryset(queryset) + + +... ``` From 3d61ecd2168996e1f8db53188fecde6b105bfb8d Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 14:08:31 +0200 Subject: [PATCH 012/172] =?UTF-8?q?=F0=9F=8E=A8=20tortoise.manager.Manager?= =?UTF-8?q?=20via=20ohmyapi.db?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ohmyapi/db/__init__.py b/src/ohmyapi/db/__init__.py index 94b339c..3d40706 100644 --- a/src/ohmyapi/db/__init__.py +++ b/src/ohmyapi/db/__init__.py @@ -1,2 +1,3 @@ from .model import Model, field +from tortoise.manager import Manager From 73785faebf1c9d33b94801983084339315176b0d Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 14:09:14 +0200 Subject: [PATCH 013/172] =?UTF-8?q?=F0=9F=93=9D=20Add=20model-level=20perm?= =?UTF-8?q?issions=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d670cc5..d42fb1d 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ ohmyapi createsuperuser ## Permissions -### API-Level +### API-Level Permissions Use FastAPI's `Depends` pattern to implement API-level access-control. @@ -255,3 +255,23 @@ async def list(user: auth.User = Depends(permissions.require_authenticated)): ... ``` +### Model-Level Permissions + +Use Tortoise's `Manager` to implement model-layer permissions. + +```python +from ohmyapi.db import Manager +from typing import Callable + + +class TeamManager(Manager): + async def for_user(self, user): + return await self.filter(members=user).all() + + +class Team(Model): + [...] + + class Meta: + manager = TeamManager() +``` From 018587618e2ed47c8052448aabe230bdf22d3678 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 14:10:56 +0200 Subject: [PATCH 014/172] =?UTF-8?q?=F0=9F=94=96=200.1.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bb95db0..e37f0a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.6" +version = "0.1.7" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 51037b615a26e6dcc058b1411e1a03a756c70faf Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:00:13 +0200 Subject: [PATCH 015/172] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20field.data.UUIDFie?= =?UTF-8?q?ld=20as=20Models.id=20on=20auth=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/models.py | 4 +- src/ohmyapi/builtin/auth/permissions.py | 1 + src/ohmyapi/builtin/auth/routes.py | 60 +++++++++++++++++++------ 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index e98363f..8b2d720 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -7,12 +7,12 @@ pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") class Group(Model): - id = field.IntField(pk=True) + id = field.data.UUIDField(pk=True) name = field.CharField(max_length=42, index=True) class User(Model): - id = field.IntField(pk=True) + id = field.data.UUIDField(pk=True) email = field.CharField(max_length=255, unique=True, index=True) username = field.CharField(max_length=150, unique=True) password_hash = field.CharField(max_length=128) diff --git a/src/ohmyapi/builtin/auth/permissions.py b/src/ohmyapi/builtin/auth/permissions.py index abde552..ca356da 100644 --- a/src/ohmyapi/builtin/auth/permissions.py +++ b/src/ohmyapi/builtin/auth/permissions.py @@ -1,4 +1,5 @@ from .routes import ( + get_token, get_current_user, require_authenticated, require_admin, diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 114493a..48c1872 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -1,12 +1,13 @@ import time -from typing import Dict +from enum import Enum +from typing import Any, Dict, List 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 +from ohmyapi.builtin.auth.models import User, Group import settings @@ -40,14 +41,39 @@ def decode_token(token: str) -> Dict: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") +class TokenType(str, Enum): + """ + Helper for indicating the token type when generating claims. + """ + access = "access" + refresh = "refresh" + + +def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Dict[str, Any]: + return { + 'type': token_type, + 'sub': str(user.id), + 'user': { + 'username': user.username, + 'email': user.email, + }, + 'roles': [g.name for g in groups] + } + +async def get_token(token: str = Depends(oauth2_scheme)) -> Dict: + """Dependency: token introspection""" + payload = decode_token(token) + return payload + + 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: + user_id = payload.get("sub") + if user_id is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") - user = await User.filter(username=username).first() + user = await User.filter(id=user_id).first() if not user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") return user @@ -100,8 +126,8 @@ async def login(form_data: LoginRequest = Body(...)): 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) + access_token = create_token(claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS) + refresh_token = create_token(claims(TokenType.refresh, user), REFRESH_TOKEN_EXPIRE_SECONDS) return { "access_token": access_token, @@ -117,20 +143,26 @@ async def refresh_token(refresh_token: str): 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() + user_id = payload.get("sub") + user = await User.filter(id=user_id).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) + new_access = create_token(claims(TokenType.access, user), 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)): +async def me(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, + "email": user.email, + "username": user.username, + "is_admin": user.is_admin, + "is_staff": user.is_staff, } + + +@router.get("/introspect") +async def introspect(token: Dict = Depends(get_token)): + return token From 812049eae7c00c6862c8ac30810568e2310488f1 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:05:12 +0200 Subject: [PATCH 016/172] =?UTF-8?q?=F0=9F=A9=B9=20Respect=20new=20User.ema?= =?UTF-8?q?il=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index 9aac0da..a97355f 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -101,9 +101,10 @@ def createsuperuser(root: str = "."): import asyncio import ohmyapi_auth + email = input("E-Mail: ") username = input("Username: ") password = getpass("Password: ") - user = ohmyapi_auth.models.User(username=username, is_staff=True, is_admin=True) + user = ohmyapi_auth.models.User(email=email, username=username, is_staff=True, is_admin=True) user.set_password(password) asyncio.run(project.init_orm()) asyncio.run(user.save()) From c15bc82caaf6c3d3babca7d92f66cca4bce319c9 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:05:38 +0200 Subject: [PATCH 017/172] =?UTF-8?q?=F0=9F=93=9D=20Use=20field.data.UUIDFie?= =?UTF-8?q?ld=20as=20id=20in=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d42fb1d..966e66d 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,31 @@ # OhMyAPI -> Think: Micro-Django, but API-first, less clunky and 100% async. +> Think: Django RestFramework, but less clunky and 100% async. OhMyAPI is a Django-flavored web-application scaffolding framework and management layer. Built around FastAPI and TortoiseORM, it is 100% async. -It is ***blazingly fast***, ***fun*** to use and comes with ***batteries included***! +It is ***blazingly fast***, ***fun to use*** and comes with ***batteries included***! **Features** - Django-like project-layout and -structure -- Django-like prject-level settings.py +- Django-like project-level settings.py - Django-like models via TortoiseORM - Django-like `Model.Meta` class for model configuration - Easily convert your query results to `pydantic` models via `Model.Schema` -- Django-like migrations (makemigrations & migrate) via Aerich +- Django-like migrations (`makemigrations` & `migrate`) via Aerich - Django-like CLI tooling (`startproject`, `startapp`, `shell`, `serve`, etc) - Various optional builtin apps you can hook into your project - Highly configurable and customizable - 100% async +OhMyAPI aims to: + +- combine FastAPI, TortoiseORM and Aerich migrations into a high-productivity web-application framework +- tying everything neatly together into a project structure consisting of apps with models and a router +- while ***AVOIDING*** to introduce any additional abstractions ontop of Tortoise's model-system or FastAPI's routing + --- ## Getting started @@ -82,7 +88,7 @@ from ohmyapi.db import Model, field class Tournament(Model): - id = field.IntField(primary_key=True) + id = field.data.UUIDField(primary_key=True) name = field.TextField() created = field.DatetimeField(auto_now_add=True) @@ -91,7 +97,7 @@ class Tournament(Model): class Event(Model): - id = field.IntField(primary_key=True) + id = field.data.UUIDField(primary_key=True) name = field.TextField() tournament = field.ForeignKeyField('tournament.Tournament', related_name='events') participants = field.ManyToManyField('torunament.Team', related_name='events', through='event_team') @@ -103,7 +109,7 @@ class Event(Model): class Team(Model): - id = field.IntField(primary_key=True) + id = field.data.UUIDField(primary_key=True) name = field.TextField() def __str__(self): @@ -130,7 +136,7 @@ async def list(): @router.get("/:id") -async def get(id: int): +async def get(id: str): try: queryset = Tournament.get(pk=id) return await Tournament.Schema.one(queryset) @@ -257,7 +263,7 @@ async def list(user: auth.User = Depends(permissions.require_authenticated)): ### Model-Level Permissions -Use Tortoise's `Manager` to implement model-layer permissions. +Use Tortoise's `Manager` to implement model-level permissions. ```python from ohmyapi.db import Manager @@ -265,7 +271,7 @@ from typing import Callable class TeamManager(Manager): - async def for_user(self, user): + async def for_user(self, user: ohmyapi_auth.models.User): return await self.filter(members=user).all() From 5379c125c4627234845d5a8476c309716a241f9a Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:06:17 +0200 Subject: [PATCH 018/172] =?UTF-8?q?=F0=9F=94=96=200.1.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e37f0a8..6538171 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.7" +version = "0.1.8" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 35e6ddfcf5eb981277b13425fe5936134f28feb3 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:23:48 +0200 Subject: [PATCH 019/172] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Fix=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 966e66d..4b80057 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ class Event(Model): id = field.data.UUIDField(primary_key=True) name = field.TextField() tournament = field.ForeignKeyField('tournament.Tournament', related_name='events') - participants = field.ManyToManyField('torunament.Team', related_name='events', through='event_team') + participants = field.ManyToManyField('tournament.Team', related_name='events', through='event_team') modified = field.DatetimeField(auto_now=True) prize = field.DecimalField(max_digits=10, decimal_places=2, null=True) From aea68b8128910a7d93c3973541a497d51e46674b Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:36:07 +0200 Subject: [PATCH 020/172] =?UTF-8?q?=F0=9F=8E=A8=20Improve=20shell=20experi?= =?UTF-8?q?ence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - more informative banner - make project singleton available via identifier `p` --- src/ohmyapi/cli.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index a97355f..6479fe8 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -9,7 +9,9 @@ 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}""" +banner = """OhMyAPI Shell | Project: {project_name} +Find your loaded project singleton via identifier: `p` +""" @app.command() @@ -46,16 +48,14 @@ def shell(root: str = "."): try: from IPython import start_ipython shell_vars = { - "settings": project.settings, - "project": Path(project_path).resolve(), + "p": project, } from traitlets.config.loader import Config c = Config() c.TerminalIPythonApp.display_banner = True - c.TerminalInteractiveShell.banner1 = banner.format(**{ + c.TerminalInteractiveShell.banner2 = 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.") From ce6b57bf9d94db149777488028ec191af46a472b Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:40:53 +0200 Subject: [PATCH 021/172] =?UTF-8?q?=F0=9F=93=9D=20More=20info=20on=20shell?= =?UTF-8?q?=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 45 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4b80057..eb2d01a 100644 --- a/README.md +++ b/README.md @@ -183,14 +183,6 @@ 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. @@ -281,3 +273,40 @@ class Team(Model): class Meta: manager = TeamManager() ``` + +## Shell + +Similar to Django, you can attach to an interactive shell with your project already loaded inside. + +``` +ohmyapi shell + +Python 3.13.7 (main, Aug 15 2025, 12:34:02) [GCC 15.2.1 20250813] +Type 'copyright', 'credits' or 'license' for more information +IPython 9.5.0 -- An enhanced Interactive Python. Type '?' for help. + +OhMyAPI Shell | Project: {{ project_name }} [{{ project_path }}] +Find your loaded project singleton via identifier: `p` +``` + +```python +In [1]: p +Out[1]: + +In [2]: p.apps +Out[2]: +{'ohmyapi_auth': App: ohmyapi_auth + Models: + - Group + - User + Routes: + - APIRoute(path='/auth/login', name='login', methods=['POST']) + - APIRoute(path='/auth/refresh', name='refresh_token', methods=['POST']) + - APIRoute(path='/auth/me', name='me', methods=['GET']) + - APIRoute(path='/auth/introspect', name='introspect', methods=['GET'])} + +In [3]: from tournament.models import Tournament +Out[3]: + +``` + From 036e041be741840d9acd082fcc5d2ea6f3227062 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:42:15 +0200 Subject: [PATCH 022/172] =?UTF-8?q?=F0=9F=94=96=200.1.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6538171..fd2f55f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.8" +version = "0.1.9" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 8b4c03a778ecbe91c297b260a9feee8d32492949 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:45:41 +0200 Subject: [PATCH 023/172] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Remove=20paste-fai?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index eb2d01a..73b56ef 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,6 @@ Use Tortoise's `Manager` to implement model-level permissions. ```python from ohmyapi.db import Manager -from typing import Callable class TeamManager(Manager): From ffac376dde77926f809105ba8a5626ff62c4e96d Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:47:01 +0200 Subject: [PATCH 024/172] =?UTF-8?q?=F0=9F=94=96=200.1.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fd2f55f..f70d8b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.9" +version = "0.1.10" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 0941a9e9d60e18bb677fd18065d863b34998c063 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:50:20 +0200 Subject: [PATCH 025/172] =?UTF-8?q?=F0=9F=93=9D=20Clarify=20imprtance=20ap?= =?UTF-8?q?p.routes.router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 73b56ef..7e0b585 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ from ohmyapi.db.exceptions import DoesNotExist from .models import Tournament +# If .routes.router exists and is an APIRouter, OhMyAPI will +# automatically pick it up and add it to the app's main APIRouter. router = APIRouter(prefix="/tournament") From 1bcd7c0f1f4a24b3159cc186a06582516ca3a98a Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 15:59:33 +0200 Subject: [PATCH 026/172] =?UTF-8?q?=F0=9F=93=9D=20Clarify=20routes.router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++++--- src/ohmyapi/core/templates/app/routes.py.j2 | 8 ++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7e0b585..5cc6ec8 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,10 @@ from ohmyapi.db.exceptions import DoesNotExist from .models import Tournament -# If .routes.router exists and is an APIRouter, OhMyAPI will -# automatically pick it up and add it to the app's main APIRouter. -router = APIRouter(prefix="/tournament") +# Expose your app's routes via `router = fastapi.APIRouter`. +# Use prefixes wisely to avoid cross-app namespace-collisions. +# Tags improve the UX of the OpenAPI docs at /docs. +router = APIRouter(prefix="/tournament", tags=['Tournament']) @router.get("/") diff --git a/src/ohmyapi/core/templates/app/routes.py.j2 b/src/ohmyapi/core/templates/app/routes.py.j2 index d6651bb..1e4d792 100644 --- a/src/ohmyapi/core/templates/app/routes.py.j2 +++ b/src/ohmyapi/core/templates/app/routes.py.j2 @@ -1,11 +1,15 @@ from ohmyapi.router import APIRouter + from . import models -router = APIRouter(prefix="/{{ app_name }}") +# Expose your app's routes via `router = fastapi.APIRouter`. +# Use prefixes wisely to avoid cross-app namespace-collisions. +# Tags improve the UX of the OpenAPI docs at /docs. +router = APIRouter(prefix="/{{ app_name }}", tags=[{{ app_name }}]) @router.get("/") -def ping(): +def hello_world(): return { "project": "{{ project_name }}", "app": "{{ app_name }}", From 8543957095656b465047a2266ad12a7438826ba4 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 16:13:22 +0200 Subject: [PATCH 027/172] =?UTF-8?q?=F0=9F=A9=B9=20Add=20missing=20ohmyapp?= =?UTF-8?q?=5Fauth.permissions.require=5Fgroup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/permissions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ohmyapi/builtin/auth/permissions.py b/src/ohmyapi/builtin/auth/permissions.py index ca356da..27093b2 100644 --- a/src/ohmyapi/builtin/auth/permissions.py +++ b/src/ohmyapi/builtin/auth/permissions.py @@ -4,4 +4,5 @@ from .routes import ( require_authenticated, require_admin, require_staff, + require_group, ) From b5b005448ad77c96f8046d42dae5028d03a3aa5a Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 16:15:16 +0200 Subject: [PATCH 028/172] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Order=20matters;?= =?UTF-8?q?=20reorder=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/routes.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 48c1872..b944e05 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -152,6 +152,11 @@ async def refresh_token(refresh_token: str): return {"access_token": new_access, "token_type": "bearer"} +@router.get("/introspect") +async def introspect(token: Dict = Depends(get_token)): + return token + + @router.get("/me") async def me(user: User = Depends(get_current_user)): """Return the currently authenticated user.""" @@ -163,6 +168,3 @@ async def me(user: User = Depends(get_current_user)): } -@router.get("/introspect") -async def introspect(token: Dict = Depends(get_token)): - return token From 92bb1cc648aad0b4d4ee8d9e1b945edf94c7fd99 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 16:15:50 +0200 Subject: [PATCH 029/172] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Return=20entire=20?= =?UTF-8?q?user=20object?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Model.Schema already prevents password_hash from leaking --- src/ohmyapi/builtin/auth/routes.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index b944e05..3a9ea39 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -160,11 +160,5 @@ async def introspect(token: Dict = Depends(get_token)): @router.get("/me") async def me(user: User = Depends(get_current_user)): """Return the currently authenticated user.""" - return { - "email": user.email, - "username": user.username, - "is_admin": user.is_admin, - "is_staff": user.is_staff, - } - + return user From 49faab5be5eafcca9392905bf5223c801a8d81f3 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 16:18:52 +0200 Subject: [PATCH 030/172] =?UTF-8?q?=F0=9F=A9=B9=20Allow=20admins=20on=20re?= =?UTF-8?q?quire=5Fstaff=20permission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 3a9ea39..e1cb3cc 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -95,7 +95,7 @@ async def require_admin(current_user: User = Depends(get_current_user)) -> 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: + if not current_user.is_admin and not current_user.is_staff: raise HTTPException(403, "Staff privileges required") return current_user From a7a792b7b218cf61db9674aa827735d7334003cf Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 16:20:16 +0200 Subject: [PATCH 031/172] =?UTF-8?q?=F0=9F=94=96=200.1.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f70d8b2..06906b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.10" +version = "0.1.11" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 42f771334559e2ecd47fe35e90688d76b60a0117 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 16:25:22 +0200 Subject: [PATCH 032/172] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Remove=20paste-fai?= =?UTF-8?q?l=20artifact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 5cc6ec8..e2aac45 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,6 @@ In your `routes.py`: ```python from ohmyapi.router import APIRouter, Depends - -from ohmyapi_auth.models import User from ohmyapi_auth import ( models as auth, permissions, From 20a826e8c0ddd0dd186596907ac9a4dc39404a9c Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 16:32:03 +0200 Subject: [PATCH 033/172] =?UTF-8?q?=F0=9F=93=9D=20Few=20small=20improvemen?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e2aac45..34cfa02 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ from ohmyapi_auth import ( from .models import Tournament -router = APIRouter(prefix="/tournament") +router = APIRouter(prefix="/tournament", tags=["Tournament"]) @router.get("/") @@ -260,10 +260,11 @@ Use Tortoise's `Manager` to implement model-level permissions. ```python from ohmyapi.db import Manager +from ohmyapi_auth.models import User class TeamManager(Manager): - async def for_user(self, user: ohmyapi_auth.models.User): + async def for_user(self, user: User): return await self.filter(members=user).all() @@ -274,6 +275,24 @@ class Team(Model): manager = TeamManager() ``` +Use the custom manager in your FastAPI route handler: + +```python +from ohmyapi.router import APIRouter +from ohmyapi_auth import ( + models as auth, + permissions, +) + +router = APIRouter(prefix="/tournament", tags=["Tournament"]) + + +@router.get("/teams") +async def teams(user: auth.User = Depends(permissions.require_authenticated)): + queryset = Team.for_user(user) + return await Tournament.Schema.many.from_queryset(queryset) +``` + ## Shell Similar to Django, you can attach to an interactive shell with your project already loaded inside. From 3071d76ae1708db2b0e07c5ac666f89e8f22226f Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 16:50:15 +0200 Subject: [PATCH 034/172] =?UTF-8?q?=F0=9F=A9=B9=20Add=20password=20verific?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/cli.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index 6479fe8..38d8fcc 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -16,13 +16,13 @@ Find your loaded project singleton via identifier: `p` @app.command() def startproject(name: str): - """Create a new OhMyAPI project in the given directory""" + """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""" + """Create a new app with the given name in your OhMyAPI project.""" scaffolding.startapp(app_name, root) @@ -93,6 +93,10 @@ def migrate(app: str = "*", root: str = "."): @app.command() def createsuperuser(root: str = "."): + """Create a superuser in the DB. + + This requires the presence of `ohmyapi_auth` in your INSTALLED_APPS to work. + """ project_path = Path(root).resolve() project = runtime.Project(project_path) if not project.is_app_installed("ohmyapi_auth"): @@ -103,9 +107,14 @@ def createsuperuser(root: str = "."): import ohmyapi_auth email = input("E-Mail: ") username = input("Username: ") - password = getpass("Password: ") + password1, password2 = "foo", "bar" + while password1 != password2: + password1 = getpass("Password: ") + password2 = getpass("Repeat Password: ") + if password1 != password2: + print("Passwords didn't match!") user = ohmyapi_auth.models.User(email=email, username=username, is_staff=True, is_admin=True) - user.set_password(password) + user.set_password(password1) asyncio.run(project.init_orm()) asyncio.run(user.save()) asyncio.run(project.close_orm()) From 97fc689d7d3e8c70357b58eb43b4024c023dd702 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 16:52:09 +0200 Subject: [PATCH 035/172] =?UTF-8?q?=F0=9F=94=96=200.1.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 06906b7..7297843 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.11" +version = "0.1.12" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 0ec26895c04f42ef6f38ea19e38b37bbebffef09 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 17:00:53 +0200 Subject: [PATCH 036/172] =?UTF-8?q?=F0=9F=8E=A8=20Make=20asyncio=20availab?= =?UTF-8?q?le=20in=20shell=20automatically?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index 38d8fcc..eb8df12 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -49,6 +49,7 @@ def shell(root: str = "."): from IPython import start_ipython shell_vars = { "p": project, + "asyncio": asyncio, } from traitlets.config.loader import Config c = Config() @@ -103,7 +104,6 @@ def createsuperuser(root: str = "."): print("Auth app not installed! Please add 'ohmyapi_auth' to your INSTALLED_APPS.") return - import asyncio import ohmyapi_auth email = input("E-Mail: ") username = input("Username: ") From 485ddc01fb2af0d50c293c88d1f5ee97ad3efc1e Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 17:12:32 +0200 Subject: [PATCH 037/172] =?UTF-8?q?Revert=20"=F0=9F=8E=A8=20Make=20asyncio?= =?UTF-8?q?=20available=20in=20shell=20automatically"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0ec26895c04f42ef6f38ea19e38b37bbebffef09. --- src/ohmyapi/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index eb8df12..38d8fcc 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -49,7 +49,6 @@ def shell(root: str = "."): from IPython import start_ipython shell_vars = { "p": project, - "asyncio": asyncio, } from traitlets.config.loader import Config c = Config() @@ -104,6 +103,7 @@ def createsuperuser(root: str = "."): print("Auth app not installed! Please add 'ohmyapi_auth' to your INSTALLED_APPS.") return + import asyncio import ohmyapi_auth email = input("E-Mail: ") username = input("Username: ") From b6d209926ff70865e79fbe2538302339dc92223c Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 17:19:37 +0200 Subject: [PATCH 038/172] =?UTF-8?q?=F0=9F=A9=B9=20Ensure=20ORM=20initializ?= =?UTF-8?q?ation=20and=20tear-down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/cli.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index 38d8fcc..58b4f11 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -1,4 +1,5 @@ import asyncio +import atexit import importlib import sys import typer @@ -45,6 +46,28 @@ def shell(root: str = "."): project_path = Path(root).resolve() project = runtime.Project(project_path) + # Ensure the ORM is shutdown + async def close_project(): + try: + await project.close_orm() + print("Tortoise ORM closed successfully.") + except Exception as e: + print(f"Error closing ORM: {e}") + + def cleanup(): + loop = None + try: + loop = asyncio.get_running_loop() + except RuntimeError: + pass + if loop and loop.is_running(): + asyncio.create_task(close_project()) + else: + asyncio.run(close_project()) + + # Ensure the ORM is initialized + asyncio.run(project.init_orm()) + try: from IPython import start_ipython shell_vars = { @@ -56,11 +79,13 @@ def shell(root: str = "."): c.TerminalInteractiveShell.banner2 = banner.format(**{ "project_name": f"{f'{project.settings.PROJECT_NAME} ' if getattr(project.settings, 'PROJECT_NAME', '') else ''}[{Path(project_path).resolve()}]", }) + atexit.register(cleanup) 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}) + atexit.register(cleanup) + code.interact(local={"p": project}) @app.command() From 15608a389b70926d898296d13379143ea276743e Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 17:20:21 +0200 Subject: [PATCH 039/172] =?UTF-8?q?=F0=9F=93=9D=20Show=20complete=20p.apps?= =?UTF-8?q?=20in=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 34cfa02..2430d77 100644 --- a/README.md +++ b/README.md @@ -321,11 +321,16 @@ Out[2]: Routes: - APIRoute(path='/auth/login', name='login', methods=['POST']) - APIRoute(path='/auth/refresh', name='refresh_token', methods=['POST']) - - APIRoute(path='/auth/me', name='me', methods=['GET']) - - APIRoute(path='/auth/introspect', name='introspect', methods=['GET'])} + - APIRoute(path='/auth/introspect', name='introspect', methods=['GET']) + - APIRoute(path='/auth/me', name='me', methods=['GET']), + 'tournament': App: tournament + Models: + - Tournament + - Event + - Team + Routes: + - APIRoute(path='/tournament/', name='list', methods=['GET'])} In [3]: from tournament.models import Tournament -Out[3]: - ``` From 33b8ff7acbb8857d3dc7aa7dbfc5b434fe0fe778 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 17:22:32 +0200 Subject: [PATCH 040/172] =?UTF-8?q?=F0=9F=94=96=200.1.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7297843..0c90a25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.12" +version = "0.1.13" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 264119a67def6766964ae8a49624b82cdc5bc65b Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 17:34:47 +0200 Subject: [PATCH 041/172] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Better=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2430d77..d75fdda 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,11 @@ It is ***blazingly fast***, ***fun to use*** and comes with ***batteries include - Highly configurable and customizable - 100% async -OhMyAPI aims to: +**Goals** - combine FastAPI, TortoiseORM and Aerich migrations into a high-productivity web-application framework -- tying everything neatly together into a project structure consisting of apps with models and a router -- while ***AVOIDING*** to introduce any additional abstractions ontop of Tortoise's model-system or FastAPI's routing +- tie everything neatly together into a concise API +- while ***AVOIDING*** any additional abstractions ontop of Tortoise's model-system or FastAPI's routing system --- From 7ca64e8aefa7d4ca6759b2ca0bc9ef474ec69fdd Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 22:06:51 +0200 Subject: [PATCH 042/172] =?UTF-8?q?=F0=9F=8E=A8=20Provide=20tortoise.query?= =?UTF-8?q?set.QuerySet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ohmyapi/db/__init__.py b/src/ohmyapi/db/__init__.py index 3d40706..0cf3f57 100644 --- a/src/ohmyapi/db/__init__.py +++ b/src/ohmyapi/db/__init__.py @@ -1,3 +1,4 @@ from .model import Model, field from tortoise.manager import Manager +from tortoise.queryset import QuerySet From 2870d5592669e4eed4d932f4caf018873622fcc5 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 22:51:11 +0200 Subject: [PATCH 043/172] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Add=20type=20annot?= =?UTF-8?q?ations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/models.py | 25 ++++++++++++------------- src/ohmyapi/builtin/auth/routes.py | 2 +- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index 8b2d720..7628c4f 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -1,5 +1,7 @@ +from functools import wraps from typing import Optional, List -from ohmyapi.db import Model, field +from ohmyapi.router import HTTPException +from ohmyapi.db import Model, field, pre_save, pre_delete from passlib.context import CryptContext from tortoise.contrib.pydantic import pydantic_queryset_creator @@ -7,24 +9,22 @@ pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") class Group(Model): - id = field.data.UUIDField(pk=True) - name = field.CharField(max_length=42, index=True) + id: str = field.data.UUIDField(pk=True) + name: str = field.CharField(max_length=42, index=True) class User(Model): - id = field.data.UUIDField(pk=True) - email = field.CharField(max_length=255, unique=True, index=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") - + id: str = field.data.UUIDField(pk=True) + email: str = field.CharField(max_length=255, unique=True, index=True) + username: str = field.CharField(max_length=150, unique=True) + password_hash: str = field.CharField(max_length=128) + is_admin: bool = field.BooleanField(default=False) + is_staff: bool = field.BooleanField(default=False) + groups: field.ManyToManyRelation[Group] = field.ManyToManyField("ohmyapi_auth.Group", related_name="users", through='user_groups') class Schema: exclude = 'password_hash', - def set_password(self, raw_password: str) -> None: """Hash and store the password.""" self.password_hash = pwd_context.hash(raw_password) @@ -40,4 +40,3 @@ class User(Model): if user and user.verify_password(password): return user return None - diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index e1cb3cc..3e8dfd3 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -160,5 +160,5 @@ async def introspect(token: Dict = Depends(get_token)): @router.get("/me") async def me(user: User = Depends(get_current_user)): """Return the currently authenticated user.""" - return user + return User.Schema.one.from_orm(user) From db329a8822f07c7e200e64ceae45c264df8d4d54 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 22:51:39 +0200 Subject: [PATCH 044/172] =?UTF-8?q?=F0=9F=8E=A8=20Expose=20tortoise=20sign?= =?UTF-8?q?als?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ohmyapi/db/__init__.py b/src/ohmyapi/db/__init__.py index 0cf3f57..1693f81 100644 --- a/src/ohmyapi/db/__init__.py +++ b/src/ohmyapi/db/__init__.py @@ -1,4 +1,10 @@ from .model import Model, field from tortoise.manager import Manager from tortoise.queryset import QuerySet +from tortoise.signals import ( + pre_delete, + post_delete, + pre_save, + post_save, +) From c43d5030b9980f4b96eab806dfc9d20cdfbb7bfb Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 22:51:58 +0200 Subject: [PATCH 045/172] =?UTF-8?q?=F0=9F=93=9D=20Reflect=20latest=20chang?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d75fdda..b738d72 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,8 @@ async def list(): @router.get("/:id") async def get(id: str): try: - queryset = Tournament.get(pk=id) - return await Tournament.Schema.one(queryset) + tournament = await Tournament.get(pk=id) + return await Tournament.Schema.one.form_orm(tournament) except DoesNotExist: raise HTTPException(status_code=404, detail="item not found") From d67ae5d3f5396f939cae8247bc1f8ce4d9b0f0c6 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 22:52:23 +0200 Subject: [PATCH 046/172] =?UTF-8?q?=F0=9F=94=96=200.1.14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0c90a25..4fd0411 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.13" +version = "0.1.14" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From f2f6beb770f6367896fa762af9939010c45c2740 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 22:56:20 +0200 Subject: [PATCH 047/172] =?UTF-8?q?=F0=9F=A9=B9=20Add=20missing=20quotes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/templates/app/routes.py.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/core/templates/app/routes.py.j2 b/src/ohmyapi/core/templates/app/routes.py.j2 index 1e4d792..a5a4d4f 100644 --- a/src/ohmyapi/core/templates/app/routes.py.j2 +++ b/src/ohmyapi/core/templates/app/routes.py.j2 @@ -5,7 +5,7 @@ from . import models # Expose your app's routes via `router = fastapi.APIRouter`. # Use prefixes wisely to avoid cross-app namespace-collisions. # Tags improve the UX of the OpenAPI docs at /docs. -router = APIRouter(prefix="/{{ app_name }}", tags=[{{ app_name }}]) +router = APIRouter(prefix="/{{ app_name }}", tags=['{{ app_name }}']) @router.get("/") From af1d5025707ba6949c74b1e01e30e877906d943b Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 23:09:41 +0200 Subject: [PATCH 048/172] =?UTF-8?q?=F0=9F=A9=B9=200.1.15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4fd0411..b753aae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.14" +version = "0.1.15" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From eac45bdeb3c429244cfccd1b0bd031f22e0a697e Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 23:16:57 +0200 Subject: [PATCH 049/172] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Replace=20os.mkdir?= =?UTF-8?q?s=20in=20favor=20of=20Path.mkdir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/scaffolding.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ohmyapi/core/scaffolding.py b/src/ohmyapi/core/scaffolding.py index b725f0e..c7d7943 100644 --- a/src/ohmyapi/core/scaffolding.py +++ b/src/ohmyapi/core/scaffolding.py @@ -1,4 +1,3 @@ -import os from pathlib import Path from jinja2 import Environment, FileSystemLoader @@ -11,7 +10,7 @@ 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) + output_path.parent.mkdir(exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: f.write(content) @@ -22,7 +21,7 @@ def render_template_dir(template_subdir: str, target_dir: Path, context: dict, s 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): + for root, _, files in template_dir.walk(): root_path = Path(root) rel_root = root_path.relative_to(template_dir) # path relative to template_subdir @@ -45,7 +44,7 @@ def render_template_dir(template_subdir: str, target_dir: Path, context: dict, s def startproject(name: str): """Create a new project: flat structure, all project templates go into /""" target_dir = Path(name).resolve() - os.makedirs(target_dir, exist_ok=True) + target_dir.mkdir(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'}") @@ -54,7 +53,7 @@ def startproject(name: str): def startapp(name: str, project: str): """Create a new app inside a project: templates go into //""" target_dir = Path(project) - os.makedirs(target_dir, exist_ok=True) + target_dir.makedirs(exist_ok=True) render_template_dir("app", target_dir, {"project_name": target_dir.resolve().name, "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!") From adf3fc9ca9b4a0d60787bdca1a95e9d7239cbfa6 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 23:17:39 +0200 Subject: [PATCH 050/172] =?UTF-8?q?=F0=9F=94=96=200.1.16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b753aae..f340c07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.15" +version = "0.1.16" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 52297d8ac384b309238c37ac41258a2aa75d52a0 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 23:20:25 +0200 Subject: [PATCH 051/172] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Fix=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/scaffolding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/core/scaffolding.py b/src/ohmyapi/core/scaffolding.py index c7d7943..ac834dc 100644 --- a/src/ohmyapi/core/scaffolding.py +++ b/src/ohmyapi/core/scaffolding.py @@ -53,7 +53,7 @@ def startproject(name: str): def startapp(name: str, project: str): """Create a new app inside a project: templates go into //""" target_dir = Path(project) - target_dir.makedirs(exist_ok=True) + target_dir.mkdir(exist_ok=True) render_template_dir("app", target_dir, {"project_name": target_dir.resolve().name, "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!") From 1dcbab06b1a3336a589fbf9824cc01c0719fc7f0 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 27 Sep 2025 23:20:57 +0200 Subject: [PATCH 052/172] =?UTF-8?q?=F0=9F=94=96=200.1.17?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f340c07..150ddf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.16" +version = "0.1.17" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From ac60c1955195763fa0f4c97f8d4d718c62889109 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 01:48:03 +0200 Subject: [PATCH 053/172] =?UTF-8?q?=F0=9F=94=A5=20Remove=20Model.Schema.ma?= =?UTF-8?q?ny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/model/model.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index 2bbc557..d005e43 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -11,30 +11,18 @@ class ModelMeta(type(TortoiseModel)): class BoundSchema: @property - def one(self): + def model(self): """Return a Pydantic model class for 'one' results.""" include = getattr(schema_opts, "include", None) exclude = getattr(schema_opts, "exclude", None) return pydantic_model_creator( new_cls, - name=f"{new_cls.__name__}SchemaOne", + name=f"{new_cls.__name__}Schema", include=include, exclude=exclude, exclude_readonly=True, ) - @property - def many(self): - """Return a Pydantic queryset class for 'many' results.""" - include = getattr(schema_opts, "include", None) - exclude = getattr(schema_opts, "exclude", None) - return pydantic_queryset_creator( - new_cls, - name=f"{new_cls.__name__}SchemaMany", - include=include, - exclude=exclude, - ) - new_cls.Schema = BoundSchema() return new_cls From 9bf33d12c94000651a85242d12d114a9fe9b69f8 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 01:51:07 +0200 Subject: [PATCH 054/172] =?UTF-8?q?=F0=9F=93=9D=20Reflect=20latest=20chang?= =?UTF-8?q?es=20in=20Model.Schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b738d72..1578949 100644 --- a/README.md +++ b/README.md @@ -135,14 +135,14 @@ router = APIRouter(prefix="/tournament", tags=['Tournament']) @router.get("/") async def list(): queryset = Tournament.all() - return await Tournament.Schema.many.from_queryset(queryset) + return await Tournament.Schema.model.from_queryset(queryset) @router.get("/:id") async def get(id: str): try: tournament = await Tournament.get(pk=id) - return await Tournament.Schema.one.form_orm(tournament) + return await Tournament.Schema.model.from_queryset_single(tournament) except DoesNotExist: raise HTTPException(status_code=404, detail="item not found") @@ -248,7 +248,7 @@ router = APIRouter(prefix="/tournament", tags=["Tournament"]) @router.get("/") async def list(user: auth.User = Depends(permissions.require_authenticated)): queryset = Tournament.all() - return await Tournament.Schema.many.from_queryset(queryset) + return await Tournament.Schema.model.from_queryset(queryset) ... @@ -290,7 +290,7 @@ router = APIRouter(prefix="/tournament", tags=["Tournament"]) @router.get("/teams") async def teams(user: auth.User = Depends(permissions.require_authenticated)): queryset = Team.for_user(user) - return await Tournament.Schema.many.from_queryset(queryset) + return await Tournament.Schema.model.from_queryset(queryset) ``` ## Shell From d509b58282302c691b593a010b26d48000ab0bac Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 01:51:52 +0200 Subject: [PATCH 055/172] =?UTF-8?q?=F0=9F=94=96=200.1.18?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 150ddf0..785068e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.17" +version = "0.1.18" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 09648fa29274de1d33859d309e295594af0e3e5e Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 02:12:14 +0200 Subject: [PATCH 056/172] =?UTF-8?q?=F0=9F=8E=A8=20pydantic=20model=20and?= =?UTF-8?q?=20readonly=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/model/model.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index d005e43..eb0b504 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -12,7 +12,7 @@ class ModelMeta(type(TortoiseModel)): class BoundSchema: @property def model(self): - """Return a Pydantic model class for 'one' results.""" + """Return a Pydantic model class for serializing results.""" include = getattr(schema_opts, "include", None) exclude = getattr(schema_opts, "exclude", None) return pydantic_model_creator( @@ -20,6 +20,18 @@ class ModelMeta(type(TortoiseModel)): name=f"{new_cls.__name__}Schema", include=include, exclude=exclude, + ) + + @property + def readonly(self): + """Return a Pydantic model class for serializing readonly results.""" + include = getattr(schema_opts, "include", None) + exclude = getattr(schema_opts, "exclude", None) + return pydantic_model_creator( + new_cls, + name=f"{new_cls.__name__}SchemaReadonly", + include=include, + exclude=exclude, exclude_readonly=True, ) From 82c39540a97441f91139d13cb04a616c45820512 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 02:12:57 +0200 Subject: [PATCH 057/172] =?UTF-8?q?=F0=9F=94=96=200.1.19?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 785068e..49c767c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.18" +version = "0.1.19" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From b15ce0b04443ba9d748851398a06abe7b9e40503 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 14:49:53 +0200 Subject: [PATCH 058/172] =?UTF-8?q?=F0=9F=90=9B=20Fix=20hanging=20shell=20?= =?UTF-8?q?on=20exit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell was hanging on exit, after at least one ORM query was performed. The cleanup() task never triggered. It now triggers reliably. --- src/ohmyapi/cli.py | 55 +++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index 58b4f11..4f4a824 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -10,9 +10,6 @@ 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} -Find your loaded project singleton via identifier: `p` -""" @app.command() @@ -40,52 +37,50 @@ def serve(root: str = ".", host="127.0.0.1", port=8000): @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) - # Ensure the ORM is shutdown - async def close_project(): + banner = f""" + OhMyAPI Project Shell: {getattr(project.settings, 'PROJECT_NAME', 'MyProject')} + Find your loaded project singleton via identifier: `p`; i.e.: `p.apps` + """ + + async def init_and_cleanup(): + try: + await project.init_orm() + return True + except Exception as e: + print(f"Failed to initialize ORM: {e}") + return False + + async def cleanup(): try: await project.close_orm() print("Tortoise ORM closed successfully.") except Exception as e: print(f"Error closing ORM: {e}") - def cleanup(): - loop = None - try: - loop = asyncio.get_running_loop() - except RuntimeError: - pass - if loop and loop.is_running(): - asyncio.create_task(close_project()) - else: - asyncio.run(close_project()) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(init_and_cleanup()) - # Ensure the ORM is initialized - asyncio.run(project.init_orm()) + # Prepare shell vars that are to be directly available + shell_vars = {"p": project} try: from IPython import start_ipython - shell_vars = { - "p": project, - } from traitlets.config.loader import Config + c = Config() c.TerminalIPythonApp.display_banner = True - c.TerminalInteractiveShell.banner2 = banner.format(**{ - "project_name": f"{f'{project.settings.PROJECT_NAME} ' if getattr(project.settings, 'PROJECT_NAME', '') else ''}[{Path(project_path).resolve()}]", - }) - atexit.register(cleanup) + c.TerminalInteractiveShell.banner2 = banner + 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 - atexit.register(cleanup) - code.interact(local={"p": project}) + code.interact(local=shell_vars, banner=banner) + finally: + loop.run_until_complete(cleanup()) @app.command() From 3465ec71c7848b3e36b61f1c6a994000d6db9be2 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 14:51:38 +0200 Subject: [PATCH 059/172] =?UTF-8?q?=F0=9F=90=9B=20Monkey-patch=20UUID=20to?= =?UTF-8?q?=20be=20pydantic=20serializable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/models.py | 10 ++++++---- src/ohmyapi/db/model/model.py | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index 7628c4f..c0983c7 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -1,20 +1,22 @@ -from functools import wraps -from typing import Optional, List from ohmyapi.router import HTTPException from ohmyapi.db import Model, field, pre_save, pre_delete + +from functools import wraps +from typing import Optional, List from passlib.context import CryptContext from tortoise.contrib.pydantic import pydantic_queryset_creator +from uuid import UUID pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") class Group(Model): - id: str = field.data.UUIDField(pk=True) + id: UUID = field.data.UUIDField(pk=True) name: str = field.CharField(max_length=42, index=True) class User(Model): - id: str = field.data.UUIDField(pk=True) + id: UUID = field.data.UUIDField(pk=True) email: str = field.CharField(max_length=255, unique=True, index=True) username: str = field.CharField(max_length=150, unique=True) password_hash: str = field.CharField(max_length=128) diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index eb0b504..e67b81e 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -1,6 +1,27 @@ +from pydantic_core import core_schema +from pydantic import GetCoreSchemaHandler from tortoise import fields as field from tortoise.models import Model as TortoiseModel from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator +from uuid import UUID + + +def __uuid_schema_monkey_patch(cls, source_type, handler): + # Always treat UUID as string schema + return core_schema.no_info_after_validator_function( + # Accept UUID or str, always return UUID internally + lambda v: v if isinstance(v, UUID) else UUID(str(v)), + core_schema.union_schema([ + core_schema.str_schema(), + core_schema.is_instance_schema(UUID), + ]), + # But when serializing, always str() + serialization=core_schema.plain_serializer_function_ser_schema(str, when_used="always"), + ) + + +# Monkey-patch UUID +UUID.__get_pydantic_core_schema__ = classmethod(__uuid_schema_monkey_patch) class ModelMeta(type(TortoiseModel)): From 30a7826eeb056ad22bc770dfe649d95b1c49cacf Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 14:52:49 +0200 Subject: [PATCH 060/172] =?UTF-8?q?=F0=9F=93=9D=20Reflect=20latest=20chang?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1578949..f467745 100644 --- a/README.md +++ b/README.md @@ -86,31 +86,35 @@ Write your first model in `turnament/models.py`: ```python from ohmyapi.db import Model, field +from datetime import datetime +from decimal import Decimal +from uuid import UUID + class Tournament(Model): - id = field.data.UUIDField(primary_key=True) - name = field.TextField() - created = field.DatetimeField(auto_now_add=True) + id: UUID = field.data.UUIDField(primary_key=True) + name: str = field.TextField() + created: datetime = field.DatetimeField(auto_now_add=True) def __str__(self): return self.name class Event(Model): - id = field.data.UUIDField(primary_key=True) - name = field.TextField() - tournament = field.ForeignKeyField('tournament.Tournament', related_name='events') - participants = field.ManyToManyField('tournament.Team', related_name='events', through='event_team') - modified = field.DatetimeField(auto_now=True) - prize = field.DecimalField(max_digits=10, decimal_places=2, null=True) + id: UUID = field.data.UUIDField(primary_key=True) + name: str = field.TextField() + tournament: UUID = field.ForeignKeyField('tournament.Tournament', related_name='events') + participants: field.ManyToManyRelation[Team] = field.ManyToManyField('tournament.Team', related_name='events', through='event_team') + modified: datetime = field.DatetimeField(auto_now=True) + prize: Decimal = field.DecimalField(max_digits=10, decimal_places=2, null=True) def __str__(self): return self.name class Team(Model): - id = field.data.UUIDField(primary_key=True) - name = field.TextField() + id: UUID = field.data.UUIDField(primary_key=True) + name: str = field.TextField() def __str__(self): return self.name @@ -210,9 +214,13 @@ It comes with a `User` and `Group` model, as well as endpoints for JWT auth. You can use the models as `ForeignKeyField` in your application models: ```python +from ohmyapi.db import Model, field +from ohmyapi_auth.models import User + + class Team(Model): [...] - members = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams') + members: field.ManyToManyRelation[User] = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams') [...] ``` From 80a4b468b18e3c9dbf445473f5fe4292ae28a44d Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 15:02:17 +0200 Subject: [PATCH 061/172] =?UTF-8?q?=F0=9F=94=96=200.1.20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 49c767c..67374e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.19" +version = "0.1.20" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 3ebebe7fbdf4c3ac89bc3b0699402152d1157eda Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 15:04:20 +0200 Subject: [PATCH 062/172] =?UTF-8?q?=F0=9F=8E=A8=20Directly=20import=20mode?= =?UTF-8?q?ls;=20force=20complete=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/templates/app/routes.py.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/core/templates/app/routes.py.j2 b/src/ohmyapi/core/templates/app/routes.py.j2 index a5a4d4f..044a533 100644 --- a/src/ohmyapi/core/templates/app/routes.py.j2 +++ b/src/ohmyapi/core/templates/app/routes.py.j2 @@ -1,6 +1,6 @@ from ohmyapi.router import APIRouter -from . import models +from .models import ... # Expose your app's routes via `router = fastapi.APIRouter`. # Use prefixes wisely to avoid cross-app namespace-collisions. From 9becfc857d8c3f8fc1a3a26c6e91c42cf8a63803 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 15:37:28 +0200 Subject: [PATCH 063/172] =?UTF-8?q?=F0=9F=9A=B8=20Add=20App.dict();=20repr?= =?UTF-8?q?esent=20as=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 40 ++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index e1ed344..d07fd3f 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -2,10 +2,11 @@ import copy import importlib import importlib.util +import json import pkgutil import sys from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, Generator, List, Optional import click from aerich import Command as AerichCommand @@ -218,28 +219,39 @@ class App: 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 + return json.dumps(self.dict(), indent=2) def __str__(self): return self.__repr__() + def _serialize_route(self, route): + """Convert APIRoute to JSON-serializable dict.""" + return { + "path": route.path, + "name": route.name, + "methods": list(route.methods), + "endpoint": route.endpoint.__name__, # just the function name + "response_model": getattr(route, "response_model", None).__name__ + if getattr(route, "response_model", None) else None, + "tags": getattr(route, "tags", None), + } + + def _serialize_router(self): + return [self._serialize_route(route) for route in self.routes] + + def dict(self) -> Dict[str, Any]: + return { + 'models': [m.__name__ for m in self.models], + 'routes': self._serialize_router(), + } + @property - def models(self) -> List[Model]: - models: List[Model] = [] + def models(self) -> Generator[Model, None, None]: 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 + yield obj @property def routes(self): From 6a90e4a44a7e143fa7dc3cc0dc68cfee23502dd6 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 15:41:01 +0200 Subject: [PATCH 064/172] =?UTF-8?q?=F0=9F=92=84=20Introduce=20black=20&=20?= =?UTF-8?q?isort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poetry.lock | 136 +++++++++++++++++++++++- pyproject.toml | 22 ++++ src/ohmyapi/__init__.py | 1 - src/ohmyapi/builtin/auth/__init__.py | 5 +- src/ohmyapi/builtin/auth/models.py | 17 +-- src/ohmyapi/builtin/auth/permissions.py | 6 +- src/ohmyapi/builtin/auth/routes.py | 80 +++++++++----- src/ohmyapi/cli.py | 23 ++-- src/ohmyapi/core/runtime.py | 48 ++++++--- src/ohmyapi/core/scaffolding.py | 28 +++-- src/ohmyapi/db/__init__.py | 6 +- src/ohmyapi/db/exceptions.py | 1 - src/ohmyapi/db/model/model.py | 26 +++-- src/ohmyapi/router.py | 1 - 14 files changed, 311 insertions(+), 89 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2e46d46..b4fab51 100644 --- a/poetry.lock +++ b/poetry.lock @@ -163,6 +163,52 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "black" +version = "25.9.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7"}, + {file = "black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92"}, + {file = "black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713"}, + {file = "black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1"}, + {file = "black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa"}, + {file = "black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d"}, + {file = "black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608"}, + {file = "black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f"}, + {file = "black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0"}, + {file = "black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4"}, + {file = "black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e"}, + {file = "black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a"}, + {file = "black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175"}, + {file = "black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f"}, + {file = "black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831"}, + {file = "black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357"}, + {file = "black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47"}, + {file = "black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823"}, + {file = "black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140"}, + {file = "black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933"}, + {file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"}, + {file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +pytokens = ">=0.1.10" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + [[package]] name = "certifi" version = "2025.8.3" @@ -367,7 +413,7 @@ version = "8.3.0" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, @@ -383,11 +429,11 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main", "dev"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} [[package]] name = "crypto" @@ -559,6 +605,22 @@ files = [ {file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, ] +[[package]] +name = "isort" +version = "6.0.1" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, + {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + [[package]] name = "jedi" version = "0.19.2" @@ -719,6 +781,18 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + [[package]] name = "naked" version = "0.1.32" @@ -735,6 +809,18 @@ files = [ pyyaml = "*" requests = "*" +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + [[package]] name = "parso" version = "0.8.5" @@ -769,6 +855,18 @@ bcrypt = ["bcrypt (>=3.1.0)"] build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] totp = ["cryptography"] +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + [[package]] name = "pexpect" version = "4.9.0" @@ -785,6 +883,23 @@ files = [ [package.dependencies] ptyprocess = ">=0.5" +[[package]] +name = "platformdirs" +version = "4.4.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, + {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -1033,6 +1148,21 @@ files = [ {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, ] +[[package]] +name = "pytokens" +version = "0.1.10" +description = "A Fast, spec compliant Python 3.12+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytokens-0.1.10-py3-none-any.whl", hash = "sha256:db7b72284e480e69fb085d9f251f66b3d2df8b7166059261258ff35f50fb711b"}, + {file = "pytokens-0.1.10.tar.gz", hash = "sha256:c9a4bfa0be1d26aebce03e6884ba454e842f186a59ea43a6d3b25af58223c044"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + [[package]] name = "pytz" version = "2025.2" @@ -1378,4 +1508,4 @@ auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"] [metadata] lock-version = "2.1" python-versions = ">=3.13" -content-hash = "16ae1b48820c723ca784e71a454fb4b686c94fc9f01fa81b086df5dcaf512074" +content-hash = "145508f708df01d84d998947a87b95cfc269e197eb8bc7467e9748a3b8e210e5" diff --git a/pyproject.toml b/pyproject.toml index 67374e5..e61a785 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,8 @@ dependencies = [ [tool.poetry.group.dev.dependencies] ipython = ">=9.5.0,<10.0.0" +black = "^25.9.0" +isort = "^6.0.1" [project.optional-dependencies] auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"] @@ -36,3 +38,23 @@ packages = [ { include = "ohmyapi", from = "src" } ] [project.scripts] ohmyapi = "ohmyapi.cli:app" + +[tool.black] +line-length = 88 +target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" # makes imports compatible with black +line_length = 88 +multi_line_output = 3 +include_trailing_comma = true + diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 72949ab..5dadfec 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1,2 +1 @@ from . import db - diff --git a/src/ohmyapi/builtin/auth/__init__.py b/src/ohmyapi/builtin/auth/__init__.py index bd581f3..8c2daf9 100644 --- a/src/ohmyapi/builtin/auth/__init__.py +++ b/src/ohmyapi/builtin/auth/__init__.py @@ -1,4 +1 @@ -from . import models -from . import routes -from . import permissions - +from . import models, permissions, routes diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index c0983c7..341a07f 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -1,11 +1,12 @@ -from ohmyapi.router import HTTPException -from ohmyapi.db import Model, field, pre_save, pre_delete - from functools import wraps -from typing import Optional, List +from typing import List, Optional +from uuid import UUID + from passlib.context import CryptContext from tortoise.contrib.pydantic import pydantic_queryset_creator -from uuid import UUID + +from ohmyapi.db import Model, field, pre_delete, pre_save +from ohmyapi.router import HTTPException pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") @@ -22,10 +23,12 @@ class User(Model): password_hash: str = field.CharField(max_length=128) is_admin: bool = field.BooleanField(default=False) is_staff: bool = field.BooleanField(default=False) - groups: field.ManyToManyRelation[Group] = field.ManyToManyField("ohmyapi_auth.Group", related_name="users", through='user_groups') + groups: field.ManyToManyRelation[Group] = field.ManyToManyField( + "ohmyapi_auth.Group", related_name="users", through="user_groups" + ) class Schema: - exclude = 'password_hash', + exclude = ("password_hash",) def set_password(self, raw_password: str) -> None: """Hash and store the password.""" diff --git a/src/ohmyapi/builtin/auth/permissions.py b/src/ohmyapi/builtin/auth/permissions.py index 27093b2..578dbd3 100644 --- a/src/ohmyapi/builtin/auth/permissions.py +++ b/src/ohmyapi/builtin/auth/permissions.py @@ -1,8 +1,8 @@ from .routes import ( - get_token, get_current_user, - require_authenticated, + get_token, require_admin, - require_staff, + require_authenticated, require_group, + require_staff, ) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 3e8dfd3..cdb735e 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -3,13 +3,12 @@ from enum import Enum from typing import Any, Dict, List import jwt +import settings 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, Group - -import settings +from ohmyapi.builtin.auth.models import Group, User # Router router = APIRouter(prefix="/auth", tags=["auth"]) @@ -17,8 +16,12 @@ 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) +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") @@ -36,30 +39,38 @@ 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") + 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") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" + ) class TokenType(str, Enum): """ Helper for indicating the token type when generating claims. """ + access = "access" refresh = "refresh" -def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Dict[str, Any]: +def claims( + token_type: TokenType, user: User, groups: List[Group] = [] +) -> Dict[str, Any]: return { - 'type': token_type, - 'sub': str(user.id), - 'user': { - 'username': user.username, - 'email': user.email, + "type": token_type, + "sub": str(user.id), + "user": { + "username": user.username, + "email": user.email, }, - 'roles': [g.name for g in groups] + "roles": [g.name for g in groups], } + async def get_token(token: str = Depends(oauth2_scheme)) -> Dict: """Dependency: token introspection""" payload = decode_token(token) @@ -71,11 +82,15 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: payload = decode_token(token) user_id = payload.get("sub") if user_id is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload" + ) user = await User.filter(id=user_id).first() if not user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" + ) return user @@ -101,15 +116,13 @@ async def require_staff(current_user: User = Depends(get_current_user)) -> User: async def require_group( - group_name: str, - current_user: User = Depends(get_current_user) + 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}'" + status_code=403, detail=f"User must belong to group '{group_name}'" ) return current_user @@ -124,15 +137,21 @@ 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") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials" + ) - access_token = create_token(claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS) - refresh_token = create_token(claims(TokenType.refresh, user), REFRESH_TOKEN_EXPIRE_SECONDS) + access_token = create_token( + claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS + ) + refresh_token = create_token( + claims(TokenType.refresh, user), REFRESH_TOKEN_EXPIRE_SECONDS + ) return { "access_token": access_token, "refresh_token": refresh_token, - "token_type": "bearer" + "token_type": "bearer", } @@ -141,14 +160,20 @@ 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") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token" + ) user_id = payload.get("sub") user = await User.filter(id=user_id).first() if not user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" + ) - new_access = create_token(claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS) + new_access = create_token( + claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS + ) return {"access_token": new_access, "token_type": "bearer"} @@ -161,4 +186,3 @@ async def introspect(token: Dict = Depends(get_token)): async def me(user: User = Depends(get_current_user)): """Return the currently authenticated user.""" return User.Schema.one.from_orm(user) - diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index 4f4a824..9825cf9 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -2,14 +2,17 @@ import asyncio import atexit import importlib import sys +from getpass import getpass +from pathlib import Path + import typer import uvicorn -from getpass import getpass -from ohmyapi.core import scaffolding, runtime -from pathlib import Path +from ohmyapi.core import runtime, scaffolding -app = typer.Typer(help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM.") +app = typer.Typer( + help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM." +) @app.command() @@ -78,6 +81,7 @@ def shell(root: str = "."): start_ipython(argv=[], user_ns=shell_vars, config=c) except ImportError: import code + code.interact(local=shell_vars, banner=banner) finally: loop.run_until_complete(cleanup()) @@ -120,11 +124,15 @@ 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.") + print( + "Auth app not installed! Please add 'ohmyapi_auth' to your INSTALLED_APPS." + ) return import asyncio + import ohmyapi_auth + email = input("E-Mail: ") username = input("Username: ") password1, password2 = "foo", "bar" @@ -133,9 +141,10 @@ def createsuperuser(root: str = "."): password2 = getpass("Repeat Password: ") if password1 != password2: print("Passwords didn't match!") - user = ohmyapi_auth.models.User(email=email, username=username, is_staff=True, is_admin=True) + user = ohmyapi_auth.models.User( + email=email, username=username, is_staff=True, is_admin=True + ) user.set_password(password1) asyncio.run(project.init_orm()) asyncio.run(user.save()) asyncio.run(project.close_orm()) - diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index d07fd3f..380c56d 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -11,8 +11,9 @@ from typing import Any, Dict, Generator, List, Optional import click from aerich import Command as AerichCommand from aerich.exceptions import NotInitedError +from fastapi import APIRouter, FastAPI from tortoise import Tortoise -from fastapi import FastAPI, APIRouter + from ohmyapi.db.model import Model @@ -44,7 +45,9 @@ class Project: orig = importlib.import_module(full) sys.modules[alias] = orig try: - sys.modules[f"{alias}.models"] = importlib.import_module(f"{full}.models") + sys.modules[f"{alias}.models"] = importlib.import_module( + f"{full}.models" + ) except ModuleNotFoundError: pass @@ -52,7 +55,9 @@ class Project: 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 + 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", []): @@ -104,11 +109,16 @@ class Project: 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"} + 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: + 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 @@ -129,7 +139,7 @@ class Project: return AerichCommand( tortoise_config=tortoise_cfg, app=flat_label, - location=str(self.migrations_dir) + location=str(self.migrations_dir), ) # --- ORM lifecycle --- @@ -144,7 +154,9 @@ class Project: await Tortoise.close_connections() # --- Migration helpers --- - async def makemigrations(self, app_label: str, name: str = "auto", db_url: Optional[str] = None) -> None: + 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() @@ -158,7 +170,9 @@ class Project: 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: + 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: @@ -231,8 +245,11 @@ class App: "name": route.name, "methods": list(route.methods), "endpoint": route.endpoint.__name__, # just the function name - "response_model": getattr(route, "response_model", None).__name__ - if getattr(route, "response_model", None) else None, + "response_model": ( + getattr(route, "response_model", None).__name__ + if getattr(route, "response_model", None) + else None + ), "tags": getattr(route, "tags", None), } @@ -241,8 +258,8 @@ class App: def dict(self) -> Dict[str, Any]: return { - 'models': [m.__name__ for m in self.models], - 'routes': self._serialize_router(), + "models": [m.__name__ for m in self.models], + "routes": self._serialize_router(), } @property @@ -250,10 +267,13 @@ class App: 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': + if ( + isinstance(obj, type) + and getattr(obj, "_meta", None) is not None + and obj.__name__ != "Model" + ): yield obj @property def routes(self): return self.router.routes - diff --git a/src/ohmyapi/core/scaffolding.py b/src/ohmyapi/core/scaffolding.py index ac834dc..1d3f38f 100644 --- a/src/ohmyapi/core/scaffolding.py +++ b/src/ohmyapi/core/scaffolding.py @@ -1,4 +1,5 @@ from pathlib import Path + from jinja2 import Environment, FileSystemLoader # Base templates directory @@ -8,14 +9,21 @@ 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("\\", "/")) + template = env.get_template( + str(template_path.relative_to(TEMPLATE_DIR)).replace("\\", "/") + ) content = template.render(**context) output_path.parent.mkdir(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): +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. @@ -23,14 +31,18 @@ def render_template_dir(template_subdir: str, target_dir: Path, context: dict, s template_dir = TEMPLATE_DIR / template_subdir for root, _, files in template_dir.walk(): root_path = Path(root) - rel_root = root_path.relative_to(template_dir) # path relative to template_subdir + 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 + output_rel_path = Path(*template_rel_path.parts).with_suffix( + "" + ) # remove .j2 # optionally wrap in subdir_name if subdir_name: @@ -54,7 +66,11 @@ def startapp(name: str, project: str): """Create a new app inside a project: templates go into //""" target_dir = Path(project) target_dir.mkdir(exist_ok=True) - render_template_dir("app", target_dir, {"project_name": target_dir.resolve().name, "app_name": name}, subdir_name=name) + render_template_dir( + "app", + target_dir, + {"project_name": target_dir.resolve().name, "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!") - diff --git a/src/ohmyapi/db/__init__.py b/src/ohmyapi/db/__init__.py index 1693f81..e526ef5 100644 --- a/src/ohmyapi/db/__init__.py +++ b/src/ohmyapi/db/__init__.py @@ -1,10 +1,10 @@ -from .model import Model, field from tortoise.manager import Manager from tortoise.queryset import QuerySet from tortoise.signals import ( - pre_delete, post_delete, - pre_save, post_save, + pre_delete, + pre_save, ) +from .model import Model, field diff --git a/src/ohmyapi/db/exceptions.py b/src/ohmyapi/db/exceptions.py index 19d761c..79466de 100644 --- a/src/ohmyapi/db/exceptions.py +++ b/src/ohmyapi/db/exceptions.py @@ -1,2 +1 @@ from tortoise.exceptions import * - diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index e67b81e..fd5fad0 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -1,22 +1,27 @@ -from pydantic_core import core_schema -from pydantic import GetCoreSchemaHandler -from tortoise import fields as field -from tortoise.models import Model as TortoiseModel -from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator from uuid import UUID +from pydantic import GetCoreSchemaHandler +from pydantic_core import core_schema +from tortoise import fields as field +from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator +from tortoise.models import Model as TortoiseModel + def __uuid_schema_monkey_patch(cls, source_type, handler): # Always treat UUID as string schema return core_schema.no_info_after_validator_function( # Accept UUID or str, always return UUID internally lambda v: v if isinstance(v, UUID) else UUID(str(v)), - core_schema.union_schema([ - core_schema.str_schema(), - core_schema.is_instance_schema(UUID), - ]), + core_schema.union_schema( + [ + core_schema.str_schema(), + core_schema.is_instance_schema(UUID), + ] + ), # But when serializing, always str() - serialization=core_schema.plain_serializer_function_ser_schema(str, when_used="always"), + serialization=core_schema.plain_serializer_function_ser_schema( + str, when_used="always" + ), ) @@ -64,4 +69,3 @@ class Model(TortoiseModel, metaclass=ModelMeta): class Schema: include = None exclude = None - diff --git a/src/ohmyapi/router.py b/src/ohmyapi/router.py index f7d5860..23f5500 100644 --- a/src/ohmyapi/router.py +++ b/src/ohmyapi/router.py @@ -1,2 +1 @@ from fastapi import APIRouter, Depends, HTTPException - From 905ce66b1a5e860d97830e1c2fbeb0bde126be3b Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 15:52:46 +0200 Subject: [PATCH 065/172] =?UTF-8?q?=F0=9F=94=96=200.1.21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e61a785..2e1ef57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.20" +version = "0.1.21" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 3e682bbc8986e9f372943b97e2fc27d3a4adeced Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 17:04:00 +0200 Subject: [PATCH 066/172] =?UTF-8?q?=F0=9F=90=9B=20Make=20apps=20"just=20wo?= =?UTF-8?q?rk"=20out-of-the-box?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/templates/app/routes.py.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/core/templates/app/routes.py.j2 b/src/ohmyapi/core/templates/app/routes.py.j2 index 044a533..a5a4d4f 100644 --- a/src/ohmyapi/core/templates/app/routes.py.j2 +++ b/src/ohmyapi/core/templates/app/routes.py.j2 @@ -1,6 +1,6 @@ from ohmyapi.router import APIRouter -from .models import ... +from . import models # Expose your app's routes via `router = fastapi.APIRouter`. # Use prefixes wisely to avoid cross-app namespace-collisions. From c8206547d885abb1ff66cd77108999572f76bdda Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 17:32:34 +0200 Subject: [PATCH 067/172] =?UTF-8?q?=F0=9F=8E=A8=20Add=20http.HTTPStatus=20?= =?UTF-8?q?for=20convenience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/router.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ohmyapi/router.py b/src/ohmyapi/router.py index 23f5500..3a0c442 100644 --- a/src/ohmyapi/router.py +++ b/src/ohmyapi/router.py @@ -1 +1,3 @@ from fastapi import APIRouter, Depends, HTTPException +from http import HTTPStatus + From 111a65da858079e8a14676b0fdf7a0f354c68538 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 17:33:21 +0200 Subject: [PATCH 068/172] =?UTF-8?q?=F0=9F=8E=A8=20CRUD=20endpoints=20boile?= =?UTF-8?q?rplate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/templates/app/routes.py.j2 | 38 +++++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/ohmyapi/core/templates/app/routes.py.j2 b/src/ohmyapi/core/templates/app/routes.py.j2 index a5a4d4f..dd1579a 100644 --- a/src/ohmyapi/core/templates/app/routes.py.j2 +++ b/src/ohmyapi/core/templates/app/routes.py.j2 @@ -1,17 +1,41 @@ -from ohmyapi.router import APIRouter +from ohmyapi.router import APIRouter, HTTPException, HTTPStatus from . import models +from typing import List + # Expose your app's routes via `router = fastapi.APIRouter`. # Use prefixes wisely to avoid cross-app namespace-collisions. # Tags improve the UX of the OpenAPI docs at /docs. router = APIRouter(prefix="/{{ app_name }}", tags=['{{ app_name }}']) -@router.get("/") -def hello_world(): - return { - "project": "{{ project_name }}", - "app": "{{ app_name }}", - } + +@router.get("/") +async def list(): + """List all ...""" + return [] + + +@router.post("/") +async def post(): + """Create ...""" + return HTTPException(status_code=HTTPStatus.CREATED) + + +@router.get("/{id}") +async def get(id: str): + """Get single ...""" + return {} + + +@router.put("/{id}") +async def put(id: str): + """Update ...""" + return HTTPException(status_code=HTTPStatus.ACCEPTED) + + +@router.delete("/{id}") +async def delete(id: str): + return HTTPException(status_code=HTTPStatus.ACCEPTED) From 31dd3a9e377cc1c4d6ab9be95cfd91ad054b76fa Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 17:34:05 +0200 Subject: [PATCH 069/172] =?UTF-8?q?=F0=9F=93=9D=20Reflect=20latest=20chang?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 89 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f467745..6cee2f9 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ class Team(Model): Next, create your endpoints in `tournament/routes.py`: ```python -from ohmyapi.router import APIRouter, HTTPException +from ohmyapi.router import APIRouter, HTTPException, HTTPStatus from ohmyapi.db.exceptions import DoesNotExist from .models import Tournament @@ -135,20 +135,25 @@ from .models import Tournament # Tags improve the UX of the OpenAPI docs at /docs. router = APIRouter(prefix="/tournament", tags=['Tournament']) - @router.get("/") async def list(): queryset = Tournament.all() return await Tournament.Schema.model.from_queryset(queryset) +@router.post("/", status_code=HTTPStatus.CREATED) +async def post(tournament: Tournament.Schema.readonly): + queryset = Tournament.create(**payload.model_dump()) + return await Tournament.Schema.model.from_queryset(queryset) + + @router.get("/:id") async def get(id: str): try: - tournament = await Tournament.get(pk=id) + queryset = Tournament.get(id=id) return await Tournament.Schema.model.from_queryset_single(tournament) except DoesNotExist: - raise HTTPException(status_code=404, detail="item not found") + raise HTTPException(status_code=404, detail="not found") ... ``` @@ -318,27 +323,65 @@ Find your loaded project singleton via identifier: `p` ```python In [1]: p -Out[1]: +Out[1]: In [2]: p.apps Out[2]: -{'ohmyapi_auth': App: ohmyapi_auth - Models: - - Group - - User - Routes: - - APIRoute(path='/auth/login', name='login', methods=['POST']) - - APIRoute(path='/auth/refresh', name='refresh_token', methods=['POST']) - - APIRoute(path='/auth/introspect', name='introspect', methods=['GET']) - - APIRoute(path='/auth/me', name='me', methods=['GET']), - 'tournament': App: tournament - Models: - - Tournament - - Event - - Team - Routes: - - APIRoute(path='/tournament/', name='list', methods=['GET'])} - -In [3]: from tournament.models import Tournament +{'ohmyapi_auth': { + "models": [ + "Group", + "User" + ], + "routes": [ + { + "path": "/auth/login", + "name": "login", + "methods": [ + "POST" + ], + "endpoint": "login", + "response_model": null, + "tags": [ + "auth" + ] + }, + { + "path": "/auth/refresh", + "name": "refresh_token", + "methods": [ + "POST" + ], + "endpoint": "refresh_token", + "response_model": null, + "tags": [ + "auth" + ] + }, + { + "path": "/auth/introspect", + "name": "introspect", + "methods": [ + "GET" + ], + "endpoint": "introspect", + "response_model": null, + "tags": [ + "auth" + ] + }, + { + "path": "/auth/me", + "name": "me", + "methods": [ + "GET" + ], + "endpoint": "me", + "response_model": null, + "tags": [ + "auth" + ] + } + ] + }} ``` From 64d6ca369ff00692e4ca7487adbff88c0a3b5236 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 17:34:34 +0200 Subject: [PATCH 070/172] =?UTF-8?q?=F0=9F=94=96=200.1.22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2e1ef57..39f31b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.21" +version = "0.1.22" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From 90f257ae382213ac879f70ff014563d297364dfb Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 19:23:22 +0200 Subject: [PATCH 071/172] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20ohmyapi?= =?UTF-8?q?=5Fauth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - improved type-safety - created and defined response_models --- src/ohmyapi/builtin/auth/routes.py | 111 ++++++++++++++++++----------- src/ohmyapi/router.py | 1 - 2 files changed, 69 insertions(+), 43 deletions(-) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index cdb735e..aa09e44 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -26,9 +26,64 @@ REFRESH_TOKEN_EXPIRE_SECONDS = getattr( 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}) +class ClaimsUser(BaseModel): + username: str + email: str + is_admin: bool + is_staff: bool + + +class Claims(BaseModel): + type: str + sub: str + user: ClaimsUser + roles: List[str] + exp: str + + +class AccessToken(BaseModel): + token_type: str + access_token: str + + +class RefreshToken(AccessToken): + refresh_token: str + + +class LoginRequest(BaseModel): + username: str + password: str + + +class TokenType(str, Enum): + """ + Helper for indicating the token type when generating claims. + """ + + access = "access" + refresh = "refresh" + + +def claims( + token_type: TokenType, user: User, groups: List[Group] = [] +) -> Claims: + return Claims( + type=token_type, + sub=str(user.id), + user=ClaimsUser( + username=user.username, + email=user.email, + is_admin=user.is_admin, + is_staff=user.is_staff, + ), + roles=[g.name for g in groups], + exp="", + ) + + +def create_token(claims: Claims, expires_in: int) -> str: + to_encode = claims.model_dump() + to_encode['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") @@ -48,29 +103,6 @@ def decode_token(token: str) -> Dict: ) -class TokenType(str, Enum): - """ - Helper for indicating the token type when generating claims. - """ - - access = "access" - refresh = "refresh" - - -def claims( - token_type: TokenType, user: User, groups: List[Group] = [] -) -> Dict[str, Any]: - return { - "type": token_type, - "sub": str(user.id), - "user": { - "username": user.username, - "email": user.email, - }, - "roles": [g.name for g in groups], - } - - async def get_token(token: str = Depends(oauth2_scheme)) -> Dict: """Dependency: token introspection""" payload = decode_token(token) @@ -127,12 +159,7 @@ async def require_group( return current_user -class LoginRequest(BaseModel): - username: str - password: str - - -@router.post("/login") +@router.post("/login", response_model=RefreshToken) 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) @@ -148,14 +175,14 @@ async def login(form_data: LoginRequest = Body(...)): claims(TokenType.refresh, user), REFRESH_TOKEN_EXPIRE_SECONDS ) - return { - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": "bearer", - } + return RefreshToken( + token_type="bearer", + access_token=access_token, + refresh_token=refresh_token, + ) -@router.post("/refresh") +@router.post("/refresh", response_model=AccessToken) async def refresh_token(refresh_token: str): """Exchange refresh token for new access token.""" payload = decode_token(refresh_token) @@ -174,15 +201,15 @@ async def refresh_token(refresh_token: str): new_access = create_token( claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS ) - return {"access_token": new_access, "token_type": "bearer"} + return AccessToken(token_type="bearer", access_token=access_token) -@router.get("/introspect") +@router.get("/introspect", response_model=Dict[str, Any]) async def introspect(token: Dict = Depends(get_token)): return token -@router.get("/me") +@router.get("/me", response_model=User.Schema.model) async def me(user: User = Depends(get_current_user)): """Return the currently authenticated user.""" - return User.Schema.one.from_orm(user) + return await User.Schema.model.from_tortoise_orm(user) diff --git a/src/ohmyapi/router.py b/src/ohmyapi/router.py index 3a0c442..ed96e12 100644 --- a/src/ohmyapi/router.py +++ b/src/ohmyapi/router.py @@ -1,3 +1,2 @@ from fastapi import APIRouter, Depends, HTTPException from http import HTTPStatus - From 61ef27936cc1708ca4acd7f3dded1212da89ae56 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 19:26:37 +0200 Subject: [PATCH 072/172] =?UTF-8?q?=F0=9F=8D=B1=20Add=20ohmyapi=5Fdemo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/demo/__init__.py | 2 ++ src/ohmyapi/builtin/demo/models.py | 50 ++++++++++++++++++++++++++ src/ohmyapi/builtin/demo/routes.py | 54 ++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/ohmyapi/builtin/demo/__init__.py create mode 100644 src/ohmyapi/builtin/demo/models.py create mode 100644 src/ohmyapi/builtin/demo/routes.py diff --git a/src/ohmyapi/builtin/demo/__init__.py b/src/ohmyapi/builtin/demo/__init__.py new file mode 100644 index 0000000..322de88 --- /dev/null +++ b/src/ohmyapi/builtin/demo/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import routes diff --git a/src/ohmyapi/builtin/demo/models.py b/src/ohmyapi/builtin/demo/models.py new file mode 100644 index 0000000..d09276b --- /dev/null +++ b/src/ohmyapi/builtin/demo/models.py @@ -0,0 +1,50 @@ +from ohmyapi.db import Model, field +from ohmyapi_auth.models import User + +from datetime import datetime +from decimal import Decimal +from uuid import UUID + + +class Team(Model): + id: UUID = field.data.UUIDField(primary_key=True) + name: str = field.TextField() + members: field.ManyToManyRelation[User] = field.ManyToManyField( + 'ohmyapi_auth.User', + related_name="tournament_teams", + through='user_tournament_teams', + ) + + def __str__(self): + return self.name + + +class Tournament(Model): + id: UUID = field.data.UUIDField(primary_key=True) + name: str = field.TextField() + created: datetime = field.DatetimeField(auto_now_add=True) + + def __str__(self): + return self.name + + +class Event(Model): + id: UUID = field.data.UUIDField(primary_key=True) + name: str = field.TextField() + tournament: field.ForeignKeyRelation[Tournament] = field.ForeignKeyField( + 'ohmyapi_demo.Tournament', + related_name='events', + ) + participants: field.ManyToManyRelation[Team] = field.ManyToManyField( + 'ohmyapi_demo.Team', + related_name='events', + through='event_team', + ) + modified: datetime = field.DatetimeField(auto_now=True) + prize: Decimal = field.DecimalField(max_digits=10, decimal_places=2, null=True) + + class Schema: + exclude = ['tournament_id'] + + def __str__(self): + return self.name diff --git a/src/ohmyapi/builtin/demo/routes.py b/src/ohmyapi/builtin/demo/routes.py new file mode 100644 index 0000000..0533976 --- /dev/null +++ b/src/ohmyapi/builtin/demo/routes.py @@ -0,0 +1,54 @@ +from ohmyapi.router import APIRouter, HTTPException, HTTPStatus +from ohmyapi.db.exceptions import DoesNotExist + +from . import models + +from typing import List + +# Expose your app's routes via `router = fastapi.APIRouter`. +# Use prefixes wisely to avoid cross-app namespace-collisions. +# Tags improve the UX of the OpenAPI docs at /docs. +router = APIRouter(prefix="/tournemant") + + +@router.get("/", + tags=["tournament"], + response_model=List[models.Tournament.Schema.model]) +async def list(): + """List all tournaments.""" + return await models.Tournament.Schema.model.from_queryset(Tournament.all()) + + +@router.post("/", + tags=["tournament"], + status_code=HTTPStatus.CREATED) +async def post(tournament: models.Tournament.Schema.readonly): + """Create tournament.""" + return await models.Tournament.Schema.model.from_queryset(models.Tournament.create(**tournament.model_dump())) + + +@router.get("/{id}", + tags=["tournament"], + response_model=models.Tournament.Schema.model) +async def get(id: str): + """Get tournament by id.""" + return await models.Tournament.Schema.model.from_queryset(models.Tournament.get(id=id)) + + +@router.put("/{id}", + tags=["tournament"], + response_model=models.Tournament.Schema.model, + status_code=HTTPStatus.ACCEPTED) +async def put(tournament: models.Tournament.Schema.model): + """Update tournament.""" + return await models.Tournament.Schema.model.from_queryset(models.Tournament.update(**tournament.model_dump())) + + +@router.delete("/{id}", tags=["tournament"]) +async def delete(id: str): + try: + tournament = await models.Tournament.get(id=id) + return await tournament.delete() + except DoesNotExist: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="not found") + From 250bf142ed38613a31552b6a20ac488037144e74 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 19:27:09 +0200 Subject: [PATCH 073/172] =?UTF-8?q?=F0=9F=94=96=200.1.23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 39f31b7..44ee38a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.22" +version = "0.1.23" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From ff8384d2c5bf8c59078e13cdb2e0bcef4f78f491 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 19:36:48 +0200 Subject: [PATCH 074/172] =?UTF-8?q?=F0=9F=93=9D=20Reword?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6cee2f9..6e4946f 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,26 @@ > Think: Django RestFramework, but less clunky and 100% async. -OhMyAPI is a Django-flavored web-application scaffolding framework and management layer. -Built around FastAPI and TortoiseORM, it is 100% async. +OhMyAPI is a Django-flavored web-application scaffolding framework and management layer, +built around FastAPI and TortoiseORM and is thus 100% async. -It is ***blazingly fast***, ***fun to use*** and comes with ***batteries included***! +It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***! **Features** -- Django-like project-layout and -structure -- Django-like project-level settings.py -- Django-like models via TortoiseORM -- Django-like `Model.Meta` class for model configuration -- Easily convert your query results to `pydantic` models via `Model.Schema` -- Django-like migrations (`makemigrations` & `migrate`) via Aerich +- Django-like project structure and application directories +- Django-like per-app migrations (`makemigrations` & `migrate`) via Aerich - Django-like CLI tooling (`startproject`, `startapp`, `shell`, `serve`, etc) -- Various optional builtin apps you can hook into your project +- Customizable pydantic model serializer built-in +- Various optional built-in apps you can hook into your project - Highly configurable and customizable - 100% async **Goals** -- combine FastAPI, TortoiseORM and Aerich migrations into a high-productivity web-application framework -- tie everything neatly together into a concise API -- while ***AVOIDING*** any additional abstractions ontop of Tortoise's model-system or FastAPI's routing system +- combine `FastAPI`, `TortoiseORM`, `Aerich` migrations and `Pydantic` into a high-productivity web-application framework +- tie everything neatly together into a concise and straight-forward API +- ***AVOID*** any adding abstractions unless making things extremely convenient --- From b07df29c9cd14079fa3b84fd625f6d86157bef6d Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 19:40:54 +0200 Subject: [PATCH 075/172] =?UTF-8?q?=F0=9F=9A=A8=20Python=20Black=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/routes.py | 6 ++--- src/ohmyapi/builtin/demo/models.py | 16 ++++++------- src/ohmyapi/builtin/demo/routes.py | 37 ++++++++++++++++-------------- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index aa09e44..40a65d5 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -64,9 +64,7 @@ class TokenType(str, Enum): refresh = "refresh" -def claims( - token_type: TokenType, user: User, groups: List[Group] = [] -) -> Claims: +def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Claims: return Claims( type=token_type, sub=str(user.id), @@ -83,7 +81,7 @@ def claims( def create_token(claims: Claims, expires_in: int) -> str: to_encode = claims.model_dump() - to_encode['exp'] = int(time.time()) + expires_in + to_encode["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") diff --git a/src/ohmyapi/builtin/demo/models.py b/src/ohmyapi/builtin/demo/models.py index d09276b..f0f041f 100644 --- a/src/ohmyapi/builtin/demo/models.py +++ b/src/ohmyapi/builtin/demo/models.py @@ -10,9 +10,9 @@ class Team(Model): id: UUID = field.data.UUIDField(primary_key=True) name: str = field.TextField() members: field.ManyToManyRelation[User] = field.ManyToManyField( - 'ohmyapi_auth.User', + "ohmyapi_auth.User", related_name="tournament_teams", - through='user_tournament_teams', + through="user_tournament_teams", ) def __str__(self): @@ -32,19 +32,19 @@ class Event(Model): id: UUID = field.data.UUIDField(primary_key=True) name: str = field.TextField() tournament: field.ForeignKeyRelation[Tournament] = field.ForeignKeyField( - 'ohmyapi_demo.Tournament', - related_name='events', + "ohmyapi_demo.Tournament", + related_name="events", ) participants: field.ManyToManyRelation[Team] = field.ManyToManyField( - 'ohmyapi_demo.Team', - related_name='events', - through='event_team', + "ohmyapi_demo.Team", + related_name="events", + through="event_team", ) modified: datetime = field.DatetimeField(auto_now=True) prize: Decimal = field.DecimalField(max_digits=10, decimal_places=2, null=True) class Schema: - exclude = ['tournament_id'] + exclude = ["tournament_id"] def __str__(self): return self.name diff --git a/src/ohmyapi/builtin/demo/routes.py b/src/ohmyapi/builtin/demo/routes.py index 0533976..5489942 100644 --- a/src/ohmyapi/builtin/demo/routes.py +++ b/src/ohmyapi/builtin/demo/routes.py @@ -11,37 +11,41 @@ from typing import List router = APIRouter(prefix="/tournemant") -@router.get("/", - tags=["tournament"], - response_model=List[models.Tournament.Schema.model]) +@router.get( + "/", tags=["tournament"], response_model=List[models.Tournament.Schema.model] +) async def list(): """List all tournaments.""" return await models.Tournament.Schema.model.from_queryset(Tournament.all()) -@router.post("/", - tags=["tournament"], - status_code=HTTPStatus.CREATED) +@router.post("/", tags=["tournament"], status_code=HTTPStatus.CREATED) async def post(tournament: models.Tournament.Schema.readonly): """Create tournament.""" - return await models.Tournament.Schema.model.from_queryset(models.Tournament.create(**tournament.model_dump())) + return await models.Tournament.Schema.model.from_queryset( + models.Tournament.create(**tournament.model_dump()) + ) -@router.get("/{id}", - tags=["tournament"], - response_model=models.Tournament.Schema.model) +@router.get("/{id}", tags=["tournament"], response_model=models.Tournament.Schema.model) async def get(id: str): """Get tournament by id.""" - return await models.Tournament.Schema.model.from_queryset(models.Tournament.get(id=id)) + return await models.Tournament.Schema.model.from_queryset( + models.Tournament.get(id=id) + ) -@router.put("/{id}", - tags=["tournament"], - response_model=models.Tournament.Schema.model, - status_code=HTTPStatus.ACCEPTED) +@router.put( + "/{id}", + tags=["tournament"], + response_model=models.Tournament.Schema.model, + status_code=HTTPStatus.ACCEPTED, +) async def put(tournament: models.Tournament.Schema.model): """Update tournament.""" - return await models.Tournament.Schema.model.from_queryset(models.Tournament.update(**tournament.model_dump())) + return await models.Tournament.Schema.model.from_queryset( + models.Tournament.update(**tournament.model_dump()) + ) @router.delete("/{id}", tags=["tournament"]) @@ -51,4 +55,3 @@ async def delete(id: str): return await tournament.delete() except DoesNotExist: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="not found") - From 737a06c05d21efb3c9478d9d38a55b30c34d4e80 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 19:52:17 +0200 Subject: [PATCH 076/172] =?UTF-8?q?=F0=9F=93=9D=20Typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6e4946f..07a3dae 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteri - combine `FastAPI`, `TortoiseORM`, `Aerich` migrations and `Pydantic` into a high-productivity web-application framework - tie everything neatly together into a concise and straight-forward API -- ***AVOID*** any adding abstractions unless making things extremely convenient +- ***AVOID*** adding any abstractions on top, unless they make things extremely convenient --- From cc2c9a3647cf6ff210ff33c0af229e364b937184 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 13:47:47 +0200 Subject: [PATCH 077/172] =?UTF-8?q?=F0=9F=90=9B=20Fix=20FQMN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/demo/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/builtin/demo/routes.py b/src/ohmyapi/builtin/demo/routes.py index 5489942..212c2be 100644 --- a/src/ohmyapi/builtin/demo/routes.py +++ b/src/ohmyapi/builtin/demo/routes.py @@ -16,7 +16,7 @@ router = APIRouter(prefix="/tournemant") ) async def list(): """List all tournaments.""" - return await models.Tournament.Schema.model.from_queryset(Tournament.all()) + return await models.Tournament.Schema.model.from_queryset(models.Tournament.all()) @router.post("/", tags=["tournament"], status_code=HTTPStatus.CREATED) From 7edd17d359da6e088052c4548ed5e2559410d43b Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 14:34:53 +0200 Subject: [PATCH 078/172] =?UTF-8?q?=F0=9F=8D=B1=20Solely=20depend=20on=20O?= =?UTF-8?q?hMyAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/templates/project/pyproject.toml.j2 | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/ohmyapi/core/templates/project/pyproject.toml.j2 b/src/ohmyapi/core/templates/project/pyproject.toml.j2 index 7aa7186..7b2a532 100644 --- a/src/ohmyapi/core/templates/project/pyproject.toml.j2 +++ b/src/ohmyapi/core/templates/project/pyproject.toml.j2 @@ -10,18 +10,7 @@ readme = "README.md" license = { text = "MIT" } 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", + "ohmyapi (>=0.1.0,<0.2.0)" ] [tool.poetry.group.dev.dependencies] From 4fffeda0bafffb0935e347aad6920b278ede5cae Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 14:35:33 +0200 Subject: [PATCH 079/172] =?UTF-8?q?=20=F0=9F=94=96=200.1.24?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 44ee38a..8bc02a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.23" +version = "0.1.24" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From e142489ed992d905df562a3dc36d47b4079f2268 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 15:32:05 +0200 Subject: [PATCH 080/172] =?UTF-8?q?=F0=9F=8E=A8=20Use=204=20spaces=20and?= =?UTF-8?q?=20double=20quotes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/templates/project/settings.py.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/core/templates/project/settings.py.j2 b/src/ohmyapi/core/templates/project/settings.py.j2 index 6d65f01..edc9580 100644 --- a/src/ohmyapi/core/templates/project/settings.py.j2 +++ b/src/ohmyapi/core/templates/project/settings.py.j2 @@ -2,6 +2,6 @@ PROJECT_NAME = "MyProject" DATABASE_URL = "sqlite://db.sqlite3" INSTALLED_APPS = [ - #'ohmyapi_auth', + #"ohmyapi_auth", ] From 1b830f7bd2c44807e0f804ceba626f1d7abb3a90 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 17:17:54 +0200 Subject: [PATCH 081/172] =?UTF-8?q?=F0=9F=93=9D=20Reflect=20latest=20chang?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 ++++++++++++++++----- src/ohmyapi/core/runtime.py | 11 +++++--- src/ohmyapi/core/templates/app/routes.py.j2 | 9 ++++--- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 07a3dae..6996844 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteri **Creating a Project** ``` -pip install ohmyapi +pipx install ohmyapi ohmyapi startproject myproject cd myproject ``` @@ -127,24 +127,29 @@ from ohmyapi.db.exceptions import DoesNotExist from .models import Tournament -# Expose your app's routes via `router = fastapi.APIRouter`. -# Use prefixes wisely to avoid cross-app namespace-collisions. +# OhMyAPI will automatically pick up all instances of `fastapi.APIRouter` and +# add their routes to the main project router. +# +# Note: +# Use prefixes wisely to avoid cross-app namespace-collisions! # Tags improve the UX of the OpenAPI docs at /docs. -router = APIRouter(prefix="/tournament", tags=['Tournament']) +# +tournament_router = APIRouter(prefix="/tournament", tags=['Tournament']) -@router.get("/") + +@tournament_router.get("/") async def list(): queryset = Tournament.all() return await Tournament.Schema.model.from_queryset(queryset) -@router.post("/", status_code=HTTPStatus.CREATED) +@tournament_router.post("/", status_code=HTTPStatus.CREATED) async def post(tournament: Tournament.Schema.readonly): queryset = Tournament.create(**payload.model_dump()) return await Tournament.Schema.model.from_queryset(queryset) -@router.get("/:id") +@tournament_router.get("/:id") async def get(id: str): try: queryset = Tournament.get(id=id) @@ -152,6 +157,16 @@ async def get(id: str): except DoesNotExist: raise HTTPException(status_code=404, detail="not found") + +@tournament_router.delete("/:id") +async def delete(id: str): + try: + tournament = await Tournament.get(id=id) + return await Tournament.Schema.model.from_queryset(tournament.delete()) + except DoesNotExist: + raise HTTPException(status_code=404, detail="not found") + + ... ``` diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 380c56d..87127f8 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -211,7 +211,7 @@ class App: self.model_modules: List[str] = [] # The APIRouter - self.router: Optional[APIRouter] = None + self.router: APIRouter = APIRouter() # Import the app, so its __init__.py runs. importlib.import_module(self.name) @@ -226,9 +226,12 @@ class App: # 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 + for attr_name in dir(routes_mod): + if attr_name.startswith("__"): + continue + attr = getattr(routes_mod, attr_name) + if isinstance(attr, APIRouter): + self.router.include_router(attr) except ModuleNotFoundError: pass diff --git a/src/ohmyapi/core/templates/app/routes.py.j2 b/src/ohmyapi/core/templates/app/routes.py.j2 index dd1579a..3edc01e 100644 --- a/src/ohmyapi/core/templates/app/routes.py.j2 +++ b/src/ohmyapi/core/templates/app/routes.py.j2 @@ -4,13 +4,16 @@ from . import models from typing import List -# Expose your app's routes via `router = fastapi.APIRouter`. -# Use prefixes wisely to avoid cross-app namespace-collisions. +# OhMyAPI will automatically pick up all instances of `fastapi.APIRouter` and +# add their routes to the main project router. +# +# Note: +# Use prefixes wisely to avoid cross-app namespace-collisions! # Tags improve the UX of the OpenAPI docs at /docs. +# router = APIRouter(prefix="/{{ app_name }}", tags=['{{ app_name }}']) - @router.get("/") async def list(): """List all ...""" From 29a5018ae3891396eed5c5fac218b02f95f8f4a8 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 17:19:20 +0200 Subject: [PATCH 082/172] =?UTF-8?q?=F0=9F=94=96=200.1.25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8bc02a5..1821c9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.24" +version = "0.1.25" description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] From c56ea6451e38255002e563ca16b1d424765b2e79 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 18:22:43 +0200 Subject: [PATCH 083/172] =?UTF-8?q?=F0=9F=8E=A8=20Make=20Model.Schema=20ca?= =?UTF-8?q?llable=20with=20readonly=20arg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 +++++++++------- src/ohmyapi/builtin/auth/routes.py | 4 ++-- src/ohmyapi/builtin/demo/routes.py | 18 +++++++++--------- src/ohmyapi/core/templates/app/routes.py.j2 | 8 ++++---- src/ohmyapi/db/model/model.py | 5 +++++ 5 files changed, 29 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6996844..44ee001 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ Next, create your endpoints in `tournament/routes.py`: from ohmyapi.router import APIRouter, HTTPException, HTTPStatus from ohmyapi.db.exceptions import DoesNotExist +from typing import List + from .models import Tournament # OhMyAPI will automatically pick up all instances of `fastapi.APIRouter` and @@ -137,23 +139,23 @@ from .models import Tournament tournament_router = APIRouter(prefix="/tournament", tags=['Tournament']) -@tournament_router.get("/") +@tournament_router.get("/", response_model=List[Tournament.Schema()]) async def list(): queryset = Tournament.all() return await Tournament.Schema.model.from_queryset(queryset) @tournament_router.post("/", status_code=HTTPStatus.CREATED) -async def post(tournament: Tournament.Schema.readonly): +async def post(tournament: Tournament.Schema(readonly=True)): queryset = Tournament.create(**payload.model_dump()) - return await Tournament.Schema.model.from_queryset(queryset) + return await Tournament.Schema().from_queryset(queryset) -@tournament_router.get("/:id") +@tournament_router.get("/:id", response_model=Tournament.Schema()) async def get(id: str): try: queryset = Tournament.get(id=id) - return await Tournament.Schema.model.from_queryset_single(tournament) + return await Tournament.Schema().from_queryset_single(tournament) except DoesNotExist: raise HTTPException(status_code=404, detail="not found") @@ -273,7 +275,7 @@ router = APIRouter(prefix="/tournament", tags=["Tournament"]) @router.get("/") async def list(user: auth.User = Depends(permissions.require_authenticated)): queryset = Tournament.all() - return await Tournament.Schema.model.from_queryset(queryset) + return await Tournament.Schema().from_queryset(queryset) ... @@ -315,7 +317,7 @@ router = APIRouter(prefix="/tournament", tags=["Tournament"]) @router.get("/teams") async def teams(user: auth.User = Depends(permissions.require_authenticated)): queryset = Team.for_user(user) - return await Tournament.Schema.model.from_queryset(queryset) + return await Tournament.Schema().from_queryset(queryset) ``` ## Shell diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 40a65d5..27b8193 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -207,7 +207,7 @@ async def introspect(token: Dict = Depends(get_token)): return token -@router.get("/me", response_model=User.Schema.model) +@router.get("/me", response_model=User.Schema()) async def me(user: User = Depends(get_current_user)): """Return the currently authenticated user.""" - return await User.Schema.model.from_tortoise_orm(user) + return await User.Schema().from_tortoise_orm(user) diff --git a/src/ohmyapi/builtin/demo/routes.py b/src/ohmyapi/builtin/demo/routes.py index 212c2be..0022039 100644 --- a/src/ohmyapi/builtin/demo/routes.py +++ b/src/ohmyapi/builtin/demo/routes.py @@ -8,29 +8,29 @@ from typing import List # Expose your app's routes via `router = fastapi.APIRouter`. # Use prefixes wisely to avoid cross-app namespace-collisions. # Tags improve the UX of the OpenAPI docs at /docs. -router = APIRouter(prefix="/tournemant") +router = APIRouter(prefix="/tournament") @router.get( - "/", tags=["tournament"], response_model=List[models.Tournament.Schema.model] + "/", tags=["tournament"], response_model=List[models.Tournament.Schema()] ) async def list(): """List all tournaments.""" - return await models.Tournament.Schema.model.from_queryset(models.Tournament.all()) + return await models.Tournament.Schema().from_queryset(models.Tournament.all()) @router.post("/", tags=["tournament"], status_code=HTTPStatus.CREATED) -async def post(tournament: models.Tournament.Schema.readonly): +async def post(tournament: models.Tournament.Schema(readonly=True)): """Create tournament.""" - return await models.Tournament.Schema.model.from_queryset( + return await models.Tournament.Schema().from_queryset( models.Tournament.create(**tournament.model_dump()) ) -@router.get("/{id}", tags=["tournament"], response_model=models.Tournament.Schema.model) +@router.get("/{id}", tags=["tournament"], response_model=models.Tournament.Schema()) async def get(id: str): """Get tournament by id.""" - return await models.Tournament.Schema.model.from_queryset( + return await models.Tournament.Schema().from_queryset( models.Tournament.get(id=id) ) @@ -43,12 +43,12 @@ async def get(id: str): ) async def put(tournament: models.Tournament.Schema.model): """Update tournament.""" - return await models.Tournament.Schema.model.from_queryset( + return await models.Tournament.Schema().from_queryset( models.Tournament.update(**tournament.model_dump()) ) -@router.delete("/{id}", tags=["tournament"]) +@router.delete("/{id}", status_code=HTTPStatus.ACCEPTED, tags=["tournament"]) async def delete(id: str): try: tournament = await models.Tournament.get(id=id) diff --git a/src/ohmyapi/core/templates/app/routes.py.j2 b/src/ohmyapi/core/templates/app/routes.py.j2 index 3edc01e..444f251 100644 --- a/src/ohmyapi/core/templates/app/routes.py.j2 +++ b/src/ohmyapi/core/templates/app/routes.py.j2 @@ -14,16 +14,16 @@ from typing import List router = APIRouter(prefix="/{{ app_name }}", tags=['{{ app_name }}']) -@router.get("/") +@router.get("/", response_model=List) async def list(): """List all ...""" return [] -@router.post("/") +@router.post("/", status_code=HTTPStatus.CREATED) async def post(): """Create ...""" - return HTTPException(status_code=HTTPStatus.CREATED) + raise HTTPException(status_code=HTTPStatus.IM_A_TEAPOT) @router.get("/{id}") @@ -38,7 +38,7 @@ async def put(id: str): return HTTPException(status_code=HTTPStatus.ACCEPTED) -@router.delete("/{id}") +@router.delete("/{id}", status_code=HTTPStatus.ACCEPTED) async def delete(id: str): return HTTPException(status_code=HTTPStatus.ACCEPTED) diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index fd5fad0..04e5d9c 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -36,6 +36,11 @@ class ModelMeta(type(TortoiseModel)): schema_opts = getattr(new_cls, "Schema", None) class BoundSchema: + def __call__(self, readonly: bool = False): + if readonly: + return self.readonly + return self.model + @property def model(self): """Return a Pydantic model class for serializing results.""" From 2399b28c521c69a894aac0617a53ba802570a8a6 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 18:28:48 +0200 Subject: [PATCH 084/172] =?UTF-8?q?=F0=9F=94=96=200.1.26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1821c9d..7447bea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,14 @@ [project] name = "ohmyapi" -version = "0.1.25" -description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." +version = "0.1.26" +description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" -keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] +keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] authors = [ {name = "Brian Wiborg", email = "me@brianwib.org"} ] readme = "README.md" -requires-python = ">=3.13" +requires-python = ">=3.10" dependencies = [ "typer >=0.19.1,<0.20.0", From 4550549c2c0f484b2b14f75c6e1997d53e822e6c Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 19:54:45 +0200 Subject: [PATCH 085/172] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20Remove=20flat?= =?UTF-8?q?=5Flabel=20artifact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 87127f8..7b0c340 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -119,26 +119,18 @@ class Project: 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") + if app_label not in self._apps: + 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") + tortoise_cfg["apps"][app_label]["models"].append("aerich.models") return AerichCommand( tortoise_config=tortoise_cfg, - app=flat_label, + app=app_label, location=str(self.migrations_dir), ) From bbadd1c1325c17c257db768e7cc59493d149f6ee Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 19:56:13 +0200 Subject: [PATCH 086/172] =?UTF-8?q?=F0=9F=94=96=200.1.27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poetry.lock | 10 +++++++--- pyproject.toml | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index b4fab51..8ccdf2e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -71,6 +71,7 @@ files = [ [package.dependencies] idna = ">=2.8" sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.31.0)"] @@ -568,6 +569,7 @@ prompt_toolkit = ">=3.0.41,<3.1.0" pygments = ">=2.4.0" stack_data = "*" traitlets = ">=5.13.0" +typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} [package.extras] all = ["ipython[doc,matplotlib,test,test-extra]"] @@ -1362,6 +1364,7 @@ files = [ [package.dependencies] anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] @@ -1432,11 +1435,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +markers = {dev = "python_version == \"3.11\""} [[package]] name = "typing-inspection" @@ -1507,5 +1511,5 @@ auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"] [metadata] lock-version = "2.1" -python-versions = ">=3.13" -content-hash = "145508f708df01d84d998947a87b95cfc269e197eb8bc7467e9748a3b8e210e5" +python-versions = ">=3.11" +content-hash = "c765d9f42a4d8bee26474bda7e19f0b6fdd43833688d0db781611090e9ee3b99" diff --git a/pyproject.toml b/pyproject.toml index 7447bea..041df89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.26" +version = "0.1.27" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] @@ -8,7 +8,7 @@ authors = [ {name = "Brian Wiborg", email = "me@brianwib.org"} ] readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "typer >=0.19.1,<0.20.0", From 7c75cea413feff6620f77b323850aa158673471b Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 22:26:39 +0200 Subject: [PATCH 087/172] =?UTF-8?q?=F0=9F=8E=A8=20models=20-=20und=20thus?= =?UTF-8?q?=20tables=20-=20have=20no=20underscores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index 341a07f..d68ba38 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -24,7 +24,7 @@ class User(Model): is_admin: bool = field.BooleanField(default=False) is_staff: bool = field.BooleanField(default=False) groups: field.ManyToManyRelation[Group] = field.ManyToManyField( - "ohmyapi_auth.Group", related_name="users", through="user_groups" + "ohmyapi_auth.Group", related_name="users", through="usergroups" ) class Schema: From e43dced1675c770cd0addaee28c74e0ac4b8ff29 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 29 Sep 2025 22:27:13 +0200 Subject: [PATCH 088/172] 0.2.0 - breaks migrations due to proxy-table rename --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 041df89..bd6a977 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.1.27" +version = "0.2.0" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] From 4d8952eff799cc2180eb567d1537bad1db92c333 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Tue, 30 Sep 2025 00:30:55 +0200 Subject: [PATCH 089/172] =?UTF-8?q?=F0=9F=8D=B1=20Add=20package=5Frouters(?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/router.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/ohmyapi/router.py b/src/ohmyapi/router.py index ed96e12..52116e1 100644 --- a/src/ohmyapi/router.py +++ b/src/ohmyapi/router.py @@ -1,2 +1,29 @@ +import importlib +import inspect +import pkgutil +import pathlib + from fastapi import APIRouter, Depends, HTTPException from http import HTTPStatus +from typing import Generator + + +def package_routers( + package_name: str, + package_path: str | pathlib.Path) -> Generator[APIRouter, None, None]: + """ + Discover all APIRouter instances in submodules of the given package. + """ + if isinstance(package_path, str): + package_path = pathlib.Path(package_path).parent + + for module_info in pkgutil.iter_modules([str(package_path)]): + if module_info.name.startswith("_"): + continue # skip private modules like __init__.py + + module_fqname = f"{package_name}.{module_info.name}" + module = importlib.import_module(module_fqname) + + for _, obj in inspect.getmembers(module): + if isinstance(obj, APIRouter): + yield obj From 642359bdebb69efe70a80cd0bdba0f88463c88ef Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Tue, 30 Sep 2025 15:39:11 +0200 Subject: [PATCH 090/172] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Capitalize=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 27b8193..8a81389 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -11,7 +11,7 @@ from pydantic import BaseModel from ohmyapi.builtin.auth.models import Group, User # Router -router = APIRouter(prefix="/auth", tags=["auth"]) +router = APIRouter(prefix="/auth", tags=["Auth"]) # Secrets & config (should come from settings/env in real projects) JWT_SECRET = getattr(settings, "JWT_SECRET", "changeme") From 2232726e7cf27ef482dc88528a400dc8d65917e0 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 1 Oct 2025 20:43:56 +0200 Subject: [PATCH 091/172] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20core.ru?= =?UTF-8?q?ntime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rewrite how apps are loaded into scope - rewrite how apps are collected for Tortoise and Aerich - rewrite how routes are collected for FastAPI - support packages for models and routes with arbitrary nesting - no need to expose models and routes in __init__.py - OhMyAPI will recursively iterate through all submodules --- src/ohmyapi/builtin/demo/routes.py | 8 +- src/ohmyapi/core/runtime.py | 146 ++++++++++++++++++++--------- src/ohmyapi/db/model/model.py | 8 +- src/ohmyapi/router.py | 27 ------ 4 files changed, 107 insertions(+), 82 deletions(-) diff --git a/src/ohmyapi/builtin/demo/routes.py b/src/ohmyapi/builtin/demo/routes.py index 0022039..24bdc83 100644 --- a/src/ohmyapi/builtin/demo/routes.py +++ b/src/ohmyapi/builtin/demo/routes.py @@ -11,9 +11,7 @@ from typing import List router = APIRouter(prefix="/tournament") -@router.get( - "/", tags=["tournament"], response_model=List[models.Tournament.Schema()] -) +@router.get("/", tags=["tournament"], response_model=List[models.Tournament.Schema()]) async def list(): """List all tournaments.""" return await models.Tournament.Schema().from_queryset(models.Tournament.all()) @@ -30,9 +28,7 @@ async def post(tournament: models.Tournament.Schema(readonly=True)): @router.get("/{id}", tags=["tournament"], response_model=models.Tournament.Schema()) async def get(id: str): """Get tournament by id.""" - return await models.Tournament.Schema().from_queryset( - models.Tournament.get(id=id) - ) + return await models.Tournament.Schema().from_queryset(models.Tournament.get(id=id)) @router.put( diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 7b0c340..103853c 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -1,12 +1,12 @@ # ohmyapi/core/runtime.py -import copy import importlib import importlib.util import json import pkgutil import sys from pathlib import Path -from typing import Any, Dict, Generator, List, Optional +from types import ModuleType +from typing import Any, Dict, Generator, List, Optional, Type import click from aerich import Command as AerichCommand @@ -107,7 +107,7 @@ class Project: } for app_name, app in self._apps.items(): - modules = list(dict.fromkeys(app.model_modules)) + modules = list(app.models.keys()) if modules: config["apps"][app_name] = { "models": modules, @@ -123,8 +123,9 @@ class Project: 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)) + tortoise_cfg = self.build_tortoise_config(db_url=db_url) + tortoise_cfg["apps"] = {app_label: tortoise_cfg["apps"][app_label]} # Append aerich.models to the models list of the target app only tortoise_cfg["apps"][app_label]["models"].append("aerich.models") @@ -199,33 +200,17 @@ class App: 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] = [] + # Reference to this app's models modules. + self._models: Dict[str, ModuleType] = {} - # The APIRouter - self.router: APIRouter = APIRouter() + # Reference to this app's routes modules. + self._routers: Dict[str, ModuleType] = {} # Import the app, so its __init__.py runs. - importlib.import_module(self.name) + mod: ModuleType = importlib.import_module(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") - for attr_name in dir(routes_mod): - if attr_name.startswith("__"): - continue - attr = getattr(routes_mod, attr_name) - if isinstance(attr, APIRouter): - self.router.include_router(attr) - except ModuleNotFoundError: - pass + self.__load_models(f"{self.name}.models") + self.__load_routes(f"{self.name}.routes") def __repr__(self): return json.dumps(self.dict(), indent=2) @@ -233,7 +218,72 @@ class App: def __str__(self): return self.__repr__() - def _serialize_route(self, route): + def __load_models(self, mod_name: str): + try: + importlib.import_module(mod_name) + except ModuleNotFoundError: + print(f"no models detected: {mod_name}") + return + + visited: set[str] = set() + out: Dict[str, ModuleType] = {} + + def walk(mod_name: str): + mod = importlib.import_module(mod_name) + if mod_name in visited: + return + visited.add(mod_name) + + for name, value in vars(mod).copy().items(): + if ( + isinstance(value, type) + and issubclass(value, Model) + and not name == Model.__name__ + ): + out[mod_name] = out.get(mod_name, []) + [value] + + # if it's a package, recurse into submodules + if hasattr(mod, "__path__"): + for _, subname, _ in pkgutil.iter_modules( + mod.__path__, mod.__name__ + "." + ): + walk(subname) + + walk(mod_name) + self._models = out + + def __load_routes(self, mod_name: str): + try: + importlib.import_module(mod_name) + except ModuleNotFound: + print(f"no routes detected: {mod_name}") + return + + visited: set[str] = set() + out: Dict[str, ModuleType] = {} + + def walk(mod_name: str): + mod = importlib.import_module(mod_name) + if mod.__name__ in visited: + return + visited.add(mod.__name__) + + for name, value in vars(mod).copy().items(): + if isinstance(value, APIRouter) and not name == APIRouter.__name__: + out[mod_name] = out.get(mod_name, []) + [value] + + # if it's a package, recurse into submodules + if hasattr(mod, "__path__"): + for _, subname, _ in pkgutil.iter_modules( + mod.__path__, mod.__name__ + "." + ): + submod = importlib.import_module(subname) + walk(submod) + + walk(mod_name) + self._routers = out + + def __serialize_route(self, route): """Convert APIRoute to JSON-serializable dict.""" return { "path": route.path, @@ -248,27 +298,31 @@ class App: "tags": getattr(route, "tags", None), } - def _serialize_router(self): - return [self._serialize_route(route) for route in self.routes] + def __serialize_router(self): + return [self.__serialize_route(route) for route in self.routes] - def dict(self) -> Dict[str, Any]: + @property + def models(self) -> List[ModuleType]: + out = [] + for module in self._models: + for model in self._models[module]: + out.append(model) return { - "models": [m.__name__ for m in self.models], - "routes": self._serialize_router(), + module: out, } - @property - def models(self) -> Generator[Model, None, None]: - 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" - ): - yield obj - @property def routes(self): - return self.router.routes + router = APIRouter() + for routes_mod in self._routers: + for r in self._routers[routes_mod]: + router.include_router(r) + return router.routes + + def dict(self) -> Dict[str, Any]: + return { + "models": [ + f"{self.name}.{m.__name__}" for m in self.models[f"{self.name}.models"] + ], + "routes": self.__serialize_router(), + } diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index 04e5d9c..20359d6 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -30,10 +30,12 @@ UUID.__get_pydantic_core_schema__ = classmethod(__uuid_schema_monkey_patch) class ModelMeta(type(TortoiseModel)): - def __new__(cls, name, bases, attrs): - new_cls = super().__new__(cls, name, bases, attrs) + def __new__(mcls, name, bases, attrs): + # Grab the Schema class for further processing. + schema_opts = attrs.get("Schema", None) - schema_opts = getattr(new_cls, "Schema", None) + # Let Tortoise's Metaclass do it's thing. + new_cls = super().__new__(mcls, name, bases, attrs) class BoundSchema: def __call__(self, readonly: bool = False): diff --git a/src/ohmyapi/router.py b/src/ohmyapi/router.py index 52116e1..ed96e12 100644 --- a/src/ohmyapi/router.py +++ b/src/ohmyapi/router.py @@ -1,29 +1,2 @@ -import importlib -import inspect -import pkgutil -import pathlib - from fastapi import APIRouter, Depends, HTTPException from http import HTTPStatus -from typing import Generator - - -def package_routers( - package_name: str, - package_path: str | pathlib.Path) -> Generator[APIRouter, None, None]: - """ - Discover all APIRouter instances in submodules of the given package. - """ - if isinstance(package_path, str): - package_path = pathlib.Path(package_path).parent - - for module_info in pkgutil.iter_modules([str(package_path)]): - if module_info.name.startswith("_"): - continue # skip private modules like __init__.py - - module_fqname = f"{package_name}.{module_info.name}" - module = importlib.import_module(module_fqname) - - for _, obj in inspect.getmembers(module): - if isinstance(obj, APIRouter): - yield obj From 16f15a3d650233cda67623fb328e2964d4d1ace1 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 1 Oct 2025 20:49:44 +0200 Subject: [PATCH 092/172] =?UTF-8?q?=F0=9F=9A=A8=20Cleanup=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/demo/__init__.py | 3 +-- src/ohmyapi/builtin/demo/models.py | 7 ++++--- src/ohmyapi/builtin/demo/routes.py | 6 +++--- src/ohmyapi/router.py | 3 ++- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/ohmyapi/builtin/demo/__init__.py b/src/ohmyapi/builtin/demo/__init__.py index 322de88..49ca6db 100644 --- a/src/ohmyapi/builtin/demo/__init__.py +++ b/src/ohmyapi/builtin/demo/__init__.py @@ -1,2 +1 @@ -from . import models -from . import routes +from . import models, routes diff --git a/src/ohmyapi/builtin/demo/models.py b/src/ohmyapi/builtin/demo/models.py index f0f041f..8f940c1 100644 --- a/src/ohmyapi/builtin/demo/models.py +++ b/src/ohmyapi/builtin/demo/models.py @@ -1,10 +1,11 @@ -from ohmyapi.db import Model, field -from ohmyapi_auth.models import User - from datetime import datetime from decimal import Decimal from uuid import UUID +from ohmyapi_auth.models import User + +from ohmyapi.db import Model, field + class Team(Model): id: UUID = field.data.UUIDField(primary_key=True) diff --git a/src/ohmyapi/builtin/demo/routes.py b/src/ohmyapi/builtin/demo/routes.py index 24bdc83..f79bfed 100644 --- a/src/ohmyapi/builtin/demo/routes.py +++ b/src/ohmyapi/builtin/demo/routes.py @@ -1,10 +1,10 @@ -from ohmyapi.router import APIRouter, HTTPException, HTTPStatus +from typing import List + from ohmyapi.db.exceptions import DoesNotExist +from ohmyapi.router import APIRouter, HTTPException, HTTPStatus from . import models -from typing import List - # Expose your app's routes via `router = fastapi.APIRouter`. # Use prefixes wisely to avoid cross-app namespace-collisions. # Tags improve the UX of the OpenAPI docs at /docs. diff --git a/src/ohmyapi/router.py b/src/ohmyapi/router.py index ed96e12..b903e81 100644 --- a/src/ohmyapi/router.py +++ b/src/ohmyapi/router.py @@ -1,2 +1,3 @@ -from fastapi import APIRouter, Depends, HTTPException from http import HTTPStatus + +from fastapi import APIRouter, Depends, HTTPException From 00e18af8fd6dd13d8c7d79875445d185b2cc9bfe Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 1 Oct 2025 21:55:01 +0200 Subject: [PATCH 093/172] =?UTF-8?q?=F0=9F=93=9D=F0=9F=8E=A8=20More=20inlin?= =?UTF-8?q?e=20docs;=20small=20adjustments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 61 ++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 103853c..1cc1773 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -4,6 +4,7 @@ import importlib.util import json import pkgutil import sys +from http import HTTPStatus from pathlib import Path from types import ModuleType from typing import Any, Dict, Generator, List, Optional, Type @@ -21,8 +22,9 @@ class Project: """ Project runtime loader + Tortoise/Aerich integration. - - injects builtin apps as ohmyapi_ - - builds unified tortoise config for runtime + - aliases builtin apps as ohmyapi_ + - loads all INSTALLED_APPS into scope + - builds unified tortoise config for ORM runtime - provides makemigrations/migrate methods using Aerich Command API """ @@ -34,8 +36,8 @@ class Project: if str(self.project_path) not in sys.path: sys.path.insert(0, str(self.project_path)) - # Pre-register builtin apps as ohmyapi_. - # This makes all builtin apps easily loadable via f"ohmyapi_{app_name}". + # Alias builtin apps as ohmyapi_. + # We need this, because Tortoise app-names may not include dots `.`. spec = importlib.util.find_spec("ohmyapi.builtin") if spec and spec.submodule_search_locations: for _, modname, _ in pkgutil.iter_modules(spec.submodule_search_locations): @@ -200,7 +202,8 @@ class App: self.project = project self.name = name - # Reference to this app's models modules. + # Reference to this app's models modules. Tortoise needs to know the + # modules where to lookup models for this app. self._models: Dict[str, ModuleType] = {} # Reference to this app's routes modules. @@ -219,14 +222,20 @@ class App: return self.__repr__() def __load_models(self, mod_name: str): + """ + Recursively scan through a module and collect all models. + If the module is a package, iterate through its submodules. + """ + + # An app may come without any models. try: importlib.import_module(mod_name) except ModuleNotFoundError: print(f"no models detected: {mod_name}") return + # Acoid duplicates. visited: set[str] = set() - out: Dict[str, ModuleType] = {} def walk(mod_name: str): mod = importlib.import_module(mod_name) @@ -240,7 +249,7 @@ class App: and issubclass(value, Model) and not name == Model.__name__ ): - out[mod_name] = out.get(mod_name, []) + [value] + self._models[mod_name] = self._models.get(mod_name, []) + [value] # if it's a package, recurse into submodules if hasattr(mod, "__path__"): @@ -249,18 +258,24 @@ class App: ): walk(subname) + # Walk the walk. walk(mod_name) - self._models = out def __load_routes(self, mod_name: str): + """ + Recursively scan through a module and collect all APIRouters. + If the module is a package, iterate through all its submodules. + """ + + # An app may come without any routes. try: importlib.import_module(mod_name) except ModuleNotFound: print(f"no routes detected: {mod_name}") return + # Avoid duplicates. visited: set[str] = set() - out: Dict[str, ModuleType] = {} def walk(mod_name: str): mod = importlib.import_module(mod_name) @@ -270,7 +285,7 @@ class App: for name, value in vars(mod).copy().items(): if isinstance(value, APIRouter) and not name == APIRouter.__name__: - out[mod_name] = out.get(mod_name, []) + [value] + self._routers[mod_name] = self._routers.get(mod_name, []) + [value] # if it's a package, recurse into submodules if hasattr(mod, "__path__"): @@ -280,22 +295,17 @@ class App: submod = importlib.import_module(subname) walk(submod) + # Walk the walk. walk(mod_name) - self._routers = out def __serialize_route(self, route): - """Convert APIRoute to JSON-serializable dict.""" + """ + Convert APIRoute to JSON-serializable dict. + """ return { "path": route.path, - "name": route.name, - "methods": list(route.methods), - "endpoint": route.endpoint.__name__, # just the function name - "response_model": ( - getattr(route, "response_model", None).__name__ - if getattr(route, "response_model", None) - else None - ), - "tags": getattr(route, "tags", None), + "method": list(route.methods)[0], + "endpoint": f"{route.endpoint.__module__}.{route.endpoint.__name__}", } def __serialize_router(self): @@ -303,6 +313,9 @@ class App: @property def models(self) -> List[ModuleType]: + """ + Return a list of all loaded models. + """ out = [] for module in self._models: for model in self._models[module]: @@ -313,6 +326,9 @@ class App: @property def routes(self): + """ + Return an APIRouter with all loaded routes. + """ router = APIRouter() for routes_mod in self._routers: for r in self._routers[routes_mod]: @@ -320,6 +336,9 @@ class App: return router.routes def dict(self) -> Dict[str, Any]: + """ + Convenience method for serializing the runtime data. + """ return { "models": [ f"{self.name}.{m.__name__}" for m in self.models[f"{self.name}.models"] From 3de9352227008595b3e5e98cc25375371fbe60db Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 1 Oct 2025 22:00:27 +0200 Subject: [PATCH 094/172] =?UTF-8?q?=F0=9F=93=9D=F0=9F=A9=B9=20More=20inlin?= =?UTF-8?q?e=20docs;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 1cc1773..fdb811a 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -127,7 +127,9 @@ class Project: # Get a fresh copy of the config (without aerich.models anywhere) tortoise_cfg = self.build_tortoise_config(db_url=db_url) + # Prevent leaking other app's models to Aerich. tortoise_cfg["apps"] = {app_label: tortoise_cfg["apps"][app_label]} + # Append aerich.models to the models list of the target app only tortoise_cfg["apps"][app_label]["models"].append("aerich.models") From ed3a776bdead8b3482ccd402e4104d06516d761d Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 1 Oct 2025 22:04:30 +0200 Subject: [PATCH 095/172] =?UTF-8?q?=F0=9F=93=9D=20More=20inline=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index fdb811a..6778024 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -121,6 +121,12 @@ class Project: def build_aerich_command( self, app_label: str, db_url: Optional[str] = None ) -> AerichCommand: + """ + Build Aerich command for app with given app_label. + + Aerich needs to see only the app of interest, but with the extra model + "aerich.models". + """ if app_label not in self._apps: raise RuntimeError(f"App '{app_label}' is not registered") From ed55c3708f2833b7b6db46fb667bb642c5157fbf Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 1 Oct 2025 22:05:23 +0200 Subject: [PATCH 096/172] =?UTF-8?q?=F0=9F=94=96=200.2.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bd6a977..3574814 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.2.0" +version = "0.2.1" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] From a3d9862c4e063b8e88879b10f83e17782b3a9de4 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 00:49:12 +0200 Subject: [PATCH 097/172] =?UTF-8?q?=F0=9F=8E=A8=20Add=20Schema.get=20metho?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/model/model.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index 20359d6..929ff60 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -39,9 +39,7 @@ class ModelMeta(type(TortoiseModel)): class BoundSchema: def __call__(self, readonly: bool = False): - if readonly: - return self.readonly - return self.model + return self.get(readonly) @property def model(self): @@ -68,6 +66,11 @@ class ModelMeta(type(TortoiseModel)): exclude_readonly=True, ) + def get(self, readonly: bool = False): + if readonly: + return self.readonly + return self.model + new_cls.Schema = BoundSchema() return new_cls From 91baf968d71f779ce515b52461b55ef5b3b06d14 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 00:49:53 +0200 Subject: [PATCH 098/172] =?UTF-8?q?=F0=9F=93=9D=20Add=20mkdocs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 391 ++------------------------------------------- docs/apps.md | 34 ++++ docs/index.md | 30 ++++ docs/migrations.md | 22 +++ docs/models.md | 102 ++++++++++++ docs/projects.md | 34 ++++ docs/routes.md | 68 ++++++++ mkdocs.yml | 9 ++ poetry.lock | 187 +++++++++++++++++++++- pyproject.toml | 1 + 10 files changed, 498 insertions(+), 380 deletions(-) create mode 100644 docs/apps.md create mode 100644 docs/index.md create mode 100644 docs/migrations.md create mode 100644 docs/models.md create mode 100644 docs/projects.md create mode 100644 docs/routes.md create mode 100644 mkdocs.yml diff --git a/README.md b/README.md index 44ee001..0963c0b 100644 --- a/README.md +++ b/README.md @@ -1,401 +1,40 @@ # OhMyAPI -> Think: Django RestFramework, but less clunky and 100% async. +OhMyAPI is a web-application scaffolding framework and management layer built around `FastAPI`, `TortoiseORM` and `Aerich` migrations. -OhMyAPI is a Django-flavored web-application scaffolding framework and management layer, -built around FastAPI and TortoiseORM and is thus 100% async. +> *Think: Django RestFramework, but less clunky and 100% async.* It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***! **Features** - Django-like project structure and application directories -- Django-like per-app migrations (`makemigrations` & `migrate`) via Aerich -- Django-like CLI tooling (`startproject`, `startapp`, `shell`, `serve`, etc) -- Customizable pydantic model serializer built-in +- Django-like per-app migrations (makemigrations & migrate) via Aerich +- Django-like CLI tooling (startproject, startapp, shell, serve, etc) +- Customizable pydantic model serializer built-in - Various optional built-in apps you can hook into your project - Highly configurable and customizable - 100% async **Goals** -- combine `FastAPI`, `TortoiseORM`, `Aerich` migrations and `Pydantic` into a high-productivity web-application framework +- combine FastAPI, TortoiseORM, Aerich migrations and Pydantic into a high-productivity web-application framework - tie everything neatly together into a concise and straight-forward API -- ***AVOID*** adding any abstractions on top, unless they make things extremely convenient +- AVOID adding any abstractions on top, unless they make things extremely convenient ---- - -## Getting started - -**Creating a Project** +## Installation ``` pipx install ohmyapi -ohmyapi startproject myproject -cd myproject ``` -This will create the following directory structure: -``` -myproject/ - - pyproject.toml - - README.md - - settings.py -``` +## Docs -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 tournament -``` - -This will create the following directory structure: - -``` -myproject/ - - tournament/ - - __init__.py - - models.py - - routes.py - - pyproject.toml - - README.md - - settings.py -``` - -Add 'tournament' to your `INSTALLED_APPS` in `settings.py`. - -### Models - -Write your first model in `turnament/models.py`: - -```python -from ohmyapi.db import Model, field - -from datetime import datetime -from decimal import Decimal -from uuid import UUID - - -class Tournament(Model): - id: UUID = field.data.UUIDField(primary_key=True) - name: str = field.TextField() - created: datetime = field.DatetimeField(auto_now_add=True) - - def __str__(self): - return self.name - - -class Event(Model): - id: UUID = field.data.UUIDField(primary_key=True) - name: str = field.TextField() - tournament: UUID = field.ForeignKeyField('tournament.Tournament', related_name='events') - participants: field.ManyToManyRelation[Team] = field.ManyToManyField('tournament.Team', related_name='events', through='event_team') - modified: datetime = field.DatetimeField(auto_now=True) - prize: Decimal = field.DecimalField(max_digits=10, decimal_places=2, null=True) - - def __str__(self): - return self.name - - -class Team(Model): - id: UUID = field.data.UUIDField(primary_key=True) - name: str = field.TextField() - - def __str__(self): - return self.name -``` - -### API Routes - -Next, create your endpoints in `tournament/routes.py`: - -```python -from ohmyapi.router import APIRouter, HTTPException, HTTPStatus -from ohmyapi.db.exceptions import DoesNotExist - -from typing import List - -from .models import Tournament - -# OhMyAPI will automatically pick up all instances of `fastapi.APIRouter` and -# add their routes to the main project router. -# -# Note: -# Use prefixes wisely to avoid cross-app namespace-collisions! -# Tags improve the UX of the OpenAPI docs at /docs. -# -tournament_router = APIRouter(prefix="/tournament", tags=['Tournament']) - - -@tournament_router.get("/", response_model=List[Tournament.Schema()]) -async def list(): - queryset = Tournament.all() - return await Tournament.Schema.model.from_queryset(queryset) - - -@tournament_router.post("/", status_code=HTTPStatus.CREATED) -async def post(tournament: Tournament.Schema(readonly=True)): - queryset = Tournament.create(**payload.model_dump()) - return await Tournament.Schema().from_queryset(queryset) - - -@tournament_router.get("/:id", response_model=Tournament.Schema()) -async def get(id: str): - try: - queryset = Tournament.get(id=id) - return await Tournament.Schema().from_queryset_single(tournament) - except DoesNotExist: - raise HTTPException(status_code=404, detail="not found") - - -@tournament_router.delete("/:id") -async def delete(id: str): - try: - tournament = await Tournament.get(id=id) - return await Tournament.Schema.model.from_queryset(tournament.delete()) - except DoesNotExist: - raise HTTPException(status_code=404, detail="not found") - - -... -``` - -## Migrations - -Before we can run the app, we need to create and initialize the database. - -Similar to Django, first run: - -``` -ohmyapi makemigrations [ ] # no app means all INSTALLED_APPS -``` - -This will create a `migrations/` folder in you project root. - -``` -myproject/ - - tournament/ - - __init__.py - - models.py - - routes.py - - migrations/ - - tournament/ - - pyproject.toml - - README.md - - settings.py -``` - -Apply your migrations via: - -``` -ohmyapi migrate [ ] # no app means all INSTALLED_APPS -``` - -Run your project: - -``` -ohmyapi serve -``` - -## 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 necessary tables to be created in the database. - -`settings.py`: - -``` -INSTALLED_APPS = [ - 'ohmyapi_auth', - ... -] - -JWT_SECRET = "t0ps3cr3t" -``` - -After restarting your project you will have access to the `ohmyapi_auth` app. -It comes with a `User` and `Group` model, as well as endpoints for JWT auth. - -You can use the models as `ForeignKeyField` in your application models: - -```python -from ohmyapi.db import Model, field -from ohmyapi_auth.models import User - - -class Team(Model): - [...] - members: field.ManyToManyRelation[User] = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams') - [...] -``` - -Remember to run `makemigrations` and `migrate` in order for your model changes to take effect in the database. - -Create a super-user: - -``` -ohmyapi createsuperuser -``` - -## Permissions - -### API-Level Permissions - -Use FastAPI's `Depends` pattern to implement API-level access-control. - - -In your `routes.py`: - -```python -from ohmyapi.router import APIRouter, Depends -from ohmyapi_auth import ( - models as auth, - permissions, -) - -from .models import Tournament - -router = APIRouter(prefix="/tournament", tags=["Tournament"]) - - -@router.get("/") -async def list(user: auth.User = Depends(permissions.require_authenticated)): - queryset = Tournament.all() - return await Tournament.Schema().from_queryset(queryset) - - -... -``` - -### Model-Level Permissions - -Use Tortoise's `Manager` to implement model-level permissions. - -```python -from ohmyapi.db import Manager -from ohmyapi_auth.models import User - - -class TeamManager(Manager): - async def for_user(self, user: User): - return await self.filter(members=user).all() - - -class Team(Model): - [...] - - class Meta: - manager = TeamManager() -``` - -Use the custom manager in your FastAPI route handler: - -```python -from ohmyapi.router import APIRouter -from ohmyapi_auth import ( - models as auth, - permissions, -) - -router = APIRouter(prefix="/tournament", tags=["Tournament"]) - - -@router.get("/teams") -async def teams(user: auth.User = Depends(permissions.require_authenticated)): - queryset = Team.for_user(user) - return await Tournament.Schema().from_queryset(queryset) -``` - -## Shell - -Similar to Django, you can attach to an interactive shell with your project already loaded inside. - -``` -ohmyapi shell - -Python 3.13.7 (main, Aug 15 2025, 12:34:02) [GCC 15.2.1 20250813] -Type 'copyright', 'credits' or 'license' for more information -IPython 9.5.0 -- An enhanced Interactive Python. Type '?' for help. - -OhMyAPI Shell | Project: {{ project_name }} [{{ project_path }}] -Find your loaded project singleton via identifier: `p` -``` - -```python -In [1]: p -Out[1]: - -In [2]: p.apps -Out[2]: -{'ohmyapi_auth': { - "models": [ - "Group", - "User" - ], - "routes": [ - { - "path": "/auth/login", - "name": "login", - "methods": [ - "POST" - ], - "endpoint": "login", - "response_model": null, - "tags": [ - "auth" - ] - }, - { - "path": "/auth/refresh", - "name": "refresh_token", - "methods": [ - "POST" - ], - "endpoint": "refresh_token", - "response_model": null, - "tags": [ - "auth" - ] - }, - { - "path": "/auth/introspect", - "name": "introspect", - "methods": [ - "GET" - ], - "endpoint": "introspect", - "response_model": null, - "tags": [ - "auth" - ] - }, - { - "path": "/auth/me", - "name": "me", - "methods": [ - "GET" - ], - "endpoint": "me", - "response_model": null, - "tags": [ - "auth" - ] - } - ] - }} -``` +See: `docs/` +- [Projects](docs/projects.md) +- [Apps](docs/apps.md) +- [Models](docs/models.md) +- [Migrations](docs/migrations.md) +- [Routes](docs/routes.md) diff --git a/docs/apps.md b/docs/apps.md new file mode 100644 index 0000000..fdcf5c4 --- /dev/null +++ b/docs/apps.md @@ -0,0 +1,34 @@ +# Apps + +Apps are a way to group database models and API routes that contextually belong together. +For example, OhMyAPI comes bundled with an `auth` app that carries a `User` and `Group` model and provides API endpoints for JWT authentication. + +Apps help organizing projects by isolating individual components (or "features") from one another. + +## Create an App + +Create a new app by: `ohmyapi startapp `, i.e.: + +``` +ohmyapi startapp restaurant +``` + +This will create the following directory structure: + +``` +myproject/ + - restaurant/ + - __init__.py + - models.py + - routes.py + - pyproject.toml + - README.md + - settings.py +``` + +Add `restaurant` to your `INSTALLED_APPS` in `settings.py` and restart you project runtime: + +``` +ohmyapi serve +``` + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..453e6d7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,30 @@ +# Welcome to OhMyAPI + +OhMyAPI is a web-application scaffolding framework and management layer built around `FastAPI`, `TortoiseORM` and `Aerich` migrations. + +> *Think: Django RestFramework, but less clunky and 100% async.* + +It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***! + +**Features** + +- Django-like project structure and application directories +- Django-like per-app migrations (makemigrations & migrate) via Aerich +- Django-like CLI tooling (startproject, startapp, shell, serve, etc) +- Customizable pydantic model serializer built-in +- Various optional built-in apps you can hook into your project +- Highly configurable and customizable +- 100% async + +**Goals** + +- combine FastAPI, TortoiseORM, Aerich migrations and Pydantic into a high-productivity web-application framework +- tie everything neatly together into a concise and straight-forward API +- AVOID adding any abstractions on top, unless they make things extremely convenient + +## Installation + +``` +pipx install ohmyapi +``` + diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..e8052e6 --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,22 @@ +# Migrations + +OhMyAPI uses [Aerich](https://github.com/tortoise/aerich) - a database migrations tool for TortoiseORM. + +## Making migrations + +Whenever you add, remove or change fields of a database model, you need to create a migration for the change. + +``` +ohmyapi makemigrations [ ] # no app indicates all INSTALLED_APPS +``` + +This will create a `migrations/` directory with subdirectories for each of your apps. + +## Migrating + +When the migrations are create, they need to be applied: + +``` +ohmyapi migrate [ ] # no app indicates all INSTALLED_APPS +``` + diff --git a/docs/models.md b/docs/models.md new file mode 100644 index 0000000..47b5708 --- /dev/null +++ b/docs/models.md @@ -0,0 +1,102 @@ +# Models + +OhMyAPI uses [Tortoise](https://tortoise.github.io/) - an easy-to-use asyncio ORM (Object Relational Mapper) inspired by Django. + +Models are exposed via a Python module named `models` in the app's directory. +OhMyAPI auto-detects all models exposed this way. +If the `models` module is a package, OhMyAPI will search through its submodules recursively. + +## Writing models + +### Your first simple model + +```python +from ohmyapi.db import Model, field + + +class Restaurant(Model): + id: int = field.IntField(pk=True) + name: str = field.CharField(max_length=255) + description: str = field.TextField() + location: str = field.CharField(max_length=255) +``` + +### ForeignKeyRelations + +You can define relationships between models. + +```python +from ohmyapi.db import Model, field + +from decimal import Decimal + + +class Restaurant(Model): + id: int = field.IntField(pk=True) + name: str = field.CharField(max_length=255) + description: str = field.TextField() + location: str = field.CharField(max_length=255) + + +class Dish(Model): + id: int = field.IntField(pk=True) + restaurant: field.ForeignKeyRelation[Restaurant] = field.ForeignKeyField( + "restaurant.Restaurant", + related_name="dishes", + ) + name: str = field.CharField(max_length=255) + price: Decimal = field.DecimalField(max_digits=10, decimal_places=2) + + +class Order(Model): + id: int = field.IntField(pk=True) + restaurant: field.ForeignKeyRelation[Restaurant] = field.ForeignKeyField( + 'restaurant.Restaurant', + related_name="dishes", + ) + dishes: field.ManyToManyRelation[Dish] = field.ManyToManyField( + "restaurant.Dish", + relatated_name="orders", + through="dishesordered", + ) +``` + +## Pydantic Schema Validation + +Each model has a builtin `Schema` class that provides easy access to Pydantic models for schema validation. +Each model provides a default schema and a readonly schema, which you can obtain via the `get` method or by calling Schema() directly. +The default schema contains the full collection of fields of the Tortoise model. +The readonly schema excludes the primary-key field, as well as all readonly fields. + +```python +In [1]: from restaurant.models import Restaurant + +In [2]: Restaurant.Schema.get() +Out[2]: tortoise.contrib.pydantic.creator.RestaurantSchema + +In [3]: Restaurant.Schema.get(readonly=True) +Out[3]: tortoise.contrib.pydantic.creator.RestaurantSchemaReadonly + +In [4]: data = { + ...: "name": "My Pizzeria", + ...: "description": "Awesome Pizza!", + ...: "location": "Berlin", + ...: } + +In [5]: Restaurant.Schema.get(readonly=True)(**data) +Out[5]: RestaurantSchemaReadonly(name='My Pizzeria', description='Awesome Pizza!', location='Berlin') + +In [6]: Restaurant(**_.model_dump()) +Out[6]: +``` + +You can customize the fields to be include in the Pydantic schema: + +```python +class MyModel(Model): + [...] + + class Schema: + include: List[str] = [] # list of fields to include + exclude: List[str] = [] # list of fields to exclude +``` diff --git a/docs/projects.md b/docs/projects.md new file mode 100644 index 0000000..7fbcdf1 --- /dev/null +++ b/docs/projects.md @@ -0,0 +1,34 @@ +# Projects + +OhMyAPI organizes projects in a diretory tree. +The root directory contains the `settings.py`, which carries global configuration for your project, such as your `DATABASE_URL` and `INSTALLED_APPS`. +Each project is organized into individual apps, which in turn may provide some database models and API handlers. +Each app is isolated in its own subdirectory within your project. +You can control which apps to install and load via `INSTALLED_APPS` in your `settings.py`. + +## Create a Project + +To create a projects, simply run: + +``` +ohmyapi startproject myproject +cd myproject +``` + +This will create the following directory structure: + +``` +myproject/ + - pyproject.toml + - README.md + - settings.py +``` + +Run your project with: + +``` +ohmyapi serve +``` + +In your browser go to: [http://localhost:8000/docs](http://localhost:8000/docs) + diff --git a/docs/routes.md b/docs/routes.md new file mode 100644 index 0000000..185f219 --- /dev/null +++ b/docs/routes.md @@ -0,0 +1,68 @@ +# Routes + +OhMyAPI uses [FastAPI](https://fastapi.tiangolo.com/) - a modern, fast (high-performance), web framework for building APIs with Python based on standard Python type hints. + +Routes are exposed via a module named `routes` in the app's directory. +OhMyAPI auto-detects all `fastapi.APIRouter` instances exposed this way. +If the `routes` module is a package, OhMyAPI will search through its submodules recursively. + +When creating an app via `startapp`, OhMyAPI will provide CRUD boilerplate to help your get started. + +## Example CRUD API endpoints + +```python +from ohmyapi.db.exceptions import DoesNotExist + +from ohmyapi.router import APIRouter, HTTPException, HTTPStatus + +from .models import Restaurant + +from typing import List + +router = APIRouter(prefix="/restaurant", tags=['restaurant']) + + +@router.get("/", response_model=List[Restaurant]) +async def list(): + """List all restaurants.""" + queryset = Restaurant.all() + schema = Restaurant.Schema() + return await schema.from_queryset(queryset) + # or in one line: + # return await Restaurant.Schema().from_queryset(Restaurant.all()) + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post(restaurant: Restaurant.Schema(readonly=True)): + """Create a new restaurant.""" + return await Restaurant(**restaurant.model_dump()).create() + + +@router.get("/{id}", response_model=Restaurant) +async def get(id: str): + """Get restaurant by ID.""" + return await Restaurant.Schema().from_queryset(Restaurant.get(id=id)) + + +@router.put("/{id}", status_code=HTTPStatus.ACCEPTED) +async def put(restaurant: Restaurant): + """Update restaurant.""" + try: + db_restaurant = await Restaurant.get(id=id) + except DoesNotExist: + return HTTPException(status_code=HTTPStatus.NOT_FOUND) + + db_restaurant.update_from_dict(restaurant.model_dump()) + return await db_restaurant.save() + + +@router.delete("/{id}", status_code=HTTPStatus.ACCEPTED) +async def delete(id: str): + try: + db_restaurant = await Restaurant.get(id=id) + except DoesNotExist: + return HTTPException(status_code=HTTPStatus.NOT_FOUND) + + return await db_restaurant.delete() + +``` diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..c634233 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,9 @@ +site_name: OhMyAPI Docs +theme: readthedocs +nav: + - Home: index.md + - Projects: projects.md + - Apps: apps.md + - Models: models.md + - Migrations: migrations.md + - Routes: routes.md diff --git a/poetry.lock b/poetry.lock index 8ccdf2e..a07655e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -519,6 +519,24 @@ all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (> standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + [[package]] name = "h11" version = "0.16.0" @@ -649,7 +667,7 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -661,6 +679,22 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "markdown" +version = "3.9" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280"}, + {file = "markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a"}, +] + +[package.extras] +docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -691,7 +725,7 @@ version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -783,6 +817,66 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +description = "Project documentation with Markdown." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, + {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +jinja2 = ">=2.11.1" +markdown = ">=3.3.6" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +mkdocs-get-deps = ">=0.2.0" +packaging = ">=20.5" +pathspec = ">=0.11.1" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, + {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, +] + +[package.dependencies] +mergedeep = ">=1.3.4" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -1138,6 +1232,21 @@ files = [ {file = "pypika_tortoise-0.6.2.tar.gz", hash = "sha256:f95ab59d9b6454db2e8daa0934728458350a1f3d56e81d9d1debc8eebeff26b3"}, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-multipart" version = "0.0.20" @@ -1183,7 +1292,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, @@ -1253,6 +1362,21 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +description = "A custom YAML tag for referencing environment variables in YAML files." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04"}, + {file = "pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"}, +] + +[package.dependencies] +pyyaml = "*" + [[package]] name = "requests" version = "2.32.5" @@ -1318,6 +1442,18 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1494,6 +1630,49 @@ h11 = ">=0.8" [package.extras] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +[[package]] +name = "watchdog" +version = "6.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, + {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, + {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, + {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, + {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + [[package]] name = "wcwidth" version = "0.2.14" @@ -1512,4 +1691,4 @@ auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"] [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "c765d9f42a4d8bee26474bda7e19f0b6fdd43833688d0db781611090e9ee3b99" +content-hash = "3d301460081dada359d425d69feefc63c1e5135aa64b6f000f554bfc1231febd" diff --git a/pyproject.toml b/pyproject.toml index 3574814..c9a7914 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ ipython = ">=9.5.0,<10.0.0" black = "^25.9.0" isort = "^6.0.1" +mkdocs = "^1.6.1" [project.optional-dependencies] auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"] From e53c206b4e76f29152341b85411f413e76888523 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 01:03:48 +0200 Subject: [PATCH 099/172] =?UTF-8?q?=F0=9F=94=96=200.2.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c9a7914..0f9ee8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,13 @@ [project] name = "ohmyapi" -version = "0.2.1" +version = "0.2.2" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] authors = [ {name = "Brian Wiborg", email = "me@brianwib.org"} ] +repository = "https://code.c-base.org/baccenfutter/ohmyapi" readme = "README.md" requires-python = ">=3.11" From f0e5c8c30ef202dbcb4352b657c60ab6028bdd31 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 02:04:19 +0200 Subject: [PATCH 100/172] =?UTF-8?q?=F0=9F=93=9D=20Add=20builtin=20section;?= =?UTF-8?q?=20add=20ohmyapi=5Fauth=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++++++------ docs/README.md | 8 ++++++++ docs/auth.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 ++ 4 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/auth.md diff --git a/README.md b/README.md index 0963c0b..1db797e 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,10 @@ pipx install ohmyapi ## Docs -See: `docs/` +See `docs/` or: -- [Projects](docs/projects.md) -- [Apps](docs/apps.md) -- [Models](docs/models.md) -- [Migrations](docs/migrations.md) -- [Routes](docs/routes.md) +``` +poetry run mkdocs serve +``` + +Go to: [http://localhost:8000/](http://localhost:8000/) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c553ce9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# OhMyAPI Docs + +- [Projects](projects.md) +- [Apps](apps.md) +- [Models](models.md) +- [Migrations](migrations.md) +- [Routes](routes.md) +- [Auth/Permissions](auth.md) diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..2526966 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,45 @@ +# Authentication + +OhMyAPI comes bundled with a builtin authentication app. +Simply add `ohmyapi_auth` to your `INSTALLED_APPS` and configure a `JWT_SECRET`. + +## Enable Auth App + +`settings.py`: + +``` +INSTALLED_APPS = [ + "ohmyapi_auth", + ... +] + +JWT_SECRET = "t0ps3cr3t" +``` + +Remember to `makemigrations` and `migrate` to create the necessary database tables. + +``` +ohmyapi makemigrations +ohmyapi migrate +``` + +## Permissions + +With the `ohmyapi_auth` app comes everything you need to implement API-level permissions. +Use FastAPI's `Depends` pattern in combination with either the provided or custom permissions. + +```python +from ohmyapi.router import APIRouter, Depends + +from ohmyapi_auth import ( + models as auth, + permissions, +) + +router = APIRouter() + + +@router.get("/") +def get(user: auth.User = Depends(permissions.required_authenticated)): + ... +``` diff --git a/mkdocs.yml b/mkdocs.yml index c634233..20db47a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,3 +7,5 @@ nav: - Models: models.md - Migrations: migrations.md - Routes: routes.md + - Builtin: + - Auth / Permissions: auth.md From 37d807eb659ba8d22488ce0263ed42d85b496fd0 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 02:06:36 +0200 Subject: [PATCH 101/172] =?UTF-8?q?=F0=9F=94=96=200.2.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0f9ee8c..f78e0b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,12 @@ [project] name = "ohmyapi" -version = "0.2.2" +version = "0.2.3" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] authors = [ {name = "Brian Wiborg", email = "me@brianwib.org"} ] -repository = "https://code.c-base.org/baccenfutter/ohmyapi" readme = "README.md" requires-python = ">=3.11" @@ -41,6 +40,9 @@ packages = [ { include = "ohmyapi", from = "src" } ] [project.scripts] ohmyapi = "ohmyapi.cli:app" +[project.urls] +repository = "https://code.c-base.org/baccenfutter/ohmyapi" + [tool.black] line-length = 88 target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] From 63a4f4f948bbe6fa597e03f6640872bf5fccb16b Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 02:45:14 +0200 Subject: [PATCH 102/172] =?UTF-8?q?=F0=9F=A9=B9=20This=20app's=20models=20?= =?UTF-8?q?only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 6778024..1db676f 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -257,7 +257,9 @@ class App: and issubclass(value, Model) and not name == Model.__name__ ): - self._models[mod_name] = self._models.get(mod_name, []) + [value] + value.__module__ = value.__module__.replace("ohmyapi.builtin.", "ohmyapi_") + if value.__module__.startswith(mod_name): + self._models[mod_name] = self._models.get(mod_name, []) + [value] # if it's a package, recurse into submodules if hasattr(mod, "__path__"): From 28f76fc4f444e4077d9f42b70d74f00e24ef57d5 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 02:45:53 +0200 Subject: [PATCH 103/172] =?UTF-8?q?=F0=9F=A9=B9=20Fix=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 1db676f..b743d7f 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -302,8 +302,7 @@ class App: for _, subname, _ in pkgutil.iter_modules( mod.__path__, mod.__name__ + "." ): - submod = importlib.import_module(subname) - walk(submod) + walk(subname) # Walk the walk. walk(mod_name) From ee4bd2760ca26f4518f2caf7e09f9bb4b2d45081 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 02:46:23 +0200 Subject: [PATCH 104/172] =?UTF-8?q?=F0=9F=94=96=200.2.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f78e0b9..6552947 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.2.3" +version = "0.2.4" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] From af110cec9d46d6e2664032850fc0dfde0f34dfe8 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 03:02:00 +0200 Subject: [PATCH 105/172] =?UTF-8?q?=F0=9F=A9=B9=20Fix=20FastAPI=20app=20in?= =?UTF-8?q?itialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index b743d7f..ccf52af 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -81,8 +81,8 @@ class Project: # Attach routers from apps for app_name, app_def in self._apps.items(): - if app_def.router: - app.include_router(app_def.router) + for router in app_def.routers: + app.include_router(router) # Startup / shutdown events @app.on_event("startup") @@ -333,15 +333,22 @@ class App: module: out, } + @property + def routers(self): + out = [] + for routes_mod in self._routers: + for r in self._routers[routes_mod]: + out.append(r) + return out + @property def routes(self): """ Return an APIRouter with all loaded routes. """ router = APIRouter() - for routes_mod in self._routers: - for r in self._routers[routes_mod]: - router.include_router(r) + for r in self.routers: + router.include_router(r) return router.routes def dict(self) -> Dict[str, Any]: From 795dab71f011721503b7b63702c08c7c09bd67ad Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 03:02:44 +0200 Subject: [PATCH 106/172] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Upgrade=20all=20de?= =?UTF-8?q?ps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poetry.lock | 176 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 102 insertions(+), 74 deletions(-) diff --git a/poetry.lock b/poetry.lock index a07655e..c30b765 100644 --- a/poetry.lock +++ b/poetry.lock @@ -566,14 +566,14 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "ipython" -version = "9.5.0" +version = "9.6.0" description = "IPython: Productive Interactive Computing" optional = false python-versions = ">=3.11" groups = ["main", "dev"] files = [ - {file = "ipython-9.5.0-py3-none-any.whl", hash = "sha256:88369ffa1d5817d609120daa523a6da06d02518e582347c29f8451732a9c5e72"}, - {file = "ipython-9.5.0.tar.gz", hash = "sha256:129c44b941fe6d9b82d36fc7a7c18127ddb1d6f02f78f867f402e2e3adde3113"}, + {file = "ipython-9.6.0-py3-none-any.whl", hash = "sha256:5f77efafc886d2f023442479b8149e7d86547ad0a979e9da9f045d252f648196"}, + {file = "ipython-9.6.0.tar.gz", hash = "sha256:5603d6d5d356378be5043e69441a072b50a5b33b4503428c77b04cb8ce7bc731"}, ] [package.dependencies] @@ -592,10 +592,10 @@ typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} [package.extras] all = ["ipython[doc,matplotlib,test,test-extra]"] black = ["black"] -doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinx_toml (==0.0.4)", "typing_extensions"] -matplotlib = ["matplotlib"] +doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[matplotlib,test]", "setuptools (>=61.2)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinx_toml (==0.0.4)", "typing_extensions"] +matplotlib = ["matplotlib (>3.7)"] test = ["packaging", "pytest", "pytest-asyncio", "testpath"] -test-extra = ["curio", "ipykernel", "ipython[test]", "jupyter_ai", "matplotlib (!=3.2.0)", "nbclient", "nbformat", "numpy (>=1.23)", "pandas", "trio"] +test-extra = ["curio", "ipykernel", "ipython[matplotlib]", "ipython[test]", "jupyter_ai", "nbclient", "nbformat", "numpy (>=1.25)", "pandas (>2.0)", "trio"] [[package]] name = "ipython-pygments-lexers" @@ -627,14 +627,14 @@ files = [ [[package]] name = "isort" -version = "6.0.1" +version = "6.1.0" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.9.0" groups = ["dev"] files = [ - {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, - {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, + {file = "isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784"}, + {file = "isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481"}, ] [package.extras] @@ -721,73 +721,101 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] [[package]] name = "markupsafe" -version = "3.0.2" +version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, - {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] [[package]] @@ -1580,14 +1608,14 @@ markers = {dev = "python_version == \"3.11\""} [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] [package.dependencies] From 26a072714d4cc5d8332189e22e09baa44fd1e729 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 03:08:24 +0200 Subject: [PATCH 107/172] =?UTF-8?q?=F0=9F=A9=B9=20Pass=20APIRouters=20dire?= =?UTF-8?q?ctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index ccf52af..6559dbe 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -346,10 +346,10 @@ class App: """ Return an APIRouter with all loaded routes. """ - router = APIRouter() + out = [] for r in self.routers: - router.include_router(r) - return router.routes + out.extend(r.routes) + return out def dict(self) -> Dict[str, Any]: """ From e894c8f1008edfbf3204f19eae432c81ac89ef4c Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 03:09:23 +0200 Subject: [PATCH 108/172] =?UTF-8?q?=F0=9F=94=96=200.2.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6552947..6e1b419 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.2.4" +version = "0.2.5" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] From 6089f950b68d1198be28c8a89105b1ca73a2b643 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 04:49:59 +0200 Subject: [PATCH 109/172] =?UTF-8?q?=F0=9F=91=B7=20Dynamic=20versioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 13 +++++++++++++ src/ohmyapi/__init__.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6e1b419..2aa8a19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,19 @@ ohmyapi = "ohmyapi.cli:app" [project.urls] repository = "https://code.c-base.org/baccenfutter/ohmyapi" +[build-system] +requires = [ + "poetry-core>=1.8.0", + "poetry-dynamic-versioning>=1.8.0" +] +build-backend = "poetry_dynamic_versioning.backend" + +[tool.poetry-dynamic-versioning] +enable = true +source = "file" +path = "src/ohmyapi/__init__.py" +pattern = "__VERSION__\\s*=\\s*['\"](?P[^'\"]+)['\"]" + [tool.black] line-length = 88 target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 5dadfec..f67fe52 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -from . import db +__VERSION__ = "0.2.5" From f74b20a19fb82e5011395d278b175908a5fe44b4 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 04:50:55 +0200 Subject: [PATCH 110/172] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Provide=20project.?= =?UTF-8?q?configure=5Fapp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/cli.py | 1 + src/ohmyapi/core/runtime.py | 26 +++++++++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index 9825cf9..8537950 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -35,6 +35,7 @@ def serve(root: str = ".", host="127.0.0.1", port=8000): project_path = Path(root) project = runtime.Project(project_path) app_instance = project.app() + app_instance = project.configure_app(app_instance) uvicorn.run(app_instance, host=host, port=int(port), reload=False) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 6559dbe..423f3c5 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -72,23 +72,35 @@ class Project: def is_app_installed(self, name: str) -> bool: return name in getattr(self.settings, "INSTALLED_APPS", []) - def app(self, generate_schemas: bool = False) -> FastAPI: + def app(self, + docs_url: str = "/docs", + ) -> FastAPI: """ - Create a FastAPI app, attach all APIRouters from registered apps, - and register ORM lifecycle event handlers. + Create and return a FastAPI app. """ - app = FastAPI(title=getattr(self.settings, "PROJECT_NAME", "OhMyAPI Project")) + import ohmyapi + return FastAPI( + title=getattr(self.settings, "PROJECT_NAME", "OhMyAPI Project"), + description=getattr(self.settings, "PROJECT_DESCRIPTION", ""), + docs_url=getattr(self.settings, "DOCS_URL", "/docs"), + version=ohmyapi.__VERSION__, + ) - # Attach routers from apps + def configure_app(self, app: FastAPI) -> FastAPI: + """ + Attach project routes and event handlers to given FastAPI instance. + """ + # Attach project routes. for app_name, app_def in self._apps.items(): for router in app_def.routers: app.include_router(router) - # Startup / shutdown events + # Initialize ORM on startup @app.on_event("startup") async def _startup(): - await self.init_orm(generate_schemas=generate_schemas) + await self.init_orm(generate_schemas=False) + # Close ORM on shutdown @app.on_event("shutdown") async def _shutdown(): await self.close_orm() From e9d0fb5b80cb639785ff3eb575919fbcd115a1e3 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 04:51:41 +0200 Subject: [PATCH 111/172] =?UTF-8?q?=F0=9F=94=96=200.2.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index f67fe52..b1e9019 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.2.5" +__VERSION__ = "0.2.6" From e2f968bac43c47761e268f9c4a1941084dbb1278 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 05:47:48 +0200 Subject: [PATCH 112/172] =?UTF-8?q?=F0=9F=91=B7=20Add=20dockerize=20comman?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/__init__.py | 2 +- src/ohmyapi/cli.py | 7 +++++ src/ohmyapi/core/scaffolding.py | 22 ++++++++++++++ src/ohmyapi/core/templates/docker/Dockerfile | 30 +++++++++++++++++++ .../core/templates/docker/docker-compose.yml | 7 +++++ .../core/templates/project/pyproject.toml.j2 | 2 +- 6 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 src/ohmyapi/core/templates/docker/Dockerfile create mode 100644 src/ohmyapi/core/templates/docker/docker-compose.yml diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index b1e9019..9b9005b 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.2.6" +__VERSION__ = "0.2.7" diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index 8537950..6728753 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -27,6 +27,12 @@ def startapp(app_name: str, root: str = "."): scaffolding.startapp(app_name, root) +@app.command() +def dockerize(root: str = "."): + """Create template Dockerfile and docker-compose.yml.""" + scaffolding.copy_static("docker", root) + + @app.command() def serve(root: str = ".", host="127.0.0.1", port=8000): """ @@ -41,6 +47,7 @@ def serve(root: str = ".", host="127.0.0.1", port=8000): @app.command() def shell(root: str = "."): + """An interactive shell with your loaded project runtime.""" project_path = Path(root).resolve() project = runtime.Project(project_path) diff --git a/src/ohmyapi/core/scaffolding.py b/src/ohmyapi/core/scaffolding.py index 1d3f38f..c637b5a 100644 --- a/src/ohmyapi/core/scaffolding.py +++ b/src/ohmyapi/core/scaffolding.py @@ -2,6 +2,8 @@ from pathlib import Path from jinja2 import Environment, FileSystemLoader +import shutil + # Base templates directory TEMPLATE_DIR = Path(__file__).parent / "templates" env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR))) @@ -53,6 +55,26 @@ def render_template_dir( render_template_file(template_dir / template_rel_path, context, output_path) +def copy_static(dir_name: str, target_dir: Path): + """Statically copy all files from {TEMPLATE_DIR}/{dir_name} to target_dir.""" + template_dir = TEMPLATE_DIR / dir_name + target_dir = Path(target_dir) + if not template_dir.exists(): + print(f"no templates found under: {dir_name}") + return + + for root, _, files in template_dir.walk(): + root_path = Path(root) + for file in files: + src = root_path / file + dst = target_dir / file + if dst.exists(): + print(f"⛔ File exists, skipping: {dst}") + continue + shutil.copy(src, ".") + print(f"✅ Templates created successfully.") + print(f"🔧 Next, run `docker compose up -d --build`") + def startproject(name: str): """Create a new project: flat structure, all project templates go into /""" target_dir = Path(name).resolve() diff --git a/src/ohmyapi/core/templates/docker/Dockerfile b/src/ohmyapi/core/templates/docker/Dockerfile new file mode 100644 index 0000000..28a8f32 --- /dev/null +++ b/src/ohmyapi/core/templates/docker/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.13-alpine + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + POETRY_HOME="/opt/poetry" \ + POETRY_VIRTUALENVS_IN_PROJECT=true \ + POETRY_NO_INTERACTION=1 + +RUN apk add --no-cache \ + build-base \ + curl \ + git \ + bash \ + libffi-dev \ + openssl-dev \ + python3-dev \ + musl-dev + +RUN curl -sSL https://install.python-poetry.org | python3 - +ENV PATH="$POETRY_HOME/bin:$PATH" + +WORKDIR /app +COPY pyproject.toml poetry.lock* /app/ +RUN poetry lock +RUN poetry install +COPY . /app + +EXPOSE 8000 + +CMD ["poetry", "run", "ohmyapi", "serve", "--host", "0.0.0.0"] diff --git a/src/ohmyapi/core/templates/docker/docker-compose.yml b/src/ohmyapi/core/templates/docker/docker-compose.yml new file mode 100644 index 0000000..6691e61 --- /dev/null +++ b/src/ohmyapi/core/templates/docker/docker-compose.yml @@ -0,0 +1,7 @@ +services: + app: + build: + context: . + restart: unless-stopped + ports: + - 8000:8000 diff --git a/src/ohmyapi/core/templates/project/pyproject.toml.j2 b/src/ohmyapi/core/templates/project/pyproject.toml.j2 index 7b2a532..0fe3dcd 100644 --- a/src/ohmyapi/core/templates/project/pyproject.toml.j2 +++ b/src/ohmyapi/core/templates/project/pyproject.toml.j2 @@ -10,7 +10,7 @@ readme = "README.md" license = { text = "MIT" } dependencies = [ - "ohmyapi (>=0.1.0,<0.2.0)" + "ohmyapi" ] [tool.poetry.group.dev.dependencies] From c411a9795cbea6f71ea1e61c78034624030b8821 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 05:48:48 +0200 Subject: [PATCH 113/172] =?UTF-8?q?=F0=9F=94=96=200.2.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2aa8a19..66cf3b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.2.5" +version = "0.2.8" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 9b9005b..6a99498 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.2.7" +__VERSION__ = "0.2.8" From 64e98f9f0a7d60ec07797b2a823a4cdccd64ea92 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 14:30:53 +0200 Subject: [PATCH 114/172] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Automatically?= =?UTF-8?q?=20prefix=20table=5Fnames=20with=20app=5Flabel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/model/model.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index 929ff60..642603d 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -34,6 +34,23 @@ class ModelMeta(type(TortoiseModel)): # Grab the Schema class for further processing. schema_opts = attrs.get("Schema", None) + # Create or get the inner Meta class + meta = attrs.get("Meta", type("Meta", (), {})) + + # Infer app name from module if not explicitly set + if not hasattr(meta, "app"): + module = attrs.get("__module__", "") + module_parts = module.split(".") + if module_parts[-1] == "models": + inferred_app = module_parts[-2] + else: + inferred_app = module_parts[-1] + meta.app = inferred_app + + # Prefix table name if not explicitly set + if not hasattr(meta, "table"): + meta.table = f"{meta.app}_{name.lower()}" + # Let Tortoise's Metaclass do it's thing. new_cls = super().__new__(mcls, name, bases, attrs) From 63d2c3176357176d1665d46388633e7e259bbe43 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 14:32:47 +0200 Subject: [PATCH 115/172] =?UTF-8?q?=F0=9F=94=96=200.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 66cf3b8..48145b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.2.8" +version = "0.3.0" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 6a99498..fb8a707 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.2.8" +__VERSION__ = "0.3.0" From bb8884f4191bdaa492c5a883e9ce68e742ea4c7a Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 14:46:32 +0200 Subject: [PATCH 116/172] =?UTF-8?q?=F0=9F=90=9B=20Remember=20to=20alias=20?= =?UTF-8?q?builtin=20apps=20before=20inferring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/model/model.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index 642603d..2047f80 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -45,6 +45,8 @@ class ModelMeta(type(TortoiseModel)): inferred_app = module_parts[-2] else: inferred_app = module_parts[-1] + # Rewrite builtin apps to ohmyapi_* alias + inferred_app = inferred_app.replace("ohmyapi.builtin.", "ohmyapi_") meta.app = inferred_app # Prefix table name if not explicitly set From 856ea12f52bf185412902d72d9b4f71522531d2d Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 14:47:15 +0200 Subject: [PATCH 117/172] =?UTF-8?q?=F0=9F=94=96=200.3.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 48145b0..7622b55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.3.0" +version = "0.3.1" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index fb8a707..4f71247 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.3.0" +__VERSION__ = "0.3.1" From bcdd23652f0e8ebcb19444bd92c5e0ac8dbde931 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 15:09:02 +0200 Subject: [PATCH 118/172] =?UTF-8?q?=F0=9F=90=9B=20Revoke=20support=20for?= =?UTF-8?q?=20table=5Fname=20prefixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unfortunately, Aerich seems a bit awkward in respecting Model.Meta.table. Also proxy-tables can not be prefixed at all. If there is no concise way to prefix all tables of an app, we shouldn't prefix it at all and leave the collision-problem for the user to keep an eye on. --- src/ohmyapi/db/model/model.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index 2047f80..929ff60 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -34,25 +34,6 @@ class ModelMeta(type(TortoiseModel)): # Grab the Schema class for further processing. schema_opts = attrs.get("Schema", None) - # Create or get the inner Meta class - meta = attrs.get("Meta", type("Meta", (), {})) - - # Infer app name from module if not explicitly set - if not hasattr(meta, "app"): - module = attrs.get("__module__", "") - module_parts = module.split(".") - if module_parts[-1] == "models": - inferred_app = module_parts[-2] - else: - inferred_app = module_parts[-1] - # Rewrite builtin apps to ohmyapi_* alias - inferred_app = inferred_app.replace("ohmyapi.builtin.", "ohmyapi_") - meta.app = inferred_app - - # Prefix table name if not explicitly set - if not hasattr(meta, "table"): - meta.table = f"{meta.app}_{name.lower()}" - # Let Tortoise's Metaclass do it's thing. new_cls = super().__new__(mcls, name, bases, attrs) From ad8986abb1381921eb2490661460317a3eeae56e Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 15:11:19 +0200 Subject: [PATCH 119/172] =?UTF-8?q?=F0=9F=94=96=200.3.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7622b55..52f53b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.3.1" +version = "0.3.2" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 4f71247..1b16679 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.3.1" +__VERSION__ = "0.3.2" From 5c632cbe8f63c6412b43eb301ad55fad62d6144c Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 17:15:39 +0200 Subject: [PATCH 120/172] =?UTF-8?q?=F0=9F=8E=A8=20Add=20Q=20to=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ohmyapi/db/__init__.py b/src/ohmyapi/db/__init__.py index e526ef5..e13bdeb 100644 --- a/src/ohmyapi/db/__init__.py +++ b/src/ohmyapi/db/__init__.py @@ -1,3 +1,4 @@ +from tortoise.expressions import Q from tortoise.manager import Manager from tortoise.queryset import QuerySet from tortoise.signals import ( From cc3872cf74358fdf1d8dfd76f09a0f188dfc86cd Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Thu, 2 Oct 2025 21:49:48 +0200 Subject: [PATCH 121/172] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20middl?= =?UTF-8?q?eware=20in=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/__init__.py | 2 +- src/ohmyapi/core/runtime.py | 37 +++++++++++++++++++++++++--- src/ohmyapi/middleware/cors.py | 26 +++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 src/ohmyapi/middleware/cors.py diff --git a/src/ohmyapi/builtin/auth/__init__.py b/src/ohmyapi/builtin/auth/__init__.py index 8c2daf9..3867c30 100644 --- a/src/ohmyapi/builtin/auth/__init__.py +++ b/src/ohmyapi/builtin/auth/__init__.py @@ -1 +1 @@ -from . import models, permissions, routes +from . import middlewares, models, permissions, routes diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 423f3c5..010c67d 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -7,7 +7,7 @@ import sys from http import HTTPStatus from pathlib import Path from types import ModuleType -from typing import Any, Dict, Generator, List, Optional, Type +from typing import Any, Dict, Generator, List, Optional, Tuple, Type import click from aerich import Command as AerichCommand @@ -88,10 +88,13 @@ class Project: def configure_app(self, app: FastAPI) -> FastAPI: """ - Attach project routes and event handlers to given FastAPI instance. + Attach project middlewares and routes and event handlers to given + FastAPI instance. """ - # Attach project routes. + # Attach project middlewares and routes. for app_name, app_def in self._apps.items(): + for middleware, kwargs in app_def.middlewares: + app.add_middleware(middleware, **kwargs) for router in app_def.routers: app.include_router(router) @@ -229,11 +232,15 @@ class App: # Reference to this app's routes modules. self._routers: Dict[str, ModuleType] = {} + # Reference to this apps middlewares. + self._middlewares: List[Tuple[Any, Dict[str, Any]]] = [] + # Import the app, so its __init__.py runs. mod: ModuleType = importlib.import_module(name) self.__load_models(f"{self.name}.models") self.__load_routes(f"{self.name}.routes") + self.__load_middlewares(f"{self.name}.middlewares") def __repr__(self): return json.dumps(self.dict(), indent=2) @@ -319,6 +326,18 @@ class App: # Walk the walk. walk(mod_name) + def __load_middlewares(self, mod_name): + try: + mod = importlib.import_module(mod_name) + except ModuleNotFoundError: + print(f"no middlewares detected: {mod_name}") + return + + getter = getattr(mod, "get", None) + if getter is not None: + for middleware in getter(): + self._middlewares.append(middleware) + def __serialize_route(self, route): """ Convert APIRoute to JSON-serializable dict. @@ -332,6 +351,12 @@ class App: def __serialize_router(self): return [self.__serialize_route(route) for route in self.routes] + def __serialize_middleware(self): + out = [] + for m in self.middlewares: + out.append((m[0].__name__, m[1])) + return out + @property def models(self) -> List[ModuleType]: """ @@ -363,6 +388,11 @@ class App: out.extend(r.routes) return out + @property + def middlewares(self): + """Returns the list of this app's middlewares.""" + return self._middlewares + def dict(self) -> Dict[str, Any]: """ Convenience method for serializing the runtime data. @@ -371,5 +401,6 @@ class App: "models": [ f"{self.name}.{m.__name__}" for m in self.models[f"{self.name}.models"] ], + "middlewares": self.__serialize_middleware(), "routes": self.__serialize_router(), } diff --git a/src/ohmyapi/middleware/cors.py b/src/ohmyapi/middleware/cors.py new file mode 100644 index 0000000..49852cd --- /dev/null +++ b/src/ohmyapi/middleware/cors.py @@ -0,0 +1,26 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from typing import Any, Dict, List, Tuple + +import settings + +DEFAULT_ORIGINS = ["http://localhost", "http://localhost:8000"] +DEFAULT_CREDENTIALS = False +DEFAULT_METHODS = ["*"] +DEFAULT_HEADERS = ["*"] + +CORS_CONFIG: Dict[str, Any] = getattr(settings, "MIDDLEWARE_CORS", {}) + +if not isinstance(CORS_CONFIG, dict): + raise ValueError("MIDDLEWARE_CORS must be of type dict") + +middleware = [ + (CORSMiddleware, { + "allow_origins": CORS_CONFIG.get("ALLOW_ORIGINS", DEFAULT_ORIGINS), + "allow_credentials": CORS_CONFIG.get("ALLOW_CREDENTIALS", DEFAULT_CREDENTIALS), + "allow_methods": CORS_CONFIG.get("ALLOW_METHODS", DEFAULT_METHODS), + "allow_headers": CORS_CONFIG.get("ALLOW_HEADERS", DEFAULT_HEADERS), + }), +] + From 33a9c94042dbd235d98e4a5e4f6e6a96da593210 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 6 Oct 2025 21:27:16 +0200 Subject: [PATCH 122/172] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20ohmyapi?= =?UTF-8?q?=5Fauth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove middlewares (should come from user) - save symmetrically hased e-mail instead of raw --- src/ohmyapi/builtin/auth/__init__.py | 2 +- src/ohmyapi/builtin/auth/models.py | 35 ++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/ohmyapi/builtin/auth/__init__.py b/src/ohmyapi/builtin/auth/__init__.py index 3867c30..8c2daf9 100644 --- a/src/ohmyapi/builtin/auth/__init__.py +++ b/src/ohmyapi/builtin/auth/__init__.py @@ -1 +1 @@ -from . import middlewares, models, permissions, routes +from . import models, permissions, routes diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index d68ba38..ea9f9ee 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -1,25 +1,40 @@ +import hmac +import hashlib +import base64 from functools import wraps +from secrets import token_bytes from typing import List, Optional from uuid import UUID from passlib.context import CryptContext from tortoise.contrib.pydantic import pydantic_queryset_creator -from ohmyapi.db import Model, field, pre_delete, pre_save +from ohmyapi.db import Model, field, Q from ohmyapi.router import HTTPException +import settings + pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") +SECRET_KEY = getattr(settings, "SECRET_KEY", "OhMyAPI Secret Key") + + +def hmac_hash(data: str) -> str: + digest = hmac.new(SECRET_KEY.encode("UTF-8"), data.encode("utf-8"), hashlib.sha256).digest() + return base64.urlsafe_b64encode(digest).decode("utf-8") class Group(Model): id: UUID = field.data.UUIDField(pk=True) name: str = field.CharField(max_length=42, index=True) + def __str__(self): + return self.name if self.name else "" + class User(Model): id: UUID = field.data.UUIDField(pk=True) - email: str = field.CharField(max_length=255, unique=True, index=True) username: str = field.CharField(max_length=150, unique=True) + email_hash: Optional[str] = field.CharField(max_length=255, unique=True, index=True) password_hash: str = field.CharField(max_length=128) is_admin: bool = field.BooleanField(default=False) is_staff: bool = field.BooleanField(default=False) @@ -28,18 +43,30 @@ class User(Model): ) class Schema: - exclude = ("password_hash",) + exclude = ["password_hash", "email_hash"] + + def __str__(self): + fields = { + 'username': self.username if self.username else "-", + 'is_admin': 'y' if self.is_admin else 'n', + 'is_staff': 'y' if self.is_staff else 'n', + } + return ' '.join([f"{k}:{v}" for k, v in fields.items()]) def set_password(self, raw_password: str) -> None: """Hash and store the password.""" self.password_hash = pwd_context.hash(raw_password) + def set_email(self, new_email: str) -> None: + """Hash and set the e-mail address.""" + self.email_hash = hmac_hash(email) + 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"]: + async def authenticate_username(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): From 9ac95af00511e1a055fa0da8cf86dcc8414dc3c0 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 6 Oct 2025 21:29:52 +0200 Subject: [PATCH 123/172] =?UTF-8?q?=F0=9F=8E=A8=20Add=20option=20to=20pull?= =?UTF-8?q?=20in=20asyncpg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/templates/project/pyproject.toml.j2 | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ohmyapi/core/templates/project/pyproject.toml.j2 b/src/ohmyapi/core/templates/project/pyproject.toml.j2 index 0fe3dcd..752ecec 100644 --- a/src/ohmyapi/core/templates/project/pyproject.toml.j2 +++ b/src/ohmyapi/core/templates/project/pyproject.toml.j2 @@ -11,6 +11,7 @@ license = { text = "MIT" } dependencies = [ "ohmyapi" +# "asyncpg" ] [tool.poetry.group.dev.dependencies] From 3e029e1fb70a97ca4c336f4d50f67be7aebe9783 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 6 Oct 2025 21:30:50 +0200 Subject: [PATCH 124/172] =?UTF-8?q?=F0=9F=94=A5=20Remove=20poetry=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/templates/docker/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ohmyapi/core/templates/docker/Dockerfile b/src/ohmyapi/core/templates/docker/Dockerfile index 28a8f32..484a371 100644 --- a/src/ohmyapi/core/templates/docker/Dockerfile +++ b/src/ohmyapi/core/templates/docker/Dockerfile @@ -21,7 +21,6 @@ ENV PATH="$POETRY_HOME/bin:$PATH" WORKDIR /app COPY pyproject.toml poetry.lock* /app/ -RUN poetry lock RUN poetry install COPY . /app From 6120c151f1fb4293198a29b2f6229d26580657ae Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 6 Oct 2025 21:31:10 +0200 Subject: [PATCH 125/172] =?UTF-8?q?=F0=9F=8E=A8=20Add=20Postgress=20servic?= =?UTF-8?q?e=20as=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/templates/docker/docker-compose.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ohmyapi/core/templates/docker/docker-compose.yml b/src/ohmyapi/core/templates/docker/docker-compose.yml index 6691e61..9e03d32 100644 --- a/src/ohmyapi/core/templates/docker/docker-compose.yml +++ b/src/ohmyapi/core/templates/docker/docker-compose.yml @@ -1,4 +1,14 @@ services: + # db: + # image: postgres:latest + # restart: unless-stopped + # environment: + # POSTGRES_DB: ohmyapi + # POSTGRES_USER: ohmyapi + # POSTGRES_PASSWORD: ohmyapi + # ports: + # - 5432:5432 + app: build: context: . From e848601f7949ccbbdf29dde73fc29542311214b9 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 12:41:05 +0200 Subject: [PATCH 126/172] =?UTF-8?q?=F0=9F=90=9B=20Use=20set=5Femail=20in?= =?UTF-8?q?=20createsuperuser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index 6728753..37727e0 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -75,7 +75,7 @@ def shell(root: str = "."): asyncio.set_event_loop(loop) loop.run_until_complete(init_and_cleanup()) - # Prepare shell vars that are to be directly available + # Prepare shell vars that are to be immediately available shell_vars = {"p": project} try: @@ -150,8 +150,9 @@ def createsuperuser(root: str = "."): if password1 != password2: print("Passwords didn't match!") user = ohmyapi_auth.models.User( - email=email, username=username, is_staff=True, is_admin=True + username=username, is_staff=True, is_admin=True ) + user.set_email(email) user.set_password(password1) asyncio.run(project.init_orm()) asyncio.run(user.save()) From 67d89a94f48d7208beb138c5ce2f4ad59894ae1a Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 12:45:27 +0200 Subject: [PATCH 127/172] =?UTF-8?q?=E2=9C=A8=20Integrate=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/cli.py | 14 +++++++++++- src/ohmyapi/core/logging.py | 38 +++++++++++++++++++++++++++++++++ src/ohmyapi/core/runtime.py | 11 +++++++--- src/ohmyapi/core/scaffolding.py | 16 ++++++-------- 4 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 src/ohmyapi/core/logging.py diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index 37727e0..42572a1 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -9,6 +9,9 @@ import typer import uvicorn from ohmyapi.core import runtime, scaffolding +from ohmyapi.core.logging import setup_logging + +logger = setup_logging() app = typer.Typer( help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM." @@ -19,18 +22,24 @@ app = typer.Typer( def startproject(name: str): """Create a new OhMyAPI project in the given directory.""" scaffolding.startproject(name) + logger.info(f"✅ Project '{name}' created successfully.") + logger.info(f"🔧 Next, configure your project in {name}/settings.py") @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) + print(f"✅ App '{app_name}' created in project '{root}' successfully.") + print(f"🔧 Remember to add '{app_name}' to your INSTALLED_APPS!") @app.command() def dockerize(root: str = "."): """Create template Dockerfile and docker-compose.yml.""" scaffolding.copy_static("docker", root) + logger.info(f"✅ Templates created successfully.") + logger.info(f"🔧 Next, run `docker compose up -d --build`") @app.command() @@ -42,7 +51,7 @@ def serve(root: str = ".", host="127.0.0.1", port=8000): project = runtime.Project(project_path) app_instance = project.app() app_instance = project.configure_app(app_instance) - uvicorn.run(app_instance, host=host, port=int(port), reload=False) + uvicorn.run(app_instance, host=host, port=int(port), reload=False, log_config=None) @app.command() @@ -107,6 +116,8 @@ def makemigrations(app: str = "*", name: str = "auto", root: str = "."): asyncio.run(project.makemigrations(app_label=app, name=name)) else: asyncio.run(project.makemigrations(app_label=app, name=name)) + logger.info(f"✅ Migrations created successfully.") + logger.info(f"🔧 To migrate the DB, run `ohmyapi migrate`, next.") @app.command() @@ -121,6 +132,7 @@ def migrate(app: str = "*", root: str = "."): asyncio.run(project.migrate(app)) else: asyncio.run(project.migrate(app)) + logger.info(f"✅ Migrations ran successfully.") @app.command() diff --git a/src/ohmyapi/core/logging.py b/src/ohmyapi/core/logging.py new file mode 100644 index 0000000..ca05220 --- /dev/null +++ b/src/ohmyapi/core/logging.py @@ -0,0 +1,38 @@ +import logging +import os +import sys + + +def setup_logging(): + """Configure unified logging for ohmyapi + FastAPI/Uvicorn.""" + log_level = os.getenv("OHMYAPI_LOG_LEVEL", "INFO").upper() + level = getattr(logging, log_level, logging.INFO) + + # Root logger (affects FastAPI, uvicorn, etc.) + logging.basicConfig( + level=level, + format="[%(asctime)s] [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[ + logging.StreamHandler(sys.stdout) + ] + ) + + # Separate ohmyapi logger (optional) + logger = logging.getLogger("ohmyapi") + + # Direct warnings/errors to stderr + class LevelFilter(logging.Filter): + def filter(self, record): + # Send warnings+ to stderr, everything else to stdout + if record.levelno >= logging.WARNING: + record.stream = sys.stderr + else: + record.stream = sys.stdout + return True + + for handler in logger.handlers: + handler.addFilter(LevelFilter()) + + logger.setLevel(level) + return logger diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 010c67d..9a920f8 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -15,8 +15,11 @@ from aerich.exceptions import NotInitedError from fastapi import APIRouter, FastAPI from tortoise import Tortoise +from ohmyapi.core.logging import setup_logging from ohmyapi.db.model import Model +logger = setup_logging() + class Project: """ @@ -29,6 +32,7 @@ class Project: """ def __init__(self, project_path: str): + logger.debug(f"Loading project: {project_path}") self.project_path = Path(project_path).resolve() self._apps: Dict[str, App] = {} self.migrations_dir = self.project_path / "migrations" @@ -238,6 +242,7 @@ class App: # Import the app, so its __init__.py runs. mod: ModuleType = importlib.import_module(name) + logger.debug(f"Loading app: {self.name}") self.__load_models(f"{self.name}.models") self.__load_routes(f"{self.name}.routes") self.__load_middlewares(f"{self.name}.middlewares") @@ -258,7 +263,6 @@ class App: try: importlib.import_module(mod_name) except ModuleNotFoundError: - print(f"no models detected: {mod_name}") return # Acoid duplicates. @@ -276,9 +280,11 @@ class App: and issubclass(value, Model) and not name == Model.__name__ ): + # monkey-patch __module__ to point to well-known aliases value.__module__ = value.__module__.replace("ohmyapi.builtin.", "ohmyapi_") if value.__module__.startswith(mod_name): self._models[mod_name] = self._models.get(mod_name, []) + [value] + logger.debug(f" - Model: {mod_name} -> {name}") # if it's a package, recurse into submodules if hasattr(mod, "__path__"): @@ -300,7 +306,6 @@ class App: try: importlib.import_module(mod_name) except ModuleNotFound: - print(f"no routes detected: {mod_name}") return # Avoid duplicates. @@ -315,6 +320,7 @@ class App: for name, value in vars(mod).copy().items(): if isinstance(value, APIRouter) and not name == APIRouter.__name__: self._routers[mod_name] = self._routers.get(mod_name, []) + [value] + logger.debug(f" - Router: {mod_name} -> {name} -> {value.routes}") # if it's a package, recurse into submodules if hasattr(mod, "__path__"): @@ -330,7 +336,6 @@ class App: try: mod = importlib.import_module(mod_name) except ModuleNotFoundError: - print(f"no middlewares detected: {mod_name}") return getter = getattr(mod, "get", None) diff --git a/src/ohmyapi/core/scaffolding.py b/src/ohmyapi/core/scaffolding.py index c637b5a..2dddeaf 100644 --- a/src/ohmyapi/core/scaffolding.py +++ b/src/ohmyapi/core/scaffolding.py @@ -2,12 +2,16 @@ from pathlib import Path from jinja2 import Environment, FileSystemLoader +from ohmyapi.core.logging import setup_logging + import shutil # Base templates directory TEMPLATE_DIR = Path(__file__).parent / "templates" env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR))) +logger = setup_logging() + def render_template_file(template_path: Path, context: dict, output_path: Path): """Render a single Jinja2 template file to disk.""" @@ -60,7 +64,7 @@ def copy_static(dir_name: str, target_dir: Path): template_dir = TEMPLATE_DIR / dir_name target_dir = Path(target_dir) if not template_dir.exists(): - print(f"no templates found under: {dir_name}") + logger.error(f"no templates found under: {dir_name}") return for root, _, files in template_dir.walk(): @@ -69,19 +73,15 @@ def copy_static(dir_name: str, target_dir: Path): src = root_path / file dst = target_dir / file if dst.exists(): - print(f"⛔ File exists, skipping: {dst}") + logger.warning(f"⛔ File exists, skipping: {dst}") continue - shutil.copy(src, ".") - print(f"✅ Templates created successfully.") - print(f"🔧 Next, run `docker compose up -d --build`") + shutil.copy(src, dst) def startproject(name: str): """Create a new project: flat structure, all project templates go into /""" target_dir = Path(name).resolve() target_dir.mkdir(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): @@ -94,5 +94,3 @@ def startapp(name: str, project: str): {"project_name": target_dir.resolve().name, "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!") From cb9acd52d0c149e44e8df8d1ce5bc7b3f74f3ae2 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 12:54:42 +0200 Subject: [PATCH 128/172] =?UTF-8?q?=F0=9F=93=9D=20Small=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1db797e..871fd45 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # OhMyAPI -OhMyAPI is a web-application scaffolding framework and management layer built around `FastAPI`, `TortoiseORM` and `Aerich` migrations. +OhMyAPI is a web-application scaffolding framework and management layer built around `FastAPI`. +It is a thin layer that tightly integrates TortoiseORM and Aerich migrations. -> *Think: Django RestFramework, but less clunky and 100% async.* +> *Think: *"Django RestFramework"*, but less clunky and instead 100% async.* It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***! @@ -12,7 +13,7 @@ It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteri - Django-like per-app migrations (makemigrations & migrate) via Aerich - Django-like CLI tooling (startproject, startapp, shell, serve, etc) - Customizable pydantic model serializer built-in -- Various optional built-in apps you can hook into your project +- Various optional built-in apps you can hook into your project (i.e. authentication and more) - Highly configurable and customizable - 100% async From f57997246655007745c88e4dd4deeee5a0aa3776 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 13:06:42 +0200 Subject: [PATCH 129/172] =?UTF-8?q?=F0=9F=8E=A8=20Allow=20overriding=20bas?= =?UTF-8?q?e=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 9a920f8..0b110f8 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -95,6 +95,7 @@ class Project: Attach project middlewares and routes and event handlers to given FastAPI instance. """ + app.router.prefix = getattr(self.settings, "API_PREFIX", "") # Attach project middlewares and routes. for app_name, app_def in self._apps.items(): for middleware, kwargs in app_def.middlewares: From 0deb5706f85273b214fa4842cda12a6320f193cd Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 13:07:31 +0200 Subject: [PATCH 130/172] =?UTF-8?q?=F0=9F=8E=A8=20Single=20line=20initiali?= =?UTF-8?q?zation=20is=20more=20readable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index 42572a1..a530424 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -49,8 +49,7 @@ def serve(root: str = ".", host="127.0.0.1", port=8000): """ project_path = Path(root) project = runtime.Project(project_path) - app_instance = project.app() - app_instance = project.configure_app(app_instance) + app_instance = project.configure_app(project.app()) uvicorn.run(app_instance, host=host, port=int(port), reload=False, log_config=None) From c23576b3937abe5483aa03e66e53089fc79fd845 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 13:09:16 +0200 Subject: [PATCH 131/172] =?UTF-8?q?=F0=9F=A9=B9=20Remove=20ohmyapi=20from?= =?UTF-8?q?=20template=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/templates/project/pyproject.toml.j2 | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ohmyapi/core/templates/project/pyproject.toml.j2 b/src/ohmyapi/core/templates/project/pyproject.toml.j2 index 752ecec..f877761 100644 --- a/src/ohmyapi/core/templates/project/pyproject.toml.j2 +++ b/src/ohmyapi/core/templates/project/pyproject.toml.j2 @@ -10,7 +10,6 @@ readme = "README.md" license = { text = "MIT" } dependencies = [ - "ohmyapi" # "asyncpg" ] From 4c11297d65e3d479d73335fec20f45f8896874b0 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 13:09:41 +0200 Subject: [PATCH 132/172] =?UTF-8?q?=F0=9F=94=96=200.4.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 52f53b9..324fa6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.3.2" +version = "0.4.0" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 1b16679..dc91b46 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.3.2" +__VERSION__ = "0.4.0" From 49c24b7f0c8b68625c01403089759c13cf1649b4 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 13:19:47 +0200 Subject: [PATCH 133/172] =?UTF-8?q?=F0=9F=90=9B=20Fix=20createsuperuser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index ea9f9ee..bdb4451 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -59,7 +59,7 @@ class User(Model): def set_email(self, new_email: str) -> None: """Hash and set the e-mail address.""" - self.email_hash = hmac_hash(email) + self.email_hash = hmac_hash(new_email) def verify_password(self, raw_password: str) -> bool: """Verify a plaintext password against the stored hash.""" From 1ef64c2b17fc29126be531a145fedc69733beab5 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 13:20:32 +0200 Subject: [PATCH 134/172] =?UTF-8?q?=F0=9F=94=96=200.4.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 324fa6a..ca0cbfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.4.0" +version = "0.4.1" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index dc91b46..7cc3839 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.4.0" +__VERSION__ = "0.4.1" From b1222c64d6f012cb6b5f52fa1fcd487e8789c989 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 13:39:11 +0200 Subject: [PATCH 135/172] =?UTF-8?q?=F0=9F=90=9B=20Fix=20ModuleNotFoundErro?= =?UTF-8?q?r=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 0b110f8..191136d 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -306,7 +306,7 @@ class App: # An app may come without any routes. try: importlib.import_module(mod_name) - except ModuleNotFound: + except ModuleNotFoundError: return # Avoid duplicates. From e3abc642ed9981a958b41aa8c9a946c67dfad390 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 13:40:28 +0200 Subject: [PATCH 136/172] =?UTF-8?q?=F0=9F=94=96=200.4.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ca0cbfd..37e0431 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.4.1" +version = "0.4.2" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 7cc3839..cf441c0 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.4.1" +__VERSION__ = "0.4.2" From 4ec2f87ce29011ea58d98a971f91ea594d76c3be Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 13:50:07 +0200 Subject: [PATCH 137/172] =?UTF-8?q?=F0=9F=90=9B=20Fix=20ohmyapi.core.runti?= =?UTF-8?q?me.App.models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 191136d..576e275 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -129,7 +129,7 @@ class Project: } for app_name, app in self._apps.items(): - modules = list(app.models.keys()) + modules = list(app._models.keys()) if modules: config["apps"][app_name] = { "models": modules, @@ -372,9 +372,7 @@ class App: for module in self._models: for model in self._models[module]: out.append(model) - return { - module: out, - } + return out @property def routers(self): From e1f5ce589caf2c01b762c87d66b666ce367bf889 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 8 Oct 2025 13:56:23 +0200 Subject: [PATCH 138/172] =?UTF-8?q?=F0=9F=94=96=200.4.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 37e0431..01e64e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.4.2" +version = "0.4.3" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index cf441c0..f929707 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.4.2" +__VERSION__ = "0.4.3" From 3e15aa772296ba3fdd0c9b5f0223e5f127b63f5a Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Fri, 10 Oct 2025 15:48:40 +0200 Subject: [PATCH 139/172] =?UTF-8?q?=F0=9F=90=9B=20Support=20apps=20without?= =?UTF-8?q?=20any=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 576e275..e3d700e 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -154,7 +154,10 @@ class Project: tortoise_cfg = self.build_tortoise_config(db_url=db_url) # Prevent leaking other app's models to Aerich. - tortoise_cfg["apps"] = {app_label: tortoise_cfg["apps"][app_label]} + if app_label in tortoise_cfg["apps"].keys(): + tortoise_cfg["apps"] = {app_label: tortoise_cfg["apps"][app_label]} + else: + tortoise_cfg["apps"] = {app_label: {"default_connection": "default", "models": []}} # Append aerich.models to the models list of the target app only tortoise_cfg["apps"][app_label]["models"].append("aerich.models") @@ -403,7 +406,7 @@ class App: """ return { "models": [ - f"{self.name}.{m.__name__}" for m in self.models[f"{self.name}.models"] + f"{self.name}.{m.__name__}" for m in self._models[f"{self.name}.models"] ], "middlewares": self.__serialize_middleware(), "routes": self.__serialize_router(), From 2239480dc03d70d627fa10462d6a7b8581e1ce73 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Fri, 10 Oct 2025 15:49:14 +0200 Subject: [PATCH 140/172] =?UTF-8?q?=F0=9F=8E=A8=20Also=20export=20tortoise?= =?UTF-8?q?.query=5Futils.Prefetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ohmyapi/db/__init__.py b/src/ohmyapi/db/__init__.py index e13bdeb..0a634a9 100644 --- a/src/ohmyapi/db/__init__.py +++ b/src/ohmyapi/db/__init__.py @@ -1,5 +1,6 @@ from tortoise.expressions import Q from tortoise.manager import Manager +from tortoise.query_utils import Prefetch from tortoise.queryset import QuerySet from tortoise.signals import ( post_delete, From 1c42b44d410abf5b95ddf6c50a6b5d061ccfa6fb Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Fri, 10 Oct 2025 15:49:57 +0200 Subject: [PATCH 141/172] =?UTF-8?q?=F0=9F=94=96=200.4.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 01e64e6..038c3f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.4.3" +version = "0.4.4" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index f929707..993fbd9 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.4.3" +__VERSION__ = "0.4.4" From 643a6b2eb7ec1f374ffac057165ac3a5e70c9682 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 01:07:55 +0200 Subject: [PATCH 142/172] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20Add=20optionall?= =?UTF-8?q?y=5Fauthenticated=20permission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/routes.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 8a81389..2f1cb2a 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -1,6 +1,6 @@ import time from enum import Enum -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional import jwt import settings @@ -124,6 +124,12 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: return user +async def optionally_authenticated(token: Optional[str] = Depends(oauth2_scheme)) -> Optional[User]: + if token is None: + return None + return await get_current_user(token) + + async def require_authenticated(current_user: User = Depends(get_current_user)) -> User: """Ensure the current user is an admin.""" if not current_user: From d7f7db338f6a50ea9908c4bd0d91f0c9bd750042 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 01:08:50 +0200 Subject: [PATCH 143/172] =?UTF-8?q?=F0=9F=94=96=200.4.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 038c3f6..9a7592c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.4.4" +version = "0.4.5" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 993fbd9..31f0221 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.4.4" +__VERSION__ = "0.4.5" From cf106e88557940bb719d4a20a420e63662231e80 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 01:20:32 +0200 Subject: [PATCH 144/172] =?UTF-8?q?=F0=9F=90=9B=20maybe=5Fauthenticated=20?= =?UTF-8?q?with=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/permissions.py | 1 + src/ohmyapi/builtin/auth/routes.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ohmyapi/builtin/auth/permissions.py b/src/ohmyapi/builtin/auth/permissions.py index 578dbd3..e7be37e 100644 --- a/src/ohmyapi/builtin/auth/permissions.py +++ b/src/ohmyapi/builtin/auth/permissions.py @@ -1,6 +1,7 @@ from .routes import ( get_current_user, get_token, + maybe_authenticated, require_admin, require_authenticated, require_group, diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 2f1cb2a..3b37f59 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -124,7 +124,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: return user -async def optionally_authenticated(token: Optional[str] = Depends(oauth2_scheme)) -> Optional[User]: +async def maybe_authenticated(token: Optional[str] = Depends(oauth2_scheme)) -> Optional[User]: if token is None: return None return await get_current_user(token) From 66176e9af7decf39dd38d6b33f259c5274cf3349 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 01:21:03 +0200 Subject: [PATCH 145/172] =?UTF-8?q?=F0=9F=94=96=200.4.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9a7592c..a5925a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.4.5" +version = "0.4.6" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 31f0221..8824968 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.4.5" +__VERSION__ = "0.4.6" From 80163ce994487b7a5471ffa38261960e72e8fd51 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 02:06:42 +0200 Subject: [PATCH 146/172] =?UTF-8?q?=F0=9F=90=9B=20Make=20settings=20module?= =?UTF-8?q?=20available,=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index e3d700e..8fedc3c 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -40,6 +40,14 @@ class Project: if str(self.project_path) not in sys.path: sys.path.insert(0, str(self.project_path)) + # 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 + # Alias builtin apps as ohmyapi_. # We need this, because Tortoise app-names may not include dots `.`. spec = importlib.util.find_spec("ohmyapi.builtin") @@ -57,14 +65,6 @@ class Project: 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) From de043ddd978ad0a38dc0e4bfe5acfaf8de4f0465 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 02:19:53 +0200 Subject: [PATCH 147/172] =?UTF-8?q?=F0=9F=90=9B=20Fix=20authenticate=20met?= =?UTF-8?q?hod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/models.py | 2 +- src/ohmyapi/builtin/auth/routes.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index bdb4451..831ebae 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -66,7 +66,7 @@ class User(Model): return pwd_context.verify(raw_password, self.password_hash) @classmethod - async def authenticate_username(cls, username: str, password: str) -> Optional["User"]: + 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): diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 3b37f59..04169c1 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -28,7 +28,6 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") class ClaimsUser(BaseModel): username: str - email: str is_admin: bool is_staff: bool @@ -70,7 +69,6 @@ def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Claim sub=str(user.id), user=ClaimsUser( username=user.username, - email=user.email, is_admin=user.is_admin, is_staff=user.is_staff, ), From a45f03b92f82618f34918a3373c8f6f61189bd98 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 02:20:59 +0200 Subject: [PATCH 148/172] =?UTF-8?q?=F0=9F=90=9B=20Fix=20maybe=5Fauthentica?= =?UTF-8?q?ted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/routes.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 04169c1..3d7be7f 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -2,14 +2,16 @@ import time from enum import Enum from typing import Any, Dict, List, Optional -import jwt -import settings -from fastapi import APIRouter, Body, Depends, Header, HTTPException, status -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request, status +from fastapi.security import OAuth2, OAuth2PasswordBearer, OAuth2PasswordRequestForm +from fastapi.security.utils import get_authorization_scheme_param from pydantic import BaseModel from ohmyapi.builtin.auth.models import Group, User +import jwt +import settings + # Router router = APIRouter(prefix="/auth", tags=["Auth"]) @@ -23,7 +25,21 @@ REFRESH_TOKEN_EXPIRE_SECONDS = getattr( settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60 ) +class OptionalOAuth2PasswordBearer(OAuth2): + def __init__(self, tokenUrl: str): + super().__init__(flows={"password": {"tokenUrl": tokenUrl}}, scheme_name="OAuth2PasswordBearer") + + async def __call__(self, request: Request) -> Optional[str]: + authorization: str = request.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "bearer": + # No token provided — just return None + return None + return param + + oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") +oauth2_optional_scheme = OptionalOAuth2PasswordBearer(tokenUrl="/auth/login") class ClaimsUser(BaseModel): @@ -122,7 +138,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: return user -async def maybe_authenticated(token: Optional[str] = Depends(oauth2_scheme)) -> Optional[User]: +async def maybe_authenticated(token: Optional[str] = Depends(oauth2_optional_scheme)) -> Optional[User]: if token is None: return None return await get_current_user(token) From 74f625ab1df19c20515540aa495721f0a1d7e834 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 02:22:15 +0200 Subject: [PATCH 149/172] =?UTF-8?q?=F0=9F=94=96=200.4.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a5925a5..16265c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.4.6" +version = "0.4.7" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 8824968..01c4965 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.4.6" +__VERSION__ = "0.4.7" From acd4844a257b897aeb2f2f0e1e9a28c865c2a9ac Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 04:31:30 +0200 Subject: [PATCH 150/172] =?UTF-8?q?=F0=9F=8E=A8=20Export=20tortoise.functi?= =?UTF-8?q?ons=20as=20ohmyapi.db.functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/db/functions.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/ohmyapi/db/functions.py diff --git a/src/ohmyapi/db/functions.py b/src/ohmyapi/db/functions.py new file mode 100644 index 0000000..c5cf5de --- /dev/null +++ b/src/ohmyapi/db/functions.py @@ -0,0 +1 @@ +from tortoise.functions import * From ce47e3f60e915ceb7acc3235be122aeee473a247 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 13:03:31 +0200 Subject: [PATCH 151/172] =?UTF-8?q?=F0=9F=8E=A8=20Auto-prefix=20table=5Fna?= =?UTF-8?q?mes=20with=20"{app=5Flabel}=5F"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no support for auto-prefixing implicit proxy-tables in Tortoise. If you need to prefix a proxy-table, explicitly define the Model for the proxy table. Being an explicit model, it will then be auto-prefixed. --- src/ohmyapi/db/model/model.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index 929ff60..7ab918f 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -31,6 +31,18 @@ UUID.__get_pydantic_core_schema__ = classmethod(__uuid_schema_monkey_patch) class ModelMeta(type(TortoiseModel)): def __new__(mcls, name, bases, attrs): + meta = attrs.get("Meta", None) + if meta is None: + class Meta: + pass + meta = Meta + attrs["Meta"] = meta + + if not hasattr(meta, "table"): + # Use first part of module as app_label + app_label = attrs.get("__module__", "").replace("ohmyapi.builtin.", "ohmyapi_").split(".")[0] + setattr(meta, "table", f"{app_label}_{name.lower()}") + # Grab the Schema class for further processing. schema_opts = attrs.get("Schema", None) From 2e1ec5d780fb875cb3000df219f27f50e928df5d Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 13:06:34 +0200 Subject: [PATCH 152/172] =?UTF-8?q?=F0=9F=94=96=200.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 16265c6..e3b4ba7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.4.7" +version = "0.5.0" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 01c4965..dc77141 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.4.7" +__VERSION__ = "0.5.0" From d494396728f5fb11a86f3431575af05fbf840582 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 13:12:48 +0200 Subject: [PATCH 153/172] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Fix=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/auth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/auth.md b/docs/auth.md index 2526966..d26c1fe 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -40,6 +40,6 @@ router = APIRouter() @router.get("/") -def get(user: auth.User = Depends(permissions.required_authenticated)): +def get(user: auth.User = Depends(permissions.require_authenticated)): ... ``` From e25c9d1715d7075327be7df8e977a87aa8a9e856 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 13:30:46 +0200 Subject: [PATCH 154/172] =?UTF-8?q?=F0=9F=A9=B9=20Define=20explicit=20user?= =?UTF-8?q?=5Fgroups=20proxy-table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/models.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index 831ebae..aeaa4ae 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -39,7 +39,7 @@ class User(Model): is_admin: bool = field.BooleanField(default=False) is_staff: bool = field.BooleanField(default=False) groups: field.ManyToManyRelation[Group] = field.ManyToManyField( - "ohmyapi_auth.Group", related_name="users", through="usergroups" + "ohmyapi_auth.Group", related_name="users", through="ohmyapi_auth_user_groups" ) class Schema: @@ -72,3 +72,20 @@ class User(Model): if user and user.verify_password(password): return user return None + + +class UserGroups(Model): + user: field.ForeignKeyRelation[User] = field.ForeignKeyField( + "ohmyapi_auth.User", + related_name="user_groups", + index=True, + ) + group: field.ForeignKeyRelation[Group] = field.ForeignKeyField( + "ohmyapi_auth.Group", + related_name="group_users", + index=True, + ) + + class Meta: + table = "ohmyapi_auth_user_groups" + constraints = [("UNIQUE", ("user_id", "group_id"))] From 812e89ede931eaf6c4a60ecaf3d0559f675b8076 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 11 Oct 2025 13:31:28 +0200 Subject: [PATCH 155/172] =?UTF-8?q?=F0=9F=94=96=200.5.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 +++- src/ohmyapi/__init__.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e3b4ba7..b33d472 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.5.0" +version = "0.5.1" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] @@ -30,6 +30,8 @@ ipython = ">=9.5.0,<10.0.0" black = "^25.9.0" isort = "^6.0.1" mkdocs = "^1.6.1" +pytest = "^8.4.2" +httpx = "^0.28.1" [project.optional-dependencies] auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index dc77141..ceaf150 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.5.0" +__VERSION__ = "0.5.1" From 6b87bfeefbd4a829c71ba4a170ad2f75fc116cca Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 22 Oct 2025 11:25:40 +0200 Subject: [PATCH 156/172] =?UTF-8?q?=F0=9F=90=9B=20email=5Fhash=20is=20not?= =?UTF-8?q?=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index aeaa4ae..d4c2f12 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -34,7 +34,7 @@ class Group(Model): class User(Model): id: UUID = field.data.UUIDField(pk=True) username: str = field.CharField(max_length=150, unique=True) - email_hash: Optional[str] = field.CharField(max_length=255, unique=True, index=True) + email_hash: str = field.CharField(max_length=255, unique=True, index=True) password_hash: str = field.CharField(max_length=128) is_admin: bool = field.BooleanField(default=False) is_staff: bool = field.BooleanField(default=False) From 58f1387aafae2f8046c58d3303d4588c9c23a729 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 22 Oct 2025 11:26:50 +0200 Subject: [PATCH 157/172] =?UTF-8?q?=F0=9F=94=96=200.5.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b33d472..9a1ece0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.5.1" +version = "0.5.2" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index ceaf150..483d205 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.5.1" +__VERSION__ = "0.5.2" From 10681cc15b9c415e922214a3c87002678ce199ed Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 22 Oct 2025 11:55:47 +0200 Subject: [PATCH 158/172] =?UTF-8?q?=F0=9F=9A=9A=20Move=20hmac=5Fhash()=20t?= =?UTF-8?q?o=20ohmyapi=5Fauth.utils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/models.py | 11 +---------- src/ohmyapi/builtin/auth/utils.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 src/ohmyapi/builtin/auth/utils.py diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index d4c2f12..e8894c1 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -1,6 +1,3 @@ -import hmac -import hashlib -import base64 from functools import wraps from secrets import token_bytes from typing import List, Optional @@ -12,15 +9,9 @@ from tortoise.contrib.pydantic import pydantic_queryset_creator from ohmyapi.db import Model, field, Q from ohmyapi.router import HTTPException -import settings +from .utils import hmac_hash pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") -SECRET_KEY = getattr(settings, "SECRET_KEY", "OhMyAPI Secret Key") - - -def hmac_hash(data: str) -> str: - digest = hmac.new(SECRET_KEY.encode("UTF-8"), data.encode("utf-8"), hashlib.sha256).digest() - return base64.urlsafe_b64encode(digest).decode("utf-8") class Group(Model): diff --git a/src/ohmyapi/builtin/auth/utils.py b/src/ohmyapi/builtin/auth/utils.py new file mode 100644 index 0000000..e54a5da --- /dev/null +++ b/src/ohmyapi/builtin/auth/utils.py @@ -0,0 +1,17 @@ +import base64 +import hashlib +import hmac + +import settings + +SECRET_KEY = getattr(settings, "SECRET_KEY", "OhMyAPI Secret Key") + + +def hmac_hash(data: str) -> str: + digest = hmac.new( + SECRET_KEY.encode("UTF-8"), + data.encode("utf-8"), + hashlib.sha256, + ).digest() + return base64.urlsafe_b64encode(digest).decode("utf-8") + From b50cbc43417c832d47639d5f9f57b00e61401a20 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 25 Oct 2025 11:17:05 +0200 Subject: [PATCH 159/172] =?UTF-8?q?=F0=9F=90=9B=20Fix=20/auth/refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/routes.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 3d7be7f..efda1b2 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -200,10 +200,14 @@ async def login(form_data: LoginRequest = Body(...)): ) +class TokenRefresh(BaseModel): + refresh_token: str + + @router.post("/refresh", response_model=AccessToken) -async def refresh_token(refresh_token: str): +async def refresh_token(refresh_token: TokenRefresh = Body(...)): """Exchange refresh token for new access token.""" - payload = decode_token(refresh_token) + payload = decode_token(refresh_token.refresh_token) if payload.get("type") != "refresh": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token" @@ -219,7 +223,7 @@ async def refresh_token(refresh_token: str): new_access = create_token( claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS ) - return AccessToken(token_type="bearer", access_token=access_token) + return AccessToken(token_type="bearer", access_token=new_access) @router.get("/introspect", response_model=Dict[str, Any]) From 4a5bafd889de962b180e984380a81ebb5c026478 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sat, 25 Oct 2025 11:18:07 +0200 Subject: [PATCH 160/172] =?UTF-8?q?=F0=9F=94=96=200.5.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9a1ece0..e4b283e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.5.2" +version = "0.5.3" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 483d205..e1de76b 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.5.2" +__VERSION__ = "0.5.3" From 715b7a030a0e0fc4c834a347a3a33629f65b2314 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 26 Oct 2025 21:47:03 +0100 Subject: [PATCH 161/172] =?UTF-8?q?=F0=9F=90=9B=20Createsuperuser=20in=20s?= =?UTF-8?q?ingle=20asyncio=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/cli.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index a530424..fd41084 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -165,6 +165,13 @@ def createsuperuser(root: str = "."): ) user.set_email(email) user.set_password(password1) - asyncio.run(project.init_orm()) - asyncio.run(user.save()) - asyncio.run(project.close_orm()) + + async def _run(): + await project.init_orm() + user = ohmyapi_auth.models.User(username=username, is_staff=True, is_admin=True) + user.set_email(email) + user.set_password(password1) + await user.save() + await project.close_orm() + + asyncio.run(_run()) From 31f4da773c9755e6af477cf922b32c351044e66c Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 26 Oct 2025 21:48:48 +0100 Subject: [PATCH 162/172] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Upgrade=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poetry.lock | 113 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index c30b765..57e1e68 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aerich" @@ -62,7 +62,7 @@ version = "4.11.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, @@ -216,7 +216,7 @@ version = "2025.8.3" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, @@ -543,19 +543,66 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -564,6 +611,18 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "ipython" version = "9.6.0" @@ -1024,6 +1083,22 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-a test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] type = ["mypy (>=1.14.1)"] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -1260,6 +1335,28 @@ files = [ {file = "pypika_tortoise-0.6.2.tar.gz", hash = "sha256:f95ab59d9b6454db2e8daa0934728458350a1f3d56e81d9d1debc8eebeff26b3"}, ] +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1488,7 +1585,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1604,7 +1701,7 @@ files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {dev = "python_version == \"3.11\""} +markers = {dev = "python_version < \"3.13\""} [[package]] name = "typing-inspection" @@ -1719,4 +1816,4 @@ auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"] [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "3d301460081dada359d425d69feefc63c1e5135aa64b6f000f554bfc1231febd" +content-hash = "cc1604995d3b73ee302e63731dd300ea17c4d95d0cfc6c386626dd9a9f60e8a7" From ed30291a4cedfaed622ea2a4852c10c939e949c8 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 26 Oct 2025 21:49:13 +0100 Subject: [PATCH 163/172] =?UTF-8?q?=F0=9F=94=96=200.5.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e4b283e..cfb6e5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.5.3" +version = "0.5.4" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index e1de76b..f8393e5 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.5.3" +__VERSION__ = "0.5.4" From 9d2e284da3aef100dbc2b3b15f5271462ff3bed6 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 27 Oct 2025 10:45:19 +0100 Subject: [PATCH 164/172] =?UTF-8?q?=F0=9F=90=9B=20Strict=20proxy-table=20f?= =?UTF-8?q?ield=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This worked in SQlite3, but threw when using PostgreSQL. --- src/ohmyapi/builtin/auth/models.py | 6 +++++- src/ohmyapi/builtin/auth/routes.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index e8894c1..2d58ce1 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -30,7 +30,11 @@ class User(Model): is_admin: bool = field.BooleanField(default=False) is_staff: bool = field.BooleanField(default=False) groups: field.ManyToManyRelation[Group] = field.ManyToManyField( - "ohmyapi_auth.Group", related_name="users", through="ohmyapi_auth_user_groups" + "ohmyapi_auth.Group", + related_name="users", + through="ohmyapi_auth.UserGroups", + forward_key="user_id", + backward_key="group_id", ) class Schema: diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index efda1b2..855126f 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -214,7 +214,7 @@ async def refresh_token(refresh_token: TokenRefresh = Body(...)): ) user_id = payload.get("sub") - user = await User.filter(id=user_id).first() + user = await User.get(id=user_id) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" From 8c2cf01f4037b98cecca4b50af8d5577677f0e0a Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 27 Oct 2025 10:47:05 +0100 Subject: [PATCH 165/172] =?UTF-8?q?=F0=9F=94=96=200.5.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cfb6e5d..8f03096 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.5.4" +version = "0.5.5" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index f8393e5..7bebe27 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.5.4" +__VERSION__ = "0.5.5" From 22ca522615a12ee4888fc0b2df59c269b9393d26 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 27 Oct 2025 11:03:12 +0100 Subject: [PATCH 166/172] =?UTF-8?q?=F0=9F=90=9B=20Catch=20invalid=20user?= =?UTF-8?q?=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/builtin/auth/routes.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 855126f..0104a4e 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request, st from fastapi.security import OAuth2, OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security.utils import get_authorization_scheme_param from pydantic import BaseModel +from tortoise.exceptions import DoesNotExist from ohmyapi.builtin.auth.models import Group, User @@ -214,7 +215,11 @@ async def refresh_token(refresh_token: TokenRefresh = Body(...)): ) user_id = payload.get("sub") - user = await User.get(id=user_id) + try: + user = await User.get(id=user_id) + except DoesNotExist: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" From 458ffc6b2cc1624cd9dc72def14ccce2ed0fcd00 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Mon, 27 Oct 2025 11:13:02 +0100 Subject: [PATCH 167/172] =?UTF-8?q?=F0=9F=94=96=200.5.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8f03096..094c428 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.5.5" +version = "0.5.6" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 7bebe27..dfda844 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.5.5" +__VERSION__ = "0.5.6" From 7163fe778e025e29c8fa8635b04f968af023f6d9 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Tue, 28 Oct 2025 14:37:45 +0100 Subject: [PATCH 168/172] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20ohmyapi?= =?UTF-8?q?=5Fauth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove Group and UserGroups (should be handled by dedicated app, if even) - enforce User.Schema() include-fields --- docs/apps.md | 2 +- src/ohmyapi/builtin/auth/models.py | 58 +++++++++--------------------- src/ohmyapi/builtin/auth/routes.py | 5 ++- 3 files changed, 19 insertions(+), 46 deletions(-) diff --git a/docs/apps.md b/docs/apps.md index fdcf5c4..dc70830 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -1,7 +1,7 @@ # Apps Apps are a way to group database models and API routes that contextually belong together. -For example, OhMyAPI comes bundled with an `auth` app that carries a `User` and `Group` model and provides API endpoints for JWT authentication. +For example, OhMyAPI comes bundled with an `auth` app that carries a `User` model and provides API endpoints for JWT authentication. Apps help organizing projects by isolating individual components (or "features") from one another. diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index 2d58ce1..b9785c7 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -1,27 +1,16 @@ -from functools import wraps -from secrets import token_bytes -from typing import List, Optional -from uuid import UUID - -from passlib.context import CryptContext -from tortoise.contrib.pydantic import pydantic_queryset_creator - from ohmyapi.db import Model, field, Q from ohmyapi.router import HTTPException from .utils import hmac_hash +from datetime import datetime +from passlib.context import CryptContext +from typing import Optional +from uuid import UUID + pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") -class Group(Model): - id: UUID = field.data.UUIDField(pk=True) - name: str = field.CharField(max_length=42, index=True) - - def __str__(self): - return self.name if self.name else "" - - class User(Model): id: UUID = field.data.UUIDField(pk=True) username: str = field.CharField(max_length=150, unique=True) @@ -29,20 +18,22 @@ class User(Model): password_hash: str = field.CharField(max_length=128) is_admin: bool = field.BooleanField(default=False) is_staff: bool = field.BooleanField(default=False) - groups: field.ManyToManyRelation[Group] = field.ManyToManyField( - "ohmyapi_auth.Group", - related_name="users", - through="ohmyapi_auth.UserGroups", - forward_key="user_id", - backward_key="group_id", - ) + created_at: datetime = field.DatetimeField(auto_now_add=True) + updated_at: datetime = field.DatetimeField(auto_now=True) class Schema: - exclude = ["password_hash", "email_hash"] + include = { + "id", + "username", + "is_admin", + "is_staff" + "created_at", + "updated_at", + } def __str__(self): fields = { - 'username': self.username if self.username else "-", + 'username': self.username, 'is_admin': 'y' if self.is_admin else 'n', 'is_staff': 'y' if self.is_staff else 'n', } @@ -67,20 +58,3 @@ class User(Model): if user and user.verify_password(password): return user return None - - -class UserGroups(Model): - user: field.ForeignKeyRelation[User] = field.ForeignKeyField( - "ohmyapi_auth.User", - related_name="user_groups", - index=True, - ) - group: field.ForeignKeyRelation[Group] = field.ForeignKeyField( - "ohmyapi_auth.Group", - related_name="group_users", - index=True, - ) - - class Meta: - table = "ohmyapi_auth_user_groups" - constraints = [("UNIQUE", ("user_id", "group_id"))] diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 0104a4e..3fa2651 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -8,7 +8,7 @@ from fastapi.security.utils import get_authorization_scheme_param from pydantic import BaseModel from tortoise.exceptions import DoesNotExist -from ohmyapi.builtin.auth.models import Group, User +from ohmyapi.builtin.auth.models import User import jwt import settings @@ -80,7 +80,7 @@ class TokenType(str, Enum): refresh = "refresh" -def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Claims: +def claims(token_type: TokenType, user: User = []) -> Claims: return Claims( type=token_type, sub=str(user.id), @@ -89,7 +89,6 @@ def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Claim is_admin=user.is_admin, is_staff=user.is_staff, ), - roles=[g.name for g in groups], exp="", ) From a9b88d87d6ccda84a8456e898aee49ef23cf8030 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Tue, 28 Oct 2025 14:40:29 +0100 Subject: [PATCH 169/172] =?UTF-8?q?=F0=9F=94=96=200.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 094c428..51aad8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.5.6" +version = "0.6.0" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index dfda844..73e56e4 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.5.6" +__VERSION__ = "0.6.0" From b588ebcf8a866c628bc348dfe82cfd60f5107611 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Tue, 28 Oct 2025 14:45:18 +0100 Subject: [PATCH 170/172] =?UTF-8?q?=F0=9F=9A=91=EF=B8=8F=20Remove=20roles?= =?UTF-8?q?=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- src/ohmyapi/builtin/auth/routes.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 51aad8b..7a7a95c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.6.0" +version = "0.6.1" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 73e56e4..d9520d7 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.6.0" +__VERSION__ = "0.6.1" diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index 3fa2651..fa2d4e7 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -53,7 +53,6 @@ class Claims(BaseModel): type: str sub: str user: ClaimsUser - roles: List[str] exp: str From 5f80a7a86f2de03397e6260477a6d93bcf4f7cae Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 5 Nov 2025 21:29:25 +0100 Subject: [PATCH 171/172] =?UTF-8?q?=F0=9F=90=9B=20Fix=20model-free=20apps?= =?UTF-8?q?=20and=20middleware=20installer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ohmyapi/core/runtime.py | 17 +++++++++++------ src/ohmyapi/middleware/cors.py | 10 +++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index 8fedc3c..dea8d78 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -342,9 +342,9 @@ class App: except ModuleNotFoundError: return - getter = getattr(mod, "get", None) - if getter is not None: - for middleware in getter(): + installer = getattr(mod, "install", None) + if installer is not None: + for middleware in installer(): self._middlewares.append(middleware) def __serialize_route(self, route): @@ -404,10 +404,15 @@ class App: """ Convenience method for serializing the runtime data. """ + # An app may come without any models + models = [] + if f"{self.name}.models" in self._models: + models = [ + f"{self.name}.{m.__name__}" + for m in self._models[f"{self.name}.models"] + ] return { - "models": [ - f"{self.name}.{m.__name__}" for m in self._models[f"{self.name}.models"] - ], + "models": models, "middlewares": self.__serialize_middleware(), "routes": self.__serialize_router(), } diff --git a/src/ohmyapi/middleware/cors.py b/src/ohmyapi/middleware/cors.py index 49852cd..77d49b6 100644 --- a/src/ohmyapi/middleware/cors.py +++ b/src/ohmyapi/middleware/cors.py @@ -15,12 +15,12 @@ CORS_CONFIG: Dict[str, Any] = getattr(settings, "MIDDLEWARE_CORS", {}) if not isinstance(CORS_CONFIG, dict): raise ValueError("MIDDLEWARE_CORS must be of type dict") -middleware = [ - (CORSMiddleware, { +middleware = ( + CORSMiddleware, + { "allow_origins": CORS_CONFIG.get("ALLOW_ORIGINS", DEFAULT_ORIGINS), "allow_credentials": CORS_CONFIG.get("ALLOW_CREDENTIALS", DEFAULT_CREDENTIALS), "allow_methods": CORS_CONFIG.get("ALLOW_METHODS", DEFAULT_METHODS), "allow_headers": CORS_CONFIG.get("ALLOW_HEADERS", DEFAULT_HEADERS), - }), -] - + } +) From b5691f3133e57b268d0d1b9f1e0bd8c9652e72d9 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Wed, 5 Nov 2025 21:37:34 +0100 Subject: [PATCH 172/172] =?UTF-8?q?=F0=9F=94=96=200.6.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/ohmyapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7a7a95c..6db719c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.6.1" +version = "0.6.2" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" license = "MIT" keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index d9520d7..4191a45 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.6.1" +__VERSION__ = "0.6.2"