Compare commits

..

No commits in common. "main" and "0.1.7" have entirely different histories.
main ... 0.1.7

36 changed files with 591 additions and 1889 deletions

278
README.md
View file

@ -1,41 +1,277 @@
# 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: Micro-Django, but API-first, 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 prject-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**
---
- 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
## Getting started
## Installation
**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
```
Go to: [http://localhost:8000/](http://localhost:8000/)
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.IntField(primary_key=True)
name = field.TextField()
created = field.DatetimeField(auto_now_add=True)
def __str__(self):
return self.name
class Event(Model):
id = field.IntField(primary_key=True)
name = field.TextField()
tournament = field.ForeignKeyField('tournament.Tournament', related_name='events')
participants = field.ManyToManyField('torunament.Team', related_name='events', through='event_team')
modified = field.DatetimeField(auto_now=True)
prize = field.DecimalField(max_digits=10, decimal_places=2, null=True)
def __str__(self):
return self.name
class Team(Model):
id = field.IntField(primary_key=True)
name = field.TextField()
def __str__(self):
return self.name
```
### API Routes
Next, create your endpoints in `tournament/routes.py`:
```python
from ohmyapi.router import APIRouter, HTTPException
from ohmyapi.db.exceptions import DoesNotExist
from .models import Tournament
router = APIRouter(prefix="/tournament")
@router.get("/")
async def list():
queryset = Tournament.all()
return await Tournament.Schema.many.from_queryset(queryset)
@router.get("/:id")
async def get(id: int):
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 [ <app> ] # 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 [ <app> ] # no app means all INSTALLED_APPS
```
Run your project:
```
ohmyapi serve
```
## Shell
Similar to Django, you can attach to an interactive shell with your project already loaded inside.
```
ohmyapi shell
```
## Authentication
A builtin auth app is available.
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-layer permissions.
```python
from ohmyapi.db import Manager
from typing import Callable
class TeamManager(Manager):
async def for_user(self, user):
return await self.filter(members=user).all()
class Team(Model):
[...]
class Meta:
manager = TeamManager()
```

View file

@ -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)

View file

@ -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 <name>`, 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
```

View file

@ -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)):
...
```

View file

@ -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
```

View file

@ -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 [ <app> ] # 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 [ <app> ] # no app indicates all INSTALLED_APPS
```

View file

@ -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]: <Restaurant>
```
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
```

View file

@ -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)

View file

@ -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()
```

View file

@ -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

608
poetry.lock generated
View file

@ -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"

View file

@ -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.7"
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<version>[^'\"]+)['\"]"
[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

View file

@ -1 +1,2 @@
__VERSION__ = "0.6.2"
from . import db

View file

@ -1 +1,4 @@
from . import models, permissions, routes
from . import models
from . import routes
from . import permissions

View file

@ -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.IntField(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.IntField(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

View file

@ -1,9 +1,6 @@
from .routes import (
get_current_user,
get_token,
maybe_authenticated,
require_admin,
require_authenticated,
require_group,
require_admin,
require_staff,
)

View file

@ -1,100 +1,30 @@
import time
from enum import Enum
from typing import Any, Dict, List, Optional
from typing import Dict
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
import jwt
from fastapi import APIRouter, Body, Depends, Header, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist
from ohmyapi.builtin.auth.models import User
import jwt
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,44 +35,24 @@ 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"
)
async def get_token(token: str = Depends(oauth2_scheme)) -> Dict:
"""Dependency: token introspection"""
payload = decode_token(token)
return payload
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
"""Dependency: extract user from access token."""
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"
)
username = payload.get("sub")
if username is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
user = await User.filter(id=user_id).first()
user = await User.filter(username=username).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:
@ -159,82 +69,68 @@ async def require_admin(current_user: User = Depends(get_current_user)) -> User:
async def require_staff(current_user: User = Depends(get_current_user)) -> User:
"""Ensure the current user is a staff member."""
if not current_user.is_admin and not current_user.is_staff:
if not current_user.is_staff:
raise HTTPException(403, "Staff privileges required")
return current_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({"sub": user.username, "type": "access"}, ACCESS_TOKEN_EXPIRE_SECONDS)
refresh_token = create_token({"sub": user.username, "type": "refresh"}, 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"
)
user_id = payload.get("sub")
try:
user = await User.get(id=user_id)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
username = payload.get("sub")
user = await User.filter(username=username).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({"sub": user.username, "type": "access"}, ACCESS_TOKEN_EXPIRE_SECONDS)
return {"access_token": new_access, "token_type": "bearer"}
@router.get("/introspect", response_model=Dict[str, Any])
async def introspect(token: Dict = Depends(get_token)):
return token
@router.get("/me", response_model=User.Schema())
async def me(user: User = Depends(get_current_user)):
@router.get("/me")
async def me(current_user: User = Depends(get_current_user)):
"""Return the currently authenticated user."""
return await User.Schema().from_tortoise_orm(user)
return {
"username": current_user.username,
"is_admin": current_user.is_admin,
"is_staff": current_user.is_staff,
}

View file

@ -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")

View file

@ -1 +0,0 @@
from . import models, routes

View file

@ -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

View file

@ -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")

View file

@ -1,45 +1,27 @@
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}"""
@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 +31,36 @@ 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 = {
"settings": project.settings,
"project": Path(project_path).resolve(),
}
from traitlets.config.loader import Config
c = Config()
c.TerminalIPythonApp.display_banner = True
c.TerminalInteractiveShell.banner2 = banner
c.TerminalInteractiveShell.banner1 = banner.format(**{
"project_name": f"{f'{project.settings.PROJECT_NAME} ' if getattr(project.settings, 'PROJECT_NAME', '') else ''}[{Path(project_path).resolve()}]",
})
c.TerminalInteractiveShell.banner2 = " "
start_ipython(argv=[], user_ns=shell_vars, config=c)
except ImportError:
typer.echo("IPython is not installed. Falling back to built-in Python shell.")
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,23 @@ 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(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())

View file

@ -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

View file

@ -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_<name>
- loads all INSTALLED_APPS into scope
- builds unified tortoise config for ORM runtime
- injects builtin apps as ohmyapi_<name>
- 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_<name>.
# We need this, because Tortoise app-names may not include dots `.`.
# Pre-register builtin apps as ohmyapi_<name>.
# 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(),
}

View file

@ -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 <name>/"""
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 <project_dir>/<name>/"""
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!")

View file

@ -1,44 +1,13 @@
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!
# Tags improve the UX of the OpenAPI docs at /docs.
#
router = APIRouter(prefix="/{{ app_name }}", tags=['{{ app_name }}'])
router = APIRouter(prefix="/{{ 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 ping():
return {
"project": "{{ project_name }}",
"app": "{{ app_name }}",
}

View file

@ -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"]

View file

@ -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

View file

@ -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]

View file

@ -2,6 +2,6 @@
PROJECT_NAME = "MyProject"
DATABASE_URL = "sqlite://db.sqlite3"
INSTALLED_APPS = [
#"ohmyapi_auth",
#'ohmyapi_auth',
]

View file

@ -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

View file

@ -1 +1,2 @@
from tortoise.exceptions import *

View file

@ -1 +0,0 @@
from tortoise.functions import *

View file

@ -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

View file

@ -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),
}
)

View file

@ -1,3 +1,2 @@
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException