diff --git a/README.md b/README.md index 871fd45..5cc6ec8 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,314 @@ # 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. +> Think: Django RestFramework, but less clunky and 100% async. -> *Think: *"Django RestFramework"*, but less clunky and instead 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***, extremely ***fun to use*** and comes with ***batteries included***! +It is ***blazingly fast***, ***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 (i.e. authentication and more) +- 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 CLI tooling (`startproject`, `startapp`, `shell`, `serve`, etc) +- Various optional builtin apps you can hook into your project - Highly configurable and customizable - 100% async -**Goals** +OhMyAPI aims to: -- 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 +- 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 -## Installation +--- + +## Getting started + +**Creating a Project** ``` -pipx install ohmyapi +pip install ohmyapi +ohmyapi startproject myproject +cd myproject ``` - -## Docs - -See `docs/` or: +This will create the following directory structure: ``` -poetry run mkdocs serve +myproject/ + - pyproject.toml + - README.md + - settings.py +``` + +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 + + +class Tournament(Model): + id = field.data.UUIDField(primary_key=True) + name = field.TextField() + created = 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) + + def __str__(self): + return self.name + + +class Team(Model): + id = field.data.UUIDField(primary_key=True) + name = 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 +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. +# 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.many.from_queryset(queryset) + + +@router.get("/:id") +async def get(id: str): + try: + queryset = Tournament.get(pk=id) + return await Tournament.Schema.one(queryset) + except DoesNotExist: + raise HTTPException(status_code=404, detail="item 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 +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: + +``` +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.models import User +from ohmyapi_auth import ( + models as auth, + permissions, +) + +from .models import Tournament + +router = APIRouter(prefix="/tournament") + + +@router.get("/") +async def list(user: auth.User = Depends(permissions.require_authenticated)): + queryset = Tournament.all() + return await Tournament.Schema.many.from_queryset(queryset) + + +... +``` + +### Model-Level Permissions + +Use Tortoise's `Manager` to implement model-level permissions. + +```python +from ohmyapi.db import Manager + + +class TeamManager(Manager): + async def for_user(self, user: ohmyapi_auth.models.User): + return await self.filter(members=user).all() + + +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]: + ``` -Go to: [http://localhost:8000/](http://localhost:8000/) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index c553ce9..0000000 --- a/docs/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# 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/apps.md b/docs/apps.md deleted file mode 100644 index dc70830..0000000 --- a/docs/apps.md +++ /dev/null @@ -1,34 +0,0 @@ -# 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. - -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/auth.md b/docs/auth.md deleted file mode 100644 index d26c1fe..0000000 --- a/docs/auth.md +++ /dev/null @@ -1,45 +0,0 @@ -# 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.require_authenticated)): - ... -``` diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 453e6d7..0000000 --- a/docs/index.md +++ /dev/null @@ -1,30 +0,0 @@ -# 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 deleted file mode 100644 index e8052e6..0000000 --- a/docs/migrations.md +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index 47b5708..0000000 --- a/docs/models.md +++ /dev/null @@ -1,102 +0,0 @@ -# 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 deleted file mode 100644 index 7fbcdf1..0000000 --- a/docs/projects.md +++ /dev/null @@ -1,34 +0,0 @@ -# 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 deleted file mode 100644 index 185f219..0000000 --- a/docs/routes.md +++ /dev/null @@ -1,68 +0,0 @@ -# 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 deleted file mode 100644 index 20db47a..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,11 +0,0 @@ -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 - - Builtin: - - Auth / Permissions: auth.md diff --git a/poetry.lock b/poetry.lock index 57e1e68..2e46d46 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"}, @@ -71,7 +71,6 @@ 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)"] @@ -164,59 +163,13 @@ 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" 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"}, @@ -414,7 +367,7 @@ version = "8.3.0" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, @@ -430,11 +383,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" @@ -519,90 +472,25 @@ 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" 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,28 +499,16 @@ 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" +version = "9.5.0" description = "IPython: Productive Interactive Computing" optional = false python-versions = ">=3.11" groups = ["main", "dev"] files = [ - {file = "ipython-9.6.0-py3-none-any.whl", hash = "sha256:5f77efafc886d2f023442479b8149e7d86547ad0a979e9da9f045d252f648196"}, - {file = "ipython-9.6.0.tar.gz", hash = "sha256:5603d6d5d356378be5043e69441a072b50a5b33b4503428c77b04cb8ce7bc731"}, + {file = "ipython-9.5.0-py3-none-any.whl", hash = "sha256:88369ffa1d5817d609120daa523a6da06d02518e582347c29f8451732a9c5e72"}, + {file = "ipython-9.5.0.tar.gz", hash = "sha256:129c44b941fe6d9b82d36fc7a7c18127ddb1d6f02f78f867f402e2e3adde3113"}, ] [package.dependencies] @@ -646,15 +522,14 @@ 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]"] black = ["black"] -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)"] +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"] test = ["packaging", "pytest", "pytest-asyncio", "testpath"] -test-extra = ["curio", "ipykernel", "ipython[matplotlib]", "ipython[test]", "jupyter_ai", "nbclient", "nbformat", "numpy (>=1.25)", "pandas (>2.0)", "trio"] +test-extra = ["curio", "ipykernel", "ipython[test]", "jupyter_ai", "matplotlib (!=3.2.0)", "nbclient", "nbformat", "numpy (>=1.23)", "pandas", "trio"] [[package]] name = "ipython-pygments-lexers" @@ -684,22 +559,6 @@ files = [ {file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, ] -[[package]] -name = "isort" -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.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784"}, - {file = "isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481"}, -] - -[package.extras] -colors = ["colorama"] -plugins = ["setuptools"] - [[package]] name = "jedi" version = "0.19.2" @@ -726,7 +585,7 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -738,22 +597,6 @@ 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" @@ -780,101 +623,73 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] [[package]] name = "markupsafe" -version = "3.0.3" +version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ - {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"}, + {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"}, ] [[package]] @@ -904,78 +719,6 @@ 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" -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" @@ -992,18 +735,6 @@ 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" @@ -1038,18 +769,6 @@ 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" @@ -1066,39 +785,6 @@ 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 = "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,43 +1021,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" -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" @@ -1384,21 +1033,6 @@ 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" @@ -1417,7 +1051,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] 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"}, @@ -1487,21 +1121,6 @@ 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" @@ -1567,25 +1186,13 @@ 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" 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"}, @@ -1625,7 +1232,6 @@ 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"] @@ -1696,23 +1302,22 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] 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\""} [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.1" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, ] [package.dependencies] @@ -1755,49 +1360,6 @@ 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" @@ -1815,5 +1377,5 @@ auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"] [metadata] lock-version = "2.1" -python-versions = ">=3.11" -content-hash = "cc1604995d3b73ee302e63731dd300ea17c4d95d0cfc6c386626dd9a9f60e8a7" +python-versions = ">=3.13" +content-hash = "16ae1b48820c723ca784e71a454fb4b686c94fc9f01fa81b086df5dcaf512074" diff --git a/pyproject.toml b/pyproject.toml index 6db719c..06906b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,14 @@ [project] name = "ohmyapi" -version = "0.6.2" -description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" +version = "0.1.11" +description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." license = "MIT" -keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] +keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] authors = [ {name = "Brian Wiborg", email = "me@brianwib.org"} ] readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.13" dependencies = [ "typer >=0.19.1,<0.20.0", @@ -27,11 +27,6 @@ dependencies = [ [tool.poetry.group.dev.dependencies] 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"] @@ -41,39 +36,3 @@ packages = [ { include = "ohmyapi", from = "src" } ] [project.scripts] 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'] -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 4191a45..72949ab 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1,2 @@ -__VERSION__ = "0.6.2" +from . import db + diff --git a/src/ohmyapi/builtin/auth/__init__.py b/src/ohmyapi/builtin/auth/__init__.py index 8c2daf9..bd581f3 100644 --- a/src/ohmyapi/builtin/auth/__init__.py +++ b/src/ohmyapi/builtin/auth/__init__.py @@ -1 +1,4 @@ -from . import models, permissions, routes +from . import models +from . import routes +from . import permissions + diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index b9785c7..8b2d720 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -1,52 +1,34 @@ -from ohmyapi.db import Model, field, Q -from ohmyapi.router import HTTPException - -from .utils import hmac_hash - -from datetime import datetime +from typing import Optional, List +from ohmyapi.db import Model, field from passlib.context import CryptContext -from typing import Optional -from uuid import UUID +from tortoise.contrib.pydantic import pydantic_queryset_creator pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") +class Group(Model): + id = field.data.UUIDField(pk=True) + name = field.CharField(max_length=42, index=True) + + class User(Model): - id: UUID = field.data.UUIDField(pk=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) + 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") + class Schema: - include = { - "id", - "username", - "is_admin", - "is_staff" - "created_at", - "updated_at", - } + exclude = 'password_hash', - 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()]) 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) @@ -58,3 +40,4 @@ class User(Model): if user and user.verify_password(password): return user return None + diff --git a/src/ohmyapi/builtin/auth/permissions.py b/src/ohmyapi/builtin/auth/permissions.py index e7be37e..27093b2 100644 --- a/src/ohmyapi/builtin/auth/permissions.py +++ b/src/ohmyapi/builtin/auth/permissions.py @@ -1,9 +1,8 @@ from .routes import ( - get_current_user, get_token, - maybe_authenticated, - require_admin, + get_current_user, require_authenticated, - require_group, + require_admin, require_staff, + require_group, ) diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index fa2d4e7..e1cb3cc 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -1,100 +1,31 @@ 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 +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 # 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") 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 -) - -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 - +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") -oauth2_optional_scheme = OptionalOAuth2PasswordBearer(tokenUrl="/auth/login") -class ClaimsUser(BaseModel): - username: str - is_admin: bool - is_staff: bool - - -class Claims(BaseModel): - type: str - sub: str - user: ClaimsUser - 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 = []) -> Claims: - return Claims( - type=token_type, - sub=str(user.id), - user=ClaimsUser( - username=user.username, - is_admin=user.is_admin, - is_staff=user.is_staff, - ), - exp="", - ) - - -def create_token(claims: Claims, expires_in: int) -> str: - to_encode = claims.model_dump() - to_encode["exp"] = int(time.time()) + expires_in +def create_token(data: dict, expires_in: int) -> str: + to_encode = data.copy() + to_encode.update({"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") @@ -105,15 +36,30 @@ 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]: + 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) @@ -125,24 +71,14 @@ 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 -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: @@ -165,76 +101,64 @@ 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 -@router.post("/login", response_model=RefreshToken) +class LoginRequest(BaseModel): + username: str + password: str + + +@router.post("/login") 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 RefreshToken( - token_type="bearer", - access_token=access_token, - refresh_token=refresh_token, - ) + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer" + } -class TokenRefresh(BaseModel): - refresh_token: str - - -@router.post("/refresh", response_model=AccessToken) -async def refresh_token(refresh_token: TokenRefresh = Body(...)): +@router.post("/refresh") +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" - ) + 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" - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") - new_access = create_token( - claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS - ) - return AccessToken(token_type="bearer", access_token=new_access) + new_access = create_token(claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS) + return {"access_token": new_access, "token_type": "bearer"} -@router.get("/introspect", response_model=Dict[str, Any]) +@router.get("/introspect") async def introspect(token: Dict = Depends(get_token)): return token -@router.get("/me", response_model=User.Schema()) +@router.get("/me") async def me(user: User = Depends(get_current_user)): """Return the currently authenticated user.""" - return await User.Schema().from_tortoise_orm(user) + return user + 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/builtin/demo/__init__.py b/src/ohmyapi/builtin/demo/__init__.py deleted file mode 100644 index 49ca6db..0000000 --- a/src/ohmyapi/builtin/demo/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import models, routes diff --git a/src/ohmyapi/builtin/demo/models.py b/src/ohmyapi/builtin/demo/models.py deleted file mode 100644 index 8f940c1..0000000 --- a/src/ohmyapi/builtin/demo/models.py +++ /dev/null @@ -1,51 +0,0 @@ -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) - 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 deleted file mode 100644 index f79bfed..0000000 --- a/src/ohmyapi/builtin/demo/routes.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import List - -from ohmyapi.db.exceptions import DoesNotExist -from ohmyapi.router import APIRouter, HTTPException, HTTPStatus - -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="/tournament") - - -@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()) - - -@router.post("/", tags=["tournament"], status_code=HTTPStatus.CREATED) -async def post(tournament: models.Tournament.Schema(readonly=True)): - """Create tournament.""" - return await models.Tournament.Schema().from_queryset( - models.Tournament.create(**tournament.model_dump()) - ) - - -@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)) - - -@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().from_queryset( - models.Tournament.update(**tournament.model_dump()) - ) - - -@router.delete("/{id}", status_code=HTTPStatus.ACCEPTED, 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") diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index fd41084..6479fe8 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -1,45 +1,29 @@ import asyncio -import atexit import importlib import sys -from getpass import getpass -from pathlib import Path - import typer import uvicorn -from ohmyapi.core import runtime, scaffolding -from ohmyapi.core.logging import setup_logging +from getpass import getpass +from ohmyapi.core import scaffolding, runtime +from pathlib import Path -logger = setup_logging() - -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.") +banner = """OhMyAPI Shell | Project: {project_name} +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) - 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.""" + """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,58 +33,34 @@ 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() + uvicorn.run(app_instance, host=host, port=int(port), reload=False) @app.command() def shell(root: str = "."): - """An interactive shell with your loaded project runtime.""" + """ + Launch an interactive IPython shell with the project and apps loaded. + """ project_path = Path(root).resolve() project = runtime.Project(project_path) - 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}") - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete(init_and_cleanup()) - - # Prepare shell vars that are to be immediately 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 - + c.TerminalInteractiveShell.banner2 = banner.format(**{ + "project_name": f"{f'{project.settings.PROJECT_NAME} ' if getattr(project.settings, 'PROJECT_NAME', '') else ''}[{Path(project_path).resolve()}]", + }) 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=shell_vars, banner=banner) - finally: - loop.run_until_complete(cleanup()) + code.interact(local={"settings": project.settings}) @app.command() @@ -115,8 +75,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,47 +89,24 @@ 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() 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"): - 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" - while password1 != password2: - password1 = getpass("Password: ") - password2 = getpass("Repeat Password: ") - if password1 != password2: - print("Passwords didn't match!") - user = ohmyapi_auth.models.User( - username=username, is_staff=True, is_admin=True - ) - user.set_email(email) - user.set_password(password1) + password = getpass("Password: ") + 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()) + 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()) 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..e1ed344 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -1,38 +1,30 @@ # ohmyapi/core/runtime.py +import copy import importlib 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, Tuple, Type +from typing import Dict, 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 ohmyapi.core.logging import setup_logging +from fastapi import FastAPI, APIRouter from ohmyapi.db.model import Model -logger = setup_logging() - class Project: """ Project runtime loader + Tortoise/Aerich integration. - - aliases builtin apps as ohmyapi_ - - loads all INSTALLED_APPS into scope - - builds unified tortoise config for ORM runtime + - injects builtin apps as ohmyapi_ + - builds unified tortoise config for runtime - provides makemigrations/migrate methods using Aerich Command API """ 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,16 +32,8 @@ 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 `.`. + # Pre-register builtin apps as ohmyapi_. + # This makes all builtin apps easily loadable via f"ohmyapi_{app_name}". spec = importlib.util.find_spec("ohmyapi.builtin") if spec and spec.submodule_search_locations: for _, modname, _ in pkgutil.iter_modules(spec.submodule_search_locations): @@ -59,12 +43,16 @@ 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 + # 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) @@ -76,39 +64,23 @@ class Project: def is_app_installed(self, name: str) -> bool: return name in getattr(self.settings, "INSTALLED_APPS", []) - def app(self, - docs_url: str = "/docs", - ) -> FastAPI: + def app(self, generate_schemas: bool = False) -> FastAPI: """ - Create and return a FastAPI app. + Create a FastAPI app, attach all APIRouters from registered apps, + and register ORM lifecycle event handlers. """ - 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__, - ) + app = FastAPI(title=getattr(self.settings, "PROJECT_NAME", "OhMyAPI Project")) - def configure_app(self, app: FastAPI) -> FastAPI: - """ - 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. + # Attach routers from apps 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) + if app_def.router: + app.include_router(app_def.router) - # Initialize ORM on startup + # Startup / shutdown events @app.on_event("startup") async def _startup(): - await self.init_orm(generate_schemas=False) + await self.init_orm(generate_schemas=generate_schemas) - # Close ORM on shutdown @app.on_event("shutdown") async def _shutdown(): await self.close_orm() @@ -129,43 +101,34 @@ class Project: } for app_name, app in self._apps.items(): - modules = list(app._models.keys()) + 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: - """ - 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") + 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") # 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. - 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 = copy.deepcopy(self.build_tortoise_config(db_url=db_url)) # Append aerich.models to the models list of the target app only - tortoise_cfg["apps"][app_label]["models"].append("aerich.models") + if flat_label in tortoise_cfg["apps"]: + tortoise_cfg["apps"][flat_label]["models"].append("aerich.models") return AerichCommand( tortoise_config=tortoise_cfg, - app=app_label, - location=str(self.migrations_dir), + app=flat_label, + location=str(self.migrations_dir) ) # --- ORM lifecycle --- @@ -180,9 +143,7 @@ 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() @@ -196,9 +157,7 @@ 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: @@ -233,186 +192,56 @@ class App: self.project = project self.name = name - # 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] = {} + # The list of module paths (e.g. "ohmyapi_auth.models") for Tortoise and Aerich + self.model_modules: List[str] = [] - # 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]]] = [] + # The APIRouter + self.router: Optional[APIRouter] = None # Import the app, so its __init__.py runs. - mod: ModuleType = importlib.import_module(name) + importlib.import_module(self.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") + # 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") + router = getattr(routes_mod, "router", None) + if isinstance(router, APIRouter): + self.router = router + except ModuleNotFoundError: + pass def __repr__(self): - return json.dumps(self.dict(), indent=2) + 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 def __str__(self): 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: - return - - # Acoid duplicates. - visited: set[str] = set() - - 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__ - ): - # 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__"): - for _, subname, _ in pkgutil.iter_modules( - mod.__path__, mod.__name__ + "." - ): - walk(subname) - - # Walk the walk. - walk(mod_name) - - 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 ModuleNotFoundError: - return - - # Avoid duplicates. - visited: set[str] = set() - - 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__: - 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__"): - for _, subname, _ in pkgutil.iter_modules( - mod.__path__, mod.__name__ + "." - ): - walk(subname) - - # 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. - """ - return { - "path": route.path, - "method": list(route.methods)[0], - "endpoint": f"{route.endpoint.__module__}.{route.endpoint.__name__}", - } - - 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]: - """ - Return a list of all loaded models. - """ - out = [] - for module in self._models: - for model in self._models[module]: - out.append(model) - return out - - @property - def routers(self): - out = [] - for routes_mod in self._routers: - for r in self._routers[routes_mod]: - out.append(r) - return out + def models(self) -> List[Model]: + models: List[Model] = [] + 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 @property def routes(self): - """ - Return an APIRouter with all loaded routes. - """ - out = [] - for r in self.routers: - out.extend(r.routes) - return out + return self.router.routes - @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(), - "routes": self.__serialize_router(), - } diff --git a/src/ohmyapi/core/scaffolding.py b/src/ohmyapi/core/scaffolding.py index 2dddeaf..b725f0e 100644 --- a/src/ohmyapi/core/scaffolding.py +++ b/src/ohmyapi/core/scaffolding.py @@ -1,54 +1,37 @@ +import os 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.""" - 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) + os.makedirs(output_path.parent, 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. """ template_dir = TEMPLATE_DIR / template_subdir - for root, _, files in template_dir.walk(): + for root, _, files in os.walk(template_dir): 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: @@ -59,38 +42,20 @@ 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(): - logger.error(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(): - logger.warning(f"⛔ File exists, skipping: {dst}") - continue - 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) + os.makedirs(target_dir, 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): """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, - ) + os.makedirs(target_dir, 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!") + diff --git a/src/ohmyapi/core/templates/app/routes.py.j2 b/src/ohmyapi/core/templates/app/routes.py.j2 index 444f251..1e4d792 100644 --- a/src/ohmyapi/core/templates/app/routes.py.j2 +++ b/src/ohmyapi/core/templates/app/routes.py.j2 @@ -1,44 +1,17 @@ -from ohmyapi.router import APIRouter, HTTPException, HTTPStatus +from ohmyapi.router import APIRouter from . import models -from typing import List - -# 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! +# 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("/", response_model=List) -async def list(): - """List all ...""" - return [] - - -@router.post("/", status_code=HTTPStatus.CREATED) -async def post(): - """Create ...""" - raise HTTPException(status_code=HTTPStatus.IM_A_TEAPOT) - - -@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}", status_code=HTTPStatus.ACCEPTED) -async def delete(id: str): - return HTTPException(status_code=HTTPStatus.ACCEPTED) +@router.get("/") +def hello_world(): + return { + "project": "{{ project_name }}", + "app": "{{ app_name }}", + } diff --git a/src/ohmyapi/core/templates/docker/Dockerfile b/src/ohmyapi/core/templates/docker/Dockerfile deleted file mode 100644 index 484a371..0000000 --- a/src/ohmyapi/core/templates/docker/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -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 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 deleted file mode 100644 index 9e03d32..0000000 --- a/src/ohmyapi/core/templates/docker/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - # db: - # image: postgres:latest - # restart: unless-stopped - # environment: - # POSTGRES_DB: ohmyapi - # POSTGRES_USER: ohmyapi - # POSTGRES_PASSWORD: ohmyapi - # ports: - # - 5432:5432 - - 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 f877761..7aa7186 100644 --- a/src/ohmyapi/core/templates/project/pyproject.toml.j2 +++ b/src/ohmyapi/core/templates/project/pyproject.toml.j2 @@ -10,7 +10,18 @@ readme = "README.md" license = { text = "MIT" } dependencies = [ -# "asyncpg" + "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] diff --git a/src/ohmyapi/core/templates/project/settings.py.j2 b/src/ohmyapi/core/templates/project/settings.py.j2 index edc9580..6d65f01 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', ] diff --git a/src/ohmyapi/db/__init__.py b/src/ohmyapi/db/__init__.py index 0a634a9..3d40706 100644 --- a/src/ohmyapi/db/__init__.py +++ b/src/ohmyapi/db/__init__.py @@ -1,12 +1,3 @@ -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, - post_save, - pre_delete, - pre_save, -) - from .model import Model, field +from tortoise.manager import Manager + diff --git a/src/ohmyapi/db/exceptions.py b/src/ohmyapi/db/exceptions.py index 79466de..19d761c 100644 --- a/src/ohmyapi/db/exceptions.py +++ b/src/ohmyapi/db/exceptions.py @@ -1 +1,2 @@ from tortoise.exceptions import * + 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..2bbc557 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -1,87 +1,39 @@ -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), - ] - ), - # 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) +from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator 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 + def __new__(cls, name, bases, attrs): + new_cls = super().__new__(cls, name, bases, attrs) - 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) - - # Let Tortoise's Metaclass do it's thing. - new_cls = super().__new__(mcls, name, bases, attrs) + schema_opts = getattr(new_cls, "Schema", None) class BoundSchema: - def __call__(self, readonly: bool = False): - return self.get(readonly) - @property - def model(self): - """Return a Pydantic model class for serializing results.""" + def one(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__}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", + name=f"{new_cls.__name__}SchemaOne", include=include, exclude=exclude, exclude_readonly=True, ) - def get(self, readonly: bool = False): - if readonly: - return self.readonly - return self.model + @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 @@ -91,3 +43,4 @@ class Model(TortoiseModel, metaclass=ModelMeta): class Schema: include = None exclude = 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), - } -) diff --git a/src/ohmyapi/router.py b/src/ohmyapi/router.py index b903e81..f7d5860 100644 --- a/src/ohmyapi/router.py +++ b/src/ohmyapi/router.py @@ -1,3 +1,2 @@ -from http import HTTPStatus - from fastapi import APIRouter, Depends, HTTPException +