diff --git a/README.md b/README.md index 871fd45..1db797e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # OhMyAPI -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. +OhMyAPI is a web-application scaffolding framework and management layer built around `FastAPI`, `TortoiseORM` and `Aerich` migrations. -> *Think: *"Django RestFramework"*, but less clunky and instead 100% async.* +> *Think: Django RestFramework, but less clunky and 100% async.* It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***! @@ -13,7 +12,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 (i.e. authentication and more) +- Various optional built-in apps you can hook into your project - Highly configurable and customizable - 100% async diff --git a/docs/apps.md b/docs/apps.md index dc70830..fdcf5c4 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` model and provides API endpoints for JWT authentication. +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. diff --git a/docs/auth.md b/docs/auth.md index d26c1fe..2526966 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -40,6 +40,6 @@ router = APIRouter() @router.get("/") -def get(user: auth.User = Depends(permissions.require_authenticated)): +def get(user: auth.User = Depends(permissions.required_authenticated)): ... ``` diff --git a/poetry.lock b/poetry.lock index 57e1e68..c30b765 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.0 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", "dev"] +groups = ["main"] 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", "dev"] +groups = ["main"] files = [ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, @@ -543,66 +543,19 @@ 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", "dev"] +groups = ["main"] 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", "dev"] +groups = ["main"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -611,18 +564,6 @@ 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" @@ -1083,22 +1024,6 @@ 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" @@ -1335,28 +1260,6 @@ 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" @@ -1585,7 +1488,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1701,7 +1604,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.13\""} +markers = {dev = "python_version == \"3.11\""} [[package]] name = "typing-inspection" @@ -1816,4 +1719,4 @@ auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"] [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "cc1604995d3b73ee302e63731dd300ea17c4d95d0cfc6c386626dd9a9f60e8a7" +content-hash = "3d301460081dada359d425d69feefc63c1e5135aa64b6f000f554bfc1231febd" diff --git a/pyproject.toml b/pyproject.toml index 6db719c..52f53b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.6.2" +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"] @@ -30,8 +30,6 @@ 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 4191a45..1b16679 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.6.2" +__VERSION__ = "0.3.2" diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index b9785c7..d68ba38 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -1,52 +1,39 @@ -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 functools import wraps +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.router import HTTPException + 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) + + 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: 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) - created_at: datetime = field.DatetimeField(auto_now_add=True) - updated_at: datetime = field.DatetimeField(auto_now=True) + groups: field.ManyToManyRelation[Group] = field.ManyToManyField( + "ohmyapi_auth.Group", related_name="users", through="usergroups" + ) class Schema: - include = { - "id", - "username", - "is_admin", - "is_staff" - "created_at", - "updated_at", - } - - def __str__(self): - fields = { - 'username': self.username, - '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()]) + exclude = ("password_hash",) 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(new_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) diff --git a/src/ohmyapi/builtin/auth/permissions.py b/src/ohmyapi/builtin/auth/permissions.py index e7be37e..578dbd3 100644 --- a/src/ohmyapi/builtin/auth/permissions.py +++ b/src/ohmyapi/builtin/auth/permissions.py @@ -1,7 +1,6 @@ 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 fa2d4e7..8a81389 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -1,17 +1,14 @@ import time from enum import Enum -from typing import Any, Dict, List, Optional - -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 tortoise.exceptions import DoesNotExist - -from ohmyapi.builtin.auth.models import User +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 Group, User # Router router = APIRouter(prefix="/auth", tags=["Auth"]) @@ -26,25 +23,12 @@ 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): username: str + email: str is_admin: bool is_staff: bool @@ -53,6 +37,7 @@ class Claims(BaseModel): type: str sub: str user: ClaimsUser + roles: List[str] exp: str @@ -79,15 +64,17 @@ class TokenType(str, Enum): refresh = "refresh" -def claims(token_type: TokenType, user: User = []) -> Claims: +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="", ) @@ -137,12 +124,6 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: return 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) - - async def require_authenticated(current_user: User = Depends(get_current_user)) -> User: """Ensure the current user is an admin.""" if not current_user: @@ -199,25 +180,17 @@ 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: TokenRefresh = Body(...)): +async def refresh_token(refresh_token: str): """Exchange refresh token for new access token.""" - payload = decode_token(refresh_token.refresh_token) + payload = decode_token(refresh_token) if payload.get("type") != "refresh": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token" ) user_id = payload.get("sub") - try: - user = await User.get(id=user_id) - except DoesNotExist: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - + user = await User.filter(id=user_id).first() if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" @@ -226,7 +199,7 @@ async def refresh_token(refresh_token: TokenRefresh = Body(...)): new_access = create_token( claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS ) - return AccessToken(token_type="bearer", access_token=new_access) + return AccessToken(token_type="bearer", access_token=access_token) @router.get("/introspect", response_model=Dict[str, Any]) diff --git a/src/ohmyapi/builtin/auth/utils.py b/src/ohmyapi/builtin/auth/utils.py deleted file mode 100644 index e54a5da..0000000 --- a/src/ohmyapi/builtin/auth/utils.py +++ /dev/null @@ -1,17 +0,0 @@ -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") - diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index fd41084..6728753 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -9,9 +9,6 @@ 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." @@ -22,24 +19,18 @@ 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() @@ -49,8 +40,9 @@ def serve(root: str = ".", host="127.0.0.1", port=8000): """ project_path = Path(root) project = runtime.Project(project_path) - app_instance = project.configure_app(project.app()) - uvicorn.run(app_instance, host=host, port=int(port), reload=False, log_config=None) + app_instance = project.app() + app_instance = project.configure_app(app_instance) + uvicorn.run(app_instance, host=host, port=int(port), reload=False) @app.command() @@ -83,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 immediately available + # Prepare shell vars that are to be directly available shell_vars = {"p": project} try: @@ -115,8 +107,6 @@ 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() @@ -131,7 +121,6 @@ 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() @@ -161,17 +150,9 @@ def createsuperuser(root: str = "."): if password1 != password2: print("Passwords didn't match!") user = ohmyapi_auth.models.User( - username=username, is_staff=True, is_admin=True + email=email, username=username, is_staff=True, is_admin=True ) - user.set_email(email) user.set_password(password1) - - 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()) + asyncio.run(project.init_orm()) + asyncio.run(user.save()) + asyncio.run(project.close_orm()) diff --git a/src/ohmyapi/core/logging.py b/src/ohmyapi/core/logging.py deleted file mode 100644 index ca05220..0000000 --- a/src/ohmyapi/core/logging.py +++ /dev/null @@ -1,38 +0,0 @@ -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 dea8d78..423f3c5 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, Tuple, Type +from typing import Any, Dict, Generator, List, Optional, Type import click from aerich import Command as AerichCommand @@ -15,11 +15,8 @@ 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: """ @@ -32,7 +29,6 @@ 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" @@ -40,14 +36,6 @@ 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") @@ -65,6 +53,14 @@ 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) @@ -92,14 +88,10 @@ class Project: def configure_app(self, app: FastAPI) -> FastAPI: """ - Attach project middlewares and routes and event handlers to given - FastAPI instance. + Attach project routes and event handlers to given FastAPI instance. """ - app.router.prefix = getattr(self.settings, "API_PREFIX", "") - # Attach project middlewares and routes. + # Attach project 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) @@ -129,7 +121,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, @@ -154,10 +146,7 @@ class Project: tortoise_cfg = self.build_tortoise_config(db_url=db_url) # Prevent leaking other app's models to Aerich. - 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": []}} + 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") @@ -240,16 +229,11 @@ 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) - 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") def __repr__(self): return json.dumps(self.dict(), indent=2) @@ -267,6 +251,7 @@ class App: try: importlib.import_module(mod_name) except ModuleNotFoundError: + print(f"no models detected: {mod_name}") return # Acoid duplicates. @@ -284,11 +269,9 @@ 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__"): @@ -309,7 +292,8 @@ class App: # An app may come without any routes. try: importlib.import_module(mod_name) - except ModuleNotFoundError: + except ModuleNotFound: + print(f"no routes detected: {mod_name}") return # Avoid duplicates. @@ -324,7 +308,6 @@ 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__"): @@ -336,17 +319,6 @@ class App: # Walk the walk. walk(mod_name) - def __load_middlewares(self, mod_name): - try: - mod = importlib.import_module(mod_name) - except ModuleNotFoundError: - return - - installer = getattr(mod, "install", None) - if installer is not None: - for middleware in installer(): - self._middlewares.append(middleware) - def __serialize_route(self, route): """ Convert APIRoute to JSON-serializable dict. @@ -360,12 +332,6 @@ 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]: """ @@ -375,7 +341,9 @@ class App: for module in self._models: for model in self._models[module]: out.append(model) - return out + return { + module: out, + } @property def routers(self): @@ -395,24 +363,13 @@ 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. """ - # 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": models, - "middlewares": self.__serialize_middleware(), + "models": [ + f"{self.name}.{m.__name__}" for m in self.models[f"{self.name}.models"] + ], "routes": self.__serialize_router(), } diff --git a/src/ohmyapi/core/scaffolding.py b/src/ohmyapi/core/scaffolding.py index 2dddeaf..c637b5a 100644 --- a/src/ohmyapi/core/scaffolding.py +++ b/src/ohmyapi/core/scaffolding.py @@ -2,16 +2,12 @@ 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.""" @@ -64,7 +60,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(): - logger.error(f"no templates found under: {dir_name}") + print(f"no templates found under: {dir_name}") return for root, _, files in template_dir.walk(): @@ -73,15 +69,19 @@ def copy_static(dir_name: str, target_dir: Path): src = root_path / file dst = target_dir / file if dst.exists(): - logger.warning(f"⛔ File exists, skipping: {dst}") + print(f"⛔ File exists, skipping: {dst}") continue - shutil.copy(src, dst) + 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() 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,3 +94,5 @@ 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!") diff --git a/src/ohmyapi/core/templates/docker/Dockerfile b/src/ohmyapi/core/templates/docker/Dockerfile index 484a371..28a8f32 100644 --- a/src/ohmyapi/core/templates/docker/Dockerfile +++ b/src/ohmyapi/core/templates/docker/Dockerfile @@ -21,6 +21,7 @@ ENV PATH="$POETRY_HOME/bin:$PATH" WORKDIR /app COPY pyproject.toml poetry.lock* /app/ +RUN poetry lock RUN poetry install COPY . /app diff --git a/src/ohmyapi/core/templates/docker/docker-compose.yml b/src/ohmyapi/core/templates/docker/docker-compose.yml index 9e03d32..6691e61 100644 --- a/src/ohmyapi/core/templates/docker/docker-compose.yml +++ b/src/ohmyapi/core/templates/docker/docker-compose.yml @@ -1,14 +1,4 @@ services: - # db: - # image: postgres:latest - # restart: unless-stopped - # environment: - # POSTGRES_DB: ohmyapi - # POSTGRES_USER: ohmyapi - # POSTGRES_PASSWORD: ohmyapi - # ports: - # - 5432:5432 - app: build: context: . diff --git a/src/ohmyapi/core/templates/project/pyproject.toml.j2 b/src/ohmyapi/core/templates/project/pyproject.toml.j2 index f877761..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 = [ -# "asyncpg" + "ohmyapi" ] [tool.poetry.group.dev.dependencies] diff --git a/src/ohmyapi/db/__init__.py b/src/ohmyapi/db/__init__.py index 0a634a9..e526ef5 100644 --- a/src/ohmyapi/db/__init__.py +++ b/src/ohmyapi/db/__init__.py @@ -1,6 +1,4 @@ -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, diff --git a/src/ohmyapi/db/functions.py b/src/ohmyapi/db/functions.py deleted file mode 100644 index c5cf5de..0000000 --- a/src/ohmyapi/db/functions.py +++ /dev/null @@ -1 +0,0 @@ -from tortoise.functions import * diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index 7ab918f..929ff60 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -31,18 +31,6 @@ 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) diff --git a/src/ohmyapi/middleware/cors.py b/src/ohmyapi/middleware/cors.py deleted file mode 100644 index 77d49b6..0000000 --- a/src/ohmyapi/middleware/cors.py +++ /dev/null @@ -1,26 +0,0 @@ -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), - } -)