From 6a90e4a44a7e143fa7dc3cc0dc68cfee23502dd6 Mon Sep 17 00:00:00 2001 From: Brian Wiborg Date: Sun, 28 Sep 2025 15:41:01 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20Introduce=20black=20&=20isort?= 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 -