Compare commits

...

172 commits
0.1.5 ... main

Author SHA1 Message Date
Brian Wiborg
b5691f3133
🔖 0.6.2 2025-11-05 21:37:34 +01:00
Brian Wiborg
5f80a7a86f
🐛 Fix model-free apps and middleware installer 2025-11-05 21:29:25 +01:00
Brian Wiborg
b588ebcf8a
🚑️ Remove roles claim 2025-10-28 14:45:18 +01:00
Brian Wiborg
a9b88d87d6
🔖 0.6.0 2025-10-28 14:40:29 +01:00
Brian Wiborg
7163fe778e
♻️ Refactor ohmyapi_auth
- remove Group and UserGroups
  (should be handled by dedicated app, if even)
- enforce User.Schema() include-fields
2025-10-28 14:39:42 +01:00
Brian Wiborg
458ffc6b2c
🔖 0.5.6 2025-10-27 11:13:02 +01:00
Brian Wiborg
22ca522615
🐛 Catch invalid user refresh 2025-10-27 11:03:12 +01:00
Brian Wiborg
8c2cf01f40
🔖 0.5.5 2025-10-27 10:47:05 +01:00
Brian Wiborg
9d2e284da3
🐛 Strict proxy-table field naming
This worked in SQlite3, but threw when using PostgreSQL.
2025-10-27 10:45:19 +01:00
Brian Wiborg
ed30291a4c
🔖 0.5.4 2025-10-26 21:49:13 +01:00
Brian Wiborg
31f4da773c
⬆️ Upgrade deps 2025-10-26 21:48:48 +01:00
Brian Wiborg
715b7a030a
🐛 Createsuperuser in single asyncio task 2025-10-26 21:47:03 +01:00
Brian Wiborg
4a5bafd889
🔖 0.5.3 2025-10-25 11:18:07 +02:00
Brian Wiborg
b50cbc4341
🐛 Fix /auth/refresh 2025-10-25 11:17:05 +02:00
Brian Wiborg
10681cc15b
🚚 Move hmac_hash() to ohmyapi_auth.utils 2025-10-24 20:54:16 +02:00
Brian Wiborg
58f1387aaf
🔖 0.5.2 2025-10-22 11:26:50 +02:00
Brian Wiborg
6b87bfeefb
🐛 email_hash is not optional 2025-10-22 11:25:40 +02:00
Brian Wiborg
812e89ede9
🔖 0.5.1 2025-10-11 13:31:28 +02:00
Brian Wiborg
e25c9d1715
🩹 Define explicit user_groups proxy-table 2025-10-11 13:30:46 +02:00
Brian Wiborg
d494396728
✏️ Fix typo 2025-10-11 13:12:48 +02:00
Brian Wiborg
2e1ec5d780
🔖 0.5.0 2025-10-11 13:06:34 +02:00
Brian Wiborg
ce47e3f60e
🎨 Auto-prefix table_names with "{app_label}_"
There is no support for auto-prefixing implicit proxy-tables in
Tortoise. If you need to prefix a proxy-table, explicitly define the
Model for the proxy table. Being an explicit model, it will then be
auto-prefixed.
2025-10-11 13:03:31 +02:00
Brian Wiborg
acd4844a25
🎨 Export tortoise.functions as ohmyapi.db.functions 2025-10-11 04:31:30 +02:00
Brian Wiborg
74f625ab1d
🔖 0.4.7 2025-10-11 02:22:15 +02:00
Brian Wiborg
a45f03b92f
🐛 Fix maybe_authenticated 2025-10-11 02:20:59 +02:00
Brian Wiborg
de043ddd97
🐛 Fix authenticate method 2025-10-11 02:20:45 +02:00
Brian Wiborg
80163ce994
🐛 Make settings module available, first 2025-10-11 02:06:42 +02:00
Brian Wiborg
66176e9af7
🔖 0.4.6 2025-10-11 01:21:03 +02:00
Brian Wiborg
cf106e8855
🐛 maybe_authenticated with export 2025-10-11 01:20:32 +02:00
Brian Wiborg
d7f7db338f
🔖 0.4.5 2025-10-11 01:08:50 +02:00
Brian Wiborg
643a6b2eb7
🔒️ Add optionally_authenticated permission 2025-10-11 01:07:55 +02:00
Brian Wiborg
1c42b44d41
🔖 0.4.4 2025-10-10 15:49:57 +02:00
Brian Wiborg
2239480dc0
🎨 Also export tortoise.query_utils.Prefetch 2025-10-10 15:49:14 +02:00
Brian Wiborg
3e15aa7722
🐛 Support apps without any models 2025-10-10 15:48:40 +02:00
Brian Wiborg
e1f5ce589c
🔖 0.4.3 2025-10-08 13:56:23 +02:00
Brian Wiborg
4ec2f87ce2
🐛 Fix ohmyapi.core.runtime.App.models 2025-10-08 13:55:57 +02:00
Brian Wiborg
e3abc642ed
🔖 0.4.2 2025-10-08 13:40:28 +02:00
Brian Wiborg
b1222c64d6
🐛 Fix ModuleNotFoundError typo 2025-10-08 13:39:11 +02:00
Brian Wiborg
1ef64c2b17
🔖 0.4.1 2025-10-08 13:20:32 +02:00
Brian Wiborg
49c24b7f0c
🐛 Fix createsuperuser 2025-10-08 13:19:47 +02:00
Brian Wiborg
4c11297d65
🔖 0.4.0 2025-10-08 13:10:30 +02:00
Brian Wiborg
c23576b393
🩹 Remove ohmyapi from template deps 2025-10-08 13:09:16 +02:00
Brian Wiborg
0deb5706f8
🎨 Single line initialization is more readable 2025-10-08 13:07:31 +02:00
Brian Wiborg
f579972466
🎨 Allow overriding base prefix 2025-10-08 13:06:42 +02:00
Brian Wiborg
cb9acd52d0
📝 Small improvements 2025-10-08 12:54:42 +02:00
Brian Wiborg
67d89a94f4
Integrate logging 2025-10-08 12:45:27 +02:00
Brian Wiborg
e848601f79
🐛 Use set_email in createsuperuser 2025-10-08 12:41:05 +02:00
Brian Wiborg
6120c151f1
🎨 Add Postgress service as comment 2025-10-06 21:31:10 +02:00
Brian Wiborg
3e029e1fb7
🔥 Remove poetry lock 2025-10-06 21:30:50 +02:00
Brian Wiborg
9ac95af005
🎨 Add option to pull in asyncpg 2025-10-06 21:29:52 +02:00
Brian Wiborg
33a9c94042
♻️ Refactor ohmyapi_auth
- remove middlewares (should come from user)
- save symmetrically hased e-mail instead of raw
2025-10-06 21:27:16 +02:00
Brian Wiborg
cc3872cf74
Add support for middleware in apps 2025-10-02 21:50:42 +02:00
Brian Wiborg
5c632cbe8f
🎨 Add Q to exports 2025-10-02 17:15:39 +02:00
Brian Wiborg
ad8986abb1
🔖 0.3.2 2025-10-02 15:11:19 +02:00
Brian Wiborg
bcdd23652f
🐛 Revoke support for table_name prefixes
Unfortunately, Aerich seems a bit awkward in respecting
Model.Meta.table. Also proxy-tables can not be prefixed at all.

If there is no concise way to prefix all tables of an app, we shouldn't
prefix it at all and leave the collision-problem for the user to keep an
eye on.
2025-10-02 15:09:02 +02:00
Brian Wiborg
856ea12f52
🔖 0.3.1 2025-10-02 14:47:15 +02:00
Brian Wiborg
bb8884f419
🐛 Remember to alias builtin apps before inferring 2025-10-02 14:46:32 +02:00
Brian Wiborg
63d2c31763
🔖 0.3.0 2025-10-02 14:32:47 +02:00
Brian Wiborg
64e98f9f0a
🏗️ Automatically prefix table_names with app_label 2025-10-02 14:30:53 +02:00
Brian Wiborg
c411a9795c
🔖 0.2.8 2025-10-02 05:48:48 +02:00
Brian Wiborg
e2f968bac4
👷 Add dockerize command 2025-10-02 05:47:48 +02:00
Brian Wiborg
e9d0fb5b80
🔖 0.2.6 2025-10-02 04:51:41 +02:00
Brian Wiborg
f74b20a19f
♻️ Provide project.configure_app 2025-10-02 04:50:55 +02:00
Brian Wiborg
6089f950b6
👷 Dynamic versioning 2025-10-02 04:49:59 +02:00
Brian Wiborg
e894c8f100
🔖 0.2.5 2025-10-02 03:09:23 +02:00
Brian Wiborg
26a072714d
🩹 Pass APIRouters directly 2025-10-02 03:08:24 +02:00
Brian Wiborg
795dab71f0
⬆️ Upgrade all deps 2025-10-02 03:02:44 +02:00
Brian Wiborg
af110cec9d
🩹 Fix FastAPI app initialization 2025-10-02 03:02:00 +02:00
Brian Wiborg
ee4bd2760c
🔖 0.2.4 2025-10-02 02:46:23 +02:00
Brian Wiborg
28f76fc4f4
🩹 Fix typo 2025-10-02 02:45:53 +02:00
Brian Wiborg
63a4f4f948
🩹 This app's models only 2025-10-02 02:45:14 +02:00
Brian Wiborg
37d807eb65
🔖 0.2.3 2025-10-02 02:06:36 +02:00
Brian Wiborg
f0e5c8c30e
📝 Add builtin section; add ohmyapi_auth docs 2025-10-02 02:05:25 +02:00
Brian Wiborg
e53c206b4e
🔖 0.2.2 2025-10-02 01:03:48 +02:00
Brian Wiborg
91baf968d7
📝 Add mkdocs 2025-10-02 01:01:57 +02:00
Brian Wiborg
a3d9862c4e
🎨 Add Schema.get method 2025-10-02 00:49:12 +02:00
Brian Wiborg
ed55c3708f
🔖 0.2.1 2025-10-01 22:05:23 +02:00
Brian Wiborg
ed3a776bde
📝 More inline docs 2025-10-01 22:04:30 +02:00
Brian Wiborg
3de9352227
📝🩹 More inline docs; 2025-10-01 22:00:27 +02:00
Brian Wiborg
00e18af8fd
📝🎨 More inline docs; small adjustments 2025-10-01 21:55:01 +02:00
Brian Wiborg
16f15a3d65
🚨 Cleanup imports 2025-10-01 20:49:44 +02:00
Brian Wiborg
2232726e7c
♻️ Refactor core.runtime
- rewrite how apps are loaded into scope
- rewrite how apps are collected for Tortoise and Aerich
- rewrite how routes are collected for FastAPI
- support packages for models and routes with arbitrary nesting
  - no need to expose models and routes in __init__.py
  - OhMyAPI will recursively iterate through all submodules
2025-10-01 20:43:56 +02:00
Brian Wiborg
642359bdeb
✏️ Capitalize tags 2025-09-30 15:39:11 +02:00
Brian Wiborg
4d8952eff7
🍱 Add package_routers() 2025-09-30 00:31:24 +02:00
Brian Wiborg
e43dced167
0.2.0
- breaks migrations due to proxy-table rename
2025-09-29 22:27:13 +02:00
Brian Wiborg
7c75cea413
🎨 models - und thus tables - have no underscores 2025-09-29 22:26:39 +02:00
Brian Wiborg
bbadd1c132
🔖 0.1.27 2025-09-29 19:56:13 +02:00
Brian Wiborg
4550549c2c
🗑️ Remove flat_label artifact 2025-09-29 19:54:45 +02:00
Brian Wiborg
2399b28c52
🔖 0.1.26 2025-09-29 18:28:48 +02:00
Brian Wiborg
c56ea6451e
🎨 Make Model.Schema callable with readonly arg 2025-09-29 18:22:43 +02:00
Brian Wiborg
29a5018ae3
🔖 0.1.25 2025-09-29 17:19:20 +02:00
Brian Wiborg
1b830f7bd2
📝 Reflect latest changes 2025-09-29 17:17:54 +02:00
Brian Wiborg
e142489ed9
🎨 Use 4 spaces and double quotes 2025-09-29 15:32:05 +02:00
Brian Wiborg
4fffeda0ba
🔖 0.1.24 2025-09-29 14:35:33 +02:00
Brian Wiborg
7edd17d359
🍱 Solely depend on OhMyAPI 2025-09-29 14:34:53 +02:00
Brian Wiborg
cc2c9a3647
🐛 Fix FQMN 2025-09-29 13:47:47 +02:00
Brian Wiborg
737a06c05d
📝 Typo 2025-09-28 19:52:17 +02:00
Brian Wiborg
b07df29c9c
🚨 Python Black commit 2025-09-28 19:40:54 +02:00
Brian Wiborg
ff8384d2c5
📝 Reword 2025-09-28 19:39:24 +02:00
Brian Wiborg
250bf142ed
🔖 0.1.23 2025-09-28 19:27:09 +02:00
Brian Wiborg
61ef27936c
🍱 Add ohmyapi_demo 2025-09-28 19:26:37 +02:00
Brian Wiborg
90f257ae38
♻️ Refactor ohmyapi_auth
- improved type-safety
- created and defined response_models
2025-09-28 19:23:22 +02:00
Brian Wiborg
64d6ca369f
🔖 0.1.22 2025-09-28 17:34:34 +02:00
Brian Wiborg
31dd3a9e37
📝 Reflect latest changes 2025-09-28 17:34:05 +02:00
Brian Wiborg
111a65da85
🎨 CRUD endpoints boilerplate 2025-09-28 17:33:51 +02:00
Brian Wiborg
c8206547d8
🎨 Add http.HTTPStatus for convenience 2025-09-28 17:32:34 +02:00
Brian Wiborg
3e682bbc89
🐛 Make apps "just work" out-of-the-box 2025-09-28 17:04:00 +02:00
Brian Wiborg
905ce66b1a
🔖 0.1.21 2025-09-28 15:52:46 +02:00
Brian Wiborg
6a90e4a44a
💄 Introduce black & isort 2025-09-28 15:41:01 +02:00
Brian Wiborg
9becfc857d
🚸 Add App.dict(); represent as JSON 2025-09-28 15:37:28 +02:00
Brian Wiborg
3ebebe7fbd
🎨 Directly import models; force complete template 2025-09-28 15:04:20 +02:00
Brian Wiborg
80a4b468b1
🔖 0.1.20 2025-09-28 15:02:17 +02:00
Brian Wiborg
30a7826eeb
📝 Reflect latest changes 2025-09-28 14:52:49 +02:00
Brian Wiborg
3465ec71c7
🐛 Monkey-patch UUID to be pydantic serializable 2025-09-28 14:51:38 +02:00
Brian Wiborg
b15ce0b044
🐛 Fix hanging shell on exit
The shell was hanging on exit, after at least one ORM query was
performed. The cleanup() task never triggered. It now triggers reliably.
2025-09-28 14:49:53 +02:00
Brian Wiborg
82c39540a9
🔖 0.1.19 2025-09-28 02:12:57 +02:00
Brian Wiborg
09648fa292
🎨 pydantic model and readonly schema 2025-09-28 02:12:43 +02:00
Brian Wiborg
d509b58282
🔖 0.1.18 2025-09-28 01:51:52 +02:00
Brian Wiborg
9bf33d12c9
📝 Reflect latest changes in Model.Schema 2025-09-28 01:51:07 +02:00
Brian Wiborg
ac60c19551
🔥 Remove Model.Schema.many 2025-09-28 01:48:03 +02:00
Brian Wiborg
1dcbab06b1
🔖 0.1.17 2025-09-27 23:20:57 +02:00
Brian Wiborg
52297d8ac3
✏️ Fix typo 2025-09-27 23:20:25 +02:00
Brian Wiborg
adf3fc9ca9
🔖 0.1.16 2025-09-27 23:17:39 +02:00
Brian Wiborg
eac45bdeb3
♻️ Replace os.mkdirs in favor of Path.mkdir 2025-09-27 23:16:57 +02:00
Brian Wiborg
af1d502570
🩹 0.1.15 2025-09-27 23:09:41 +02:00
Brian Wiborg
f2f6beb770
🩹 Add missing quotes 2025-09-27 22:56:20 +02:00
Brian Wiborg
d67ae5d3f5
🔖 0.1.14 2025-09-27 22:52:23 +02:00
Brian Wiborg
c43d5030b9
📝 Reflect latest changes 2025-09-27 22:51:58 +02:00
Brian Wiborg
db329a8822
🎨 Expose tortoise signals 2025-09-27 22:51:39 +02:00
Brian Wiborg
2870d55926
♻️ Add type annotations 2025-09-27 22:51:11 +02:00
Brian Wiborg
7ca64e8aef
🎨 Provide tortoise.queryset.QuerySet 2025-09-27 22:06:51 +02:00
Brian Wiborg
264119a67d
✏️ Better wording 2025-09-27 17:34:47 +02:00
Brian Wiborg
33b8ff7acb
🔖 0.1.13 2025-09-27 17:22:32 +02:00
Brian Wiborg
15608a389b
📝 Show complete p.apps in examples 2025-09-27 17:21:53 +02:00
Brian Wiborg
b6d209926f
🩹 Ensure ORM initialization and tear-down 2025-09-27 17:19:37 +02:00
Brian Wiborg
485ddc01fb
Revert "🎨 Make asyncio available in shell automatically"
This reverts commit 0ec26895c0.
2025-09-27 17:12:32 +02:00
Brian Wiborg
0ec26895c0
🎨 Make asyncio available in shell automatically 2025-09-27 17:00:53 +02:00
Brian Wiborg
97fc689d7d
🔖 0.1.12 2025-09-27 16:52:09 +02:00
Brian Wiborg
3071d76ae1
🩹 Add password verification 2025-09-27 16:50:15 +02:00
Brian Wiborg
20a826e8c0
📝 Few small improvements 2025-09-27 16:32:03 +02:00
Brian Wiborg
42f7713345
✏️ Remove paste-fail artifact 2025-09-27 16:25:22 +02:00
Brian Wiborg
a7a792b7b2
🔖 0.1.11 2025-09-27 16:20:16 +02:00
Brian Wiborg
49faab5be5
🩹 Allow admins on require_staff permission 2025-09-27 16:18:52 +02:00
Brian Wiborg
92bb1cc648
♻️ Return entire user object
The Model.Schema already prevents password_hash from leaking
2025-09-27 16:15:50 +02:00
Brian Wiborg
b5b005448a
♻️ Order matters; reorder endpoints 2025-09-27 16:15:16 +02:00
Brian Wiborg
8543957095
🩹 Add missing ohmyapp_auth.permissions.require_group 2025-09-27 16:13:22 +02:00
Brian Wiborg
1bcd7c0f1f
📝 Clarify routes.router 2025-09-27 16:00:55 +02:00
Brian Wiborg
0941a9e9d6
📝 Clarify imprtance app.routes.router 2025-09-27 15:50:20 +02:00
Brian Wiborg
ffac376dde
🔖 0.1.10 2025-09-27 15:47:01 +02:00
Brian Wiborg
8b4c03a778
✏️ Remove paste-fail 2025-09-27 15:45:41 +02:00
Brian Wiborg
036e041be7
🔖 0.1.9 2025-09-27 15:42:15 +02:00
Brian Wiborg
ce6b57bf9d
📝 More info on shell command 2025-09-27 15:40:53 +02:00
Brian Wiborg
aea68b8128
🎨 Improve shell experience
- more informative banner
- make project singleton available via identifier `p`
2025-09-27 15:36:07 +02:00
Brian Wiborg
35e6ddfcf5
✏️ Fix typo 2025-09-27 15:23:48 +02:00
Brian Wiborg
5379c125c4
🔖 0.1.8 2025-09-27 15:06:17 +02:00
Brian Wiborg
c15bc82caa
📝 Use field.data.UUIDField as id in examples 2025-09-27 15:05:38 +02:00
Brian Wiborg
812049eae7
🩹 Respect new User.email field 2025-09-27 15:05:12 +02:00
Brian Wiborg
51037b615a
♻️ field.data.UUIDField as Models.id on auth models 2025-09-27 15:00:13 +02:00
Brian Wiborg
018587618e
🔖 0.1.7 2025-09-27 14:10:56 +02:00
Brian Wiborg
73785faebf
📝 Add model-level permissions example 2025-09-27 14:09:14 +02:00
Brian Wiborg
3d61ecd216
🎨 tortoise.manager.Manager via ohmyapi.db 2025-09-27 14:08:31 +02:00
Brian Wiborg
8f4648643d
📝 Use Tortoise's Tournement example for models 2025-09-27 13:48:49 +02:00
Brian Wiborg
3165243755
📝 Reflect latest changes in getting started docs 2025-09-27 13:31:00 +02:00
Brian Wiborg
82fe75b0c7
♻️ Improve ohmyapi-only imports in apps 2025-09-27 13:28:21 +02:00
Brian Wiborg
970117a474
📝 Improve getting started docs 2025-09-27 12:46:38 +02:00
Brian Wiborg
8d486001b6
♻️Tidy up pyproject.toml 2025-09-27 12:32:03 +02:00
Brian Wiborg
0baedd94d9
Add __main__.py package entrypoint 2025-09-27 12:31:13 +02:00
Brian Wiborg
091e8a4605
♻️Remove obsolete main() 2025-09-27 12:30:50 +02:00
Brian Wiborg
df2d2fd89c
📝 Add README.md.j2 to project template 2025-09-27 12:30:28 +02:00
Brian Wiborg
3958c51213
♻️Refactor settings.py.j2 to new project syntax 2025-09-27 12:29:45 +02:00
Brian Wiborg
7cf7d6ccfc
🔖 0.1.6 2025-09-27 06:16:11 +02:00
Brian Wiborg
2e83e65c7e
🔥 Remove commit artifact 2025-09-27 06:15:47 +02:00
40 changed files with 1970 additions and 629 deletions

186
README.md
View file

@ -1,177 +1,41 @@
# OhMyAPI # OhMyAPI
> OhMyAPI == Application scaffolding for FastAPI+TortoiseORM. OhMyAPI is a web-application scaffolding framework and management layer built around `FastAPI`.
It is a thin layer that tightly integrates TortoiseORM and Aerich migrations.
OhMyAPI is a Django-flavored web-application scaffolding framework. > *Think: *"Django RestFramework"*, but less clunky and instead 100% async.*
Built around FastAPI and TortoiseORM, it 100% async.
It is blazingly fast and has batteries included.
Features: It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***!
- Django-like project-layout and -structure **Features**
- Django-like settings.py
- Django-like models via TortoiseORM - Django-like project structure and application directories
- Django-like model.Meta class for model configuration - Django-like per-app migrations (makemigrations & migrate) via Aerich
- Django-like advanced permissions system - Django-like CLI tooling (startproject, startapp, shell, serve, etc)
- Django-like migrations (makemigrations & migrate) via Aerich - Customizable pydantic model serializer built-in
- Django-like CLI for interfacing with your projects (startproject, startapp, shell, serve, etc) - Various optional built-in apps you can hook into your project (i.e. authentication and more)
- various optional builtin apps - Highly configurable and customizable
- highly configurable and customizable
- 100% async - 100% async
## Getting started **Goals**
**Creating a Project** - 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
``` ```
pip install ohmyapi pipx install ohmyapi
ohmyapi startproject myproject
cd myproject
``` ```
This will create the following directory structure:
## Docs
See `docs/` or:
``` ```
myproject/ poetry run mkdocs serve
- pyproject.toml
- settings.py
```
Run your project with:
```
ohmyapi serve
```
In your browser go to:
- http://localhost:8000/docs
**Creating an App**
Create a new app by:
```
ohmyapi startapp myapp
```
This will lead to the following directory structure:
```
myproject/
- myapp/
- __init__.py
- models.py
- routes.py
- pyproject.toml
- settings.py
```
Add 'myapp' to your `INSTALLED_APPS` in `settings.py`.
Write your first model in `myapp/models.py`:
```python
from ohmyapi.db import Model, field
class Person(Model):
id: int = field.IntField(min=1, pk=True)
name: str = field.CharField(min_length=1, max_length=255)
username: str = field.CharField(min_length=1, max_length=255, unique=True)
age: int = field.IntField(min=0)
```
Next, create your endpoints in `myapp/routes.py`:
```python
from fastapi import APIRouter, HTTPException
from tortoise.exceptions import DoesNotExist
from .models import Person
router = APIRouter(prefix="/myapp")
@router.get("/")
async def list():
return await Person.Schema.many.from_queryset(Person.all())
@router.get("/:id")
async def get(id: int):
try:
return await Person.Schema.one(Person.get(pk=id))
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/
- myapp/
- __init__.py
- models.py
- routes.py
- migrations/
- myapp/
- pyproject.toml
- 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 auth tables to be created in the database.
`settings.py`:
```
INSTALLED_APPS = [
'ohmyapi_auth',
...
]
JWT_SECRET = "t0ps3cr3t"
```
Create a super-user:
```
ohmyapi createsuperuser
``` ```
Go to: [http://localhost:8000/](http://localhost:8000/)

8
docs/README.md Normal file
View file

@ -0,0 +1,8 @@
# OhMyAPI Docs
- [Projects](projects.md)
- [Apps](apps.md)
- [Models](models.md)
- [Migrations](migrations.md)
- [Routes](routes.md)
- [Auth/Permissions](auth.md)

34
docs/apps.md Normal file
View file

@ -0,0 +1,34 @@
# Apps
Apps are a way to group database models and API routes that contextually belong together.
For example, OhMyAPI comes bundled with an `auth` app that carries a `User` 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
```

45
docs/auth.md Normal file
View file

@ -0,0 +1,45 @@
# Authentication
OhMyAPI comes bundled with a builtin authentication app.
Simply add `ohmyapi_auth` to your `INSTALLED_APPS` and configure a `JWT_SECRET`.
## Enable Auth App
`settings.py`:
```
INSTALLED_APPS = [
"ohmyapi_auth",
...
]
JWT_SECRET = "t0ps3cr3t"
```
Remember to `makemigrations` and `migrate` to create the necessary database tables.
```
ohmyapi makemigrations
ohmyapi migrate
```
## Permissions
With the `ohmyapi_auth` app comes everything you need to implement API-level permissions.
Use FastAPI's `Depends` pattern in combination with either the provided or custom permissions.
```python
from ohmyapi.router import APIRouter, Depends
from ohmyapi_auth import (
models as auth,
permissions,
)
router = APIRouter()
@router.get("/")
def get(user: auth.User = Depends(permissions.require_authenticated)):
...
```

30
docs/index.md Normal file
View file

@ -0,0 +1,30 @@
# Welcome to OhMyAPI
OhMyAPI is a web-application scaffolding framework and management layer built around `FastAPI`, `TortoiseORM` and `Aerich` migrations.
> *Think: Django RestFramework, but less clunky and 100% async.*
It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***!
**Features**
- Django-like project structure and application directories
- Django-like per-app migrations (makemigrations & migrate) via Aerich
- Django-like CLI tooling (startproject, startapp, shell, serve, etc)
- Customizable pydantic model serializer built-in
- Various optional built-in apps you can hook into your project
- Highly configurable and customizable
- 100% async
**Goals**
- combine FastAPI, TortoiseORM, Aerich migrations and Pydantic into a high-productivity web-application framework
- tie everything neatly together into a concise and straight-forward API
- AVOID adding any abstractions on top, unless they make things extremely convenient
## Installation
```
pipx install ohmyapi
```

22
docs/migrations.md Normal file
View file

@ -0,0 +1,22 @@
# Migrations
OhMyAPI uses [Aerich](https://github.com/tortoise/aerich) - a database migrations tool for TortoiseORM.
## Making migrations
Whenever you add, remove or change fields of a database model, you need to create a migration for the change.
```
ohmyapi makemigrations [ <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
```

102
docs/models.md Normal file
View file

@ -0,0 +1,102 @@
# Models
OhMyAPI uses [Tortoise](https://tortoise.github.io/) - an easy-to-use asyncio ORM (Object Relational Mapper) inspired by Django.
Models are exposed via a Python module named `models` in the app's directory.
OhMyAPI auto-detects all models exposed this way.
If the `models` module is a package, OhMyAPI will search through its submodules recursively.
## Writing models
### Your first simple model
```python
from ohmyapi.db import Model, field
class Restaurant(Model):
id: int = field.IntField(pk=True)
name: str = field.CharField(max_length=255)
description: str = field.TextField()
location: str = field.CharField(max_length=255)
```
### ForeignKeyRelations
You can define relationships between models.
```python
from ohmyapi.db import Model, field
from decimal import Decimal
class Restaurant(Model):
id: int = field.IntField(pk=True)
name: str = field.CharField(max_length=255)
description: str = field.TextField()
location: str = field.CharField(max_length=255)
class Dish(Model):
id: int = field.IntField(pk=True)
restaurant: field.ForeignKeyRelation[Restaurant] = field.ForeignKeyField(
"restaurant.Restaurant",
related_name="dishes",
)
name: str = field.CharField(max_length=255)
price: Decimal = field.DecimalField(max_digits=10, decimal_places=2)
class Order(Model):
id: int = field.IntField(pk=True)
restaurant: field.ForeignKeyRelation[Restaurant] = field.ForeignKeyField(
'restaurant.Restaurant',
related_name="dishes",
)
dishes: field.ManyToManyRelation[Dish] = field.ManyToManyField(
"restaurant.Dish",
relatated_name="orders",
through="dishesordered",
)
```
## Pydantic Schema Validation
Each model has a builtin `Schema` class that provides easy access to Pydantic models for schema validation.
Each model provides a default schema and a readonly schema, which you can obtain via the `get` method or by calling Schema() directly.
The default schema contains the full collection of fields of the Tortoise model.
The readonly schema excludes the primary-key field, as well as all readonly fields.
```python
In [1]: from restaurant.models import Restaurant
In [2]: Restaurant.Schema.get()
Out[2]: tortoise.contrib.pydantic.creator.RestaurantSchema
In [3]: Restaurant.Schema.get(readonly=True)
Out[3]: tortoise.contrib.pydantic.creator.RestaurantSchemaReadonly
In [4]: data = {
...: "name": "My Pizzeria",
...: "description": "Awesome Pizza!",
...: "location": "Berlin",
...: }
In [5]: Restaurant.Schema.get(readonly=True)(**data)
Out[5]: RestaurantSchemaReadonly(name='My Pizzeria', description='Awesome Pizza!', location='Berlin')
In [6]: Restaurant(**_.model_dump())
Out[6]: <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
```

34
docs/projects.md Normal file
View file

@ -0,0 +1,34 @@
# Projects
OhMyAPI organizes projects in a diretory tree.
The root directory contains the `settings.py`, which carries global configuration for your project, such as your `DATABASE_URL` and `INSTALLED_APPS`.
Each project is organized into individual apps, which in turn may provide some database models and API handlers.
Each app is isolated in its own subdirectory within your project.
You can control which apps to install and load via `INSTALLED_APPS` in your `settings.py`.
## Create a Project
To create a projects, simply run:
```
ohmyapi startproject myproject
cd myproject
```
This will create the following directory structure:
```
myproject/
- pyproject.toml
- README.md
- settings.py
```
Run your project with:
```
ohmyapi serve
```
In your browser go to: [http://localhost:8000/docs](http://localhost:8000/docs)

68
docs/routes.md Normal file
View file

@ -0,0 +1,68 @@
# Routes
OhMyAPI uses [FastAPI](https://fastapi.tiangolo.com/) - a modern, fast (high-performance), web framework for building APIs with Python based on standard Python type hints.
Routes are exposed via a module named `routes` in the app's directory.
OhMyAPI auto-detects all `fastapi.APIRouter` instances exposed this way.
If the `routes` module is a package, OhMyAPI will search through its submodules recursively.
When creating an app via `startapp`, OhMyAPI will provide CRUD boilerplate to help your get started.
## Example CRUD API endpoints
```python
from ohmyapi.db.exceptions import DoesNotExist
from ohmyapi.router import APIRouter, HTTPException, HTTPStatus
from .models import Restaurant
from typing import List
router = APIRouter(prefix="/restaurant", tags=['restaurant'])
@router.get("/", response_model=List[Restaurant])
async def list():
"""List all restaurants."""
queryset = Restaurant.all()
schema = Restaurant.Schema()
return await schema.from_queryset(queryset)
# or in one line:
# return await Restaurant.Schema().from_queryset(Restaurant.all())
@router.post("/", status_code=HTTPStatus.CREATED)
async def post(restaurant: Restaurant.Schema(readonly=True)):
"""Create a new restaurant."""
return await Restaurant(**restaurant.model_dump()).create()
@router.get("/{id}", response_model=Restaurant)
async def get(id: str):
"""Get restaurant by ID."""
return await Restaurant.Schema().from_queryset(Restaurant.get(id=id))
@router.put("/{id}", status_code=HTTPStatus.ACCEPTED)
async def put(restaurant: Restaurant):
"""Update restaurant."""
try:
db_restaurant = await Restaurant.get(id=id)
except DoesNotExist:
return HTTPException(status_code=HTTPStatus.NOT_FOUND)
db_restaurant.update_from_dict(restaurant.model_dump())
return await db_restaurant.save()
@router.delete("/{id}", status_code=HTTPStatus.ACCEPTED)
async def delete(id: str):
try:
db_restaurant = await Restaurant.get(id=id)
except DoesNotExist:
return HTTPException(status_code=HTTPStatus.NOT_FOUND)
return await db_restaurant.delete()
```

11
mkdocs.yml Normal file
View file

@ -0,0 +1,11 @@
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

665
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]] [[package]]
name = "aerich" name = "aerich"
@ -58,22 +58,23 @@ files = [
[[package]] [[package]]
name = "anyio" name = "anyio"
version = "4.10.0" version = "4.11.0"
description = "High-level concurrency and networking framework on top of asyncio or Trio" description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"},
{file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"},
] ]
[package.dependencies] [package.dependencies]
idna = ">=2.8" idna = ">=2.8"
sniffio = ">=1.1" sniffio = ">=1.1"
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras] [package.extras]
trio = ["trio (>=0.26.1)"] trio = ["trio (>=0.31.0)"]
[[package]] [[package]]
name = "argon2-cffi" name = "argon2-cffi"
@ -138,7 +139,7 @@ version = "3.0.0"
description = "Annotate AST trees with source code positions" description = "Annotate AST trees with source code positions"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"}, {file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"},
{file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"}, {file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"},
@ -163,13 +164,59 @@ files = [
[package.dependencies] [package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""} 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]] [[package]]
name = "certifi" name = "certifi"
version = "2025.8.3" version = "2025.8.3"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
@ -367,7 +414,7 @@ version = "8.3.0"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
{file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
@ -382,7 +429,7 @@ version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"] groups = ["main", "dev"]
markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
@ -411,7 +458,7 @@ version = "5.2.1"
description = "Decorators for Humans" description = "Decorators for Humans"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"},
{file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"},
@ -441,7 +488,7 @@ version = "2.2.1"
description = "Get the currently executing AST node of a frame, and other information" description = "Get the currently executing AST node of a frame, and other information"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"},
{file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"},
@ -472,25 +519,90 @@ 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 = ["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)"] 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]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, {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]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
@ -499,16 +611,28 @@ files = [
[package.extras] [package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 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]] [[package]]
name = "ipython" name = "ipython"
version = "9.5.0" version = "9.6.0"
description = "IPython: Productive Interactive Computing" description = "IPython: Productive Interactive Computing"
optional = false optional = false
python-versions = ">=3.11" python-versions = ">=3.11"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "ipython-9.5.0-py3-none-any.whl", hash = "sha256:88369ffa1d5817d609120daa523a6da06d02518e582347c29f8451732a9c5e72"}, {file = "ipython-9.6.0-py3-none-any.whl", hash = "sha256:5f77efafc886d2f023442479b8149e7d86547ad0a979e9da9f045d252f648196"},
{file = "ipython-9.5.0.tar.gz", hash = "sha256:129c44b941fe6d9b82d36fc7a7c18127ddb1d6f02f78f867f402e2e3adde3113"}, {file = "ipython-9.6.0.tar.gz", hash = "sha256:5603d6d5d356378be5043e69441a072b50a5b33b4503428c77b04cb8ce7bc731"},
] ]
[package.dependencies] [package.dependencies]
@ -522,14 +646,15 @@ prompt_toolkit = ">=3.0.41,<3.1.0"
pygments = ">=2.4.0" pygments = ">=2.4.0"
stack_data = "*" stack_data = "*"
traitlets = ">=5.13.0" traitlets = ">=5.13.0"
typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""}
[package.extras] [package.extras]
all = ["ipython[doc,matplotlib,test,test-extra]"] all = ["ipython[doc,matplotlib,test,test-extra]"]
black = ["black"] black = ["black"]
doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinx_toml (==0.0.4)", "typing_extensions"] 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"] matplotlib = ["matplotlib (>3.7)"]
test = ["packaging", "pytest", "pytest-asyncio", "testpath"] test = ["packaging", "pytest", "pytest-asyncio", "testpath"]
test-extra = ["curio", "ipykernel", "ipython[test]", "jupyter_ai", "matplotlib (!=3.2.0)", "nbclient", "nbformat", "numpy (>=1.23)", "pandas", "trio"] test-extra = ["curio", "ipykernel", "ipython[matplotlib]", "ipython[test]", "jupyter_ai", "nbclient", "nbformat", "numpy (>=1.25)", "pandas (>2.0)", "trio"]
[[package]] [[package]]
name = "ipython-pygments-lexers" name = "ipython-pygments-lexers"
@ -537,7 +662,7 @@ version = "1.1.1"
description = "Defines a variety of Pygments lexers for highlighting IPython code." description = "Defines a variety of Pygments lexers for highlighting IPython code."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"}, {file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"},
{file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"}, {file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"},
@ -559,13 +684,29 @@ files = [
{file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, {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]] [[package]]
name = "jedi" name = "jedi"
version = "0.19.2" version = "0.19.2"
description = "An autocompletion tool for Python that can be used for text editors." description = "An autocompletion tool for Python that can be used for text editors."
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"},
{file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"},
@ -585,7 +726,7 @@ version = "3.1.6"
description = "A very fast and expressive template engine." description = "A very fast and expressive template engine."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
@ -597,6 +738,22 @@ MarkupSafe = ">=2.0"
[package.extras] [package.extras]
i18n = ["Babel (>=2.7)"] 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]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "4.0.0" version = "4.0.0"
@ -623,73 +780,101 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.2" version = "3.0.3"
description = "Safely add untrusted strings to HTML/XML markup." description = "Safely add untrusted strings to HTML/XML markup."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
{file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, {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.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, {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.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"},
{file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"},
{file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"},
{file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"},
{file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"},
{file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"},
{file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, {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.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, {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.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"},
{file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"},
{file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"},
{file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"},
{file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"},
{file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"},
{file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"},
{file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"},
{file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"},
{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.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"},
{file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, {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.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"},
{file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"},
{file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"},
{file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"},
{file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"},
{file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"},
{file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"},
{file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"},
{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.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"},
{file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, {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.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, {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.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"},
{file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"},
{file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"},
{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.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, {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.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, {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.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"},
{file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"},
{file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"},
{file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"},
{file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"},
{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.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, {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.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, {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.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"},
{file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"},
{file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"},
{file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"},
{file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"},
{file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"},
{file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"},
{file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"},
{file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"},
{file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"},
] ]
[[package]] [[package]]
@ -698,7 +883,7 @@ version = "0.1.7"
description = "Inline Matplotlib backend for Jupyter" description = "Inline Matplotlib backend for Jupyter"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"},
{file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"},
@ -719,6 +904,78 @@ files = [
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, {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]] [[package]]
name = "naked" name = "naked"
version = "0.1.32" version = "0.1.32"
@ -735,13 +992,25 @@ files = [
pyyaml = "*" pyyaml = "*"
requests = "*" 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]] [[package]]
name = "parso" name = "parso"
version = "0.8.5" version = "0.8.5"
description = "A Python Parser" description = "A Python Parser"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887"}, {file = "parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887"},
{file = "parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a"}, {file = "parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a"},
@ -769,13 +1038,25 @@ bcrypt = ["bcrypt (>=3.1.0)"]
build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"]
totp = ["cryptography"] 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]] [[package]]
name = "pexpect" name = "pexpect"
version = "4.9.0" version = "4.9.0"
description = "Pexpect allows easy control of interactive console applications." description = "Pexpect allows easy control of interactive console applications."
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"] groups = ["main", "dev"]
markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
files = [ files = [
{file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"},
@ -785,13 +1066,46 @@ files = [
[package.dependencies] [package.dependencies]
ptyprocess = ">=0.5" 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]] [[package]]
name = "prompt-toolkit" name = "prompt-toolkit"
version = "3.0.52" version = "3.0.52"
description = "Library for building powerful interactive command lines in Python" description = "Library for building powerful interactive command lines in Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"},
{file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"},
@ -806,7 +1120,7 @@ version = "0.7.0"
description = "Run a subprocess in a pseudo terminal" description = "Run a subprocess in a pseudo terminal"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"] groups = ["main", "dev"]
markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
files = [ files = [
{file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
@ -819,7 +1133,7 @@ version = "0.2.3"
description = "Safely evaluate AST nodes without side effects" description = "Safely evaluate AST nodes without side effects"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"},
{file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"},
@ -981,7 +1295,7 @@ version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python." description = "Pygments is a syntax highlighting package written in Python."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
@ -1021,6 +1335,43 @@ files = [
{file = "pypika_tortoise-0.6.2.tar.gz", hash = "sha256:f95ab59d9b6454db2e8daa0934728458350a1f3d56e81d9d1debc8eebeff26b3"}, {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]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.20" version = "0.0.20"
@ -1033,6 +1384,21 @@ files = [
{file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, {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]] [[package]]
name = "pytz" name = "pytz"
version = "2025.2" version = "2025.2"
@ -1051,7 +1417,7 @@ version = "6.0.3"
description = "YAML parser and emitter for Python" description = "YAML parser and emitter for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ 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_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
@ -1121,6 +1487,21 @@ files = [
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, {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]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"
@ -1186,13 +1567,25 @@ files = [
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, {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]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"
description = "Sniff out which async library your code is running under" description = "Sniff out which async library your code is running under"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@ -1204,7 +1597,7 @@ version = "0.6.3"
description = "Extract data from python stack frames and tracebacks for informative displays" description = "Extract data from python stack frames and tracebacks for informative displays"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"},
{file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"},
@ -1232,6 +1625,7 @@ files = [
[package.dependencies] [package.dependencies]
anyio = ">=3.6.2,<5" anyio = ">=3.6.2,<5"
typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""}
[package.extras] [package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
@ -1268,7 +1662,7 @@ version = "5.14.3"
description = "Traitlets Python configuration system" description = "Traitlets Python configuration system"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"},
{file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"},
@ -1280,14 +1674,14 @@ test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,
[[package]] [[package]]
name = "typer" name = "typer"
version = "0.19.1" version = "0.19.2"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints." description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "typer-0.19.1-py3-none-any.whl", hash = "sha256:914b2b39a1da4bafca5f30637ca26fa622a5bf9f515e5fdc772439f306d5682a"}, {file = "typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9"},
{file = "typer-0.19.1.tar.gz", hash = "sha256:cb881433a4b15dacc875bb0583d1a61e78497806741f9aba792abcab390c03e6"}, {file = "typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca"},
] ]
[package.dependencies] [package.dependencies]
@ -1302,22 +1696,23 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+" description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
] ]
markers = {dev = "python_version < \"3.13\""}
[[package]] [[package]]
name = "typing-inspection" name = "typing-inspection"
version = "0.4.1" version = "0.4.2"
description = "Runtime typing introspection tools" description = "Runtime typing introspection tools"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
] ]
[package.dependencies] [package.dependencies]
@ -1343,14 +1738,14 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.36.0" version = "0.36.1"
description = "The lightning-fast ASGI server." description = "The lightning-fast ASGI server."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "uvicorn-0.36.0-py3-none-any.whl", hash = "sha256:6bb4ba67f16024883af8adf13aba3a9919e415358604ce46780d3f9bdc36d731"}, {file = "uvicorn-0.36.1-py3-none-any.whl", hash = "sha256:059086ecb470a021553f17bf860fce2095611d92fb8b669c44325b3435a0a654"},
{file = "uvicorn-0.36.0.tar.gz", hash = "sha256:527dc68d77819919d90a6b267be55f0e76704dca829d34aea9480be831a9b9d9"}, {file = "uvicorn-0.36.1.tar.gz", hash = "sha256:048e68f2a0fe291cd848ed076f18c026e1b0bc69991495f087634ac9a41e8706"},
] ]
[package.dependencies] [package.dependencies]
@ -1360,19 +1755,65 @@ h11 = ">=0.8"
[package.extras] [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)"] 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]] [[package]]
name = "wcwidth" name = "wcwidth"
version = "0.2.14" version = "0.2.14"
description = "Measures the displayed width of unicode strings in a terminal" description = "Measures the displayed width of unicode strings in a terminal"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1"}, {file = "wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1"},
{file = "wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605"}, {file = "wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605"},
] ]
[extras]
auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.13" python-versions = ">=3.11"
content-hash = "b1f2f4159b02bf80e7bf6f995933ce4417ec6e1cff299da5157dda50e7da0bb6" content-hash = "cc1604995d3b73ee302e63731dd300ea17c4d95d0cfc6c386626dd9a9f60e8a7"

View file

@ -1,36 +1,79 @@
[project] [project]
name = "ohmyapi" name = "ohmyapi"
version = "0.1.5" version = "0.6.2"
description = "A Django-like but async web-framework based on FastAPI and TortoiseORM." description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations"
license = "MIT" license = "MIT"
keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"] keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"]
authors = [ authors = [
{name = "Brian Wiborg", email = "me@brianwib.org"} {name = "Brian Wiborg", email = "me@brianwib.org"}
] ]
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.11"
dependencies = [ dependencies = [
"typer (>=0.19.1,<0.20.0)", "typer >=0.19.1,<0.20.0",
"jinja2 (>=3.1.6,<4.0.0)", "jinja2 >=3.1.6,<4.0.0",
"fastapi (>=0.117.1,<0.118.0)", "fastapi >=0.117.1,<0.118.0",
"tortoise-orm (>=0.25.1,<0.26.0)", "tortoise-orm >=0.25.1,<0.26.0",
"aerich (>=0.9.1,<0.10.0)", "aerich >=0.9.1,<0.10.0",
"uvicorn (>=0.36.0,<0.37.0)", "uvicorn >=0.36.0,<0.37.0",
"ipython (>=9.5.0,<10.0.0)", "ipython >=9.5.0,<10.0.0",
"passlib (>=1.7.4,<2.0.0)", "passlib >=1.7.4,<2.0.0",
"pyjwt (>=2.10.1,<3.0.0)", "pyjwt >=2.10.1,<3.0.0",
"python-multipart (>=0.0.20,<0.0.21)", "python-multipart >=0.0.20,<0.0.21",
"crypto (>=1.4.1,<2.0.0)", "crypto >=1.4.1,<2.0.0",
"argon2-cffi (>=25.1.0,<26.0.0)", "argon2-cffi >=25.1.0,<26.0.0",
] ]
[tool.poetry] [tool.poetry.group.dev.dependencies]
packages = [{include = "ohmyapi", from = "src"}] 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"
[tool.poetry.scripts] [project.optional-dependencies]
ohmyapi = "ohmyapi.cli:main" auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"]
[tool.poetry]
packages = [ { include = "ohmyapi", from = "src" } ]
[project.scripts]
ohmyapi = "ohmyapi.cli:app"
[project.urls]
repository = "https://code.c-base.org/baccenfutter/ohmyapi"
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = [
build-backend = "poetry.core.masonry.api" "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,2 +1 @@
from . import db __VERSION__ = "0.6.2"

3
src/ohmyapi/__main__.py Normal file
View file

@ -0,0 +1,3 @@
from .cli import app
app()

View file

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

View file

@ -1,34 +1,52 @@
from typing import Optional, List from ohmyapi.db import Model, field, Q
from ohmyapi.db import Model, field from ohmyapi.router import HTTPException
from .utils import hmac_hash
from datetime import datetime
from passlib.context import CryptContext from passlib.context import CryptContext
from tortoise.contrib.pydantic import pydantic_queryset_creator from typing import Optional
from uuid import UUID
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") 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): class User(Model):
id = field.IntField(pk=True) id: UUID = field.data.UUIDField(pk=True)
email = field.CharField(max_length=255, unique=True, index=True) username: str = field.CharField(max_length=150, unique=True)
username = field.CharField(max_length=150, unique=True) email_hash: str = field.CharField(max_length=255, unique=True, index=True)
password_hash = field.CharField(max_length=128) password_hash: str = field.CharField(max_length=128)
is_admin = field.BooleanField(default=False) is_admin: bool = field.BooleanField(default=False)
is_staff = field.BooleanField(default=False) is_staff: bool = field.BooleanField(default=False)
groups: Optional[List[Group]] = field.ManyToManyField("ohmyapi_auth.Group", related_name="users") created_at: datetime = field.DatetimeField(auto_now_add=True)
updated_at: datetime = field.DatetimeField(auto_now=True)
class Schema: class Schema:
exclude = 'password_hash', include = {
"id",
"username",
"is_admin",
"is_staff"
"created_at",
"updated_at",
}
def __str__(self):
fields = {
'username': self.username,
'is_admin': 'y' if self.is_admin else 'n',
'is_staff': 'y' if self.is_staff else 'n',
}
return ' '.join([f"{k}:{v}" for k, v in fields.items()])
def set_password(self, raw_password: str) -> None: def set_password(self, raw_password: str) -> None:
"""Hash and store the password.""" """Hash and store the password."""
self.password_hash = pwd_context.hash(raw_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: def verify_password(self, raw_password: str) -> bool:
"""Verify a plaintext password against the stored hash.""" """Verify a plaintext password against the stored hash."""
return pwd_context.verify(raw_password, self.password_hash) return pwd_context.verify(raw_password, self.password_hash)
@ -40,4 +58,3 @@ class User(Model):
if user and user.verify_password(password): if user and user.verify_password(password):
return user return user
return None return None

View file

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

View file

@ -1,30 +1,100 @@
import time import time
from typing import Dict from enum import Enum
from typing import Any, Dict, List, Optional
import jwt from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request, status
from fastapi import APIRouter, Body, Depends, Header, HTTPException, status from fastapi.security import OAuth2, OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security.utils import get_authorization_scheme_param
from pydantic import BaseModel from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist
from ohmyapi.builtin.auth.models import User from ohmyapi.builtin.auth.models import User
import jwt
import settings import settings
# Router # Router
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["Auth"])
# Secrets & config (should come from settings/env in real projects) # Secrets & config (should come from settings/env in real projects)
JWT_SECRET = getattr(settings, "JWT_SECRET", "changeme") JWT_SECRET = getattr(settings, "JWT_SECRET", "changeme")
JWT_ALGORITHM = getattr(settings, "JWT_ALGORITHM", "HS256") JWT_ALGORITHM = getattr(settings, "JWT_ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_SECONDS = getattr(settings, "JWT_ACCESS_TOKEN_EXPIRE_SECONDS", 15 * 60) ACCESS_TOKEN_EXPIRE_SECONDS = getattr(
REFRESH_TOKEN_EXPIRE_SECONDS = getattr(settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60) 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
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
oauth2_optional_scheme = OptionalOAuth2PasswordBearer(tokenUrl="/auth/login")
def create_token(data: dict, expires_in: int) -> str: class ClaimsUser(BaseModel):
to_encode = data.copy() username: str
to_encode.update({"exp": int(time.time()) + expires_in}) 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
token = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM) token = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM)
if isinstance(token, bytes): if isinstance(token, bytes):
token = token.decode("utf-8") token = token.decode("utf-8")
@ -35,24 +105,44 @@ def decode_token(token: str) -> Dict:
try: try:
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError: 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: except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
)
async def get_token(token: str = Depends(oauth2_scheme)) -> Dict:
"""Dependency: token introspection"""
payload = decode_token(token)
return payload
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
"""Dependency: extract user from access token.""" """Dependency: extract user from access token."""
payload = decode_token(token) payload = decode_token(token)
username = payload.get("sub") user_id = payload.get("sub")
if username is None: if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload"
)
user = await User.filter(username=username).first() user = await User.filter(id=user_id).first()
if not user: 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 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: async def require_authenticated(current_user: User = Depends(get_current_user)) -> User:
"""Ensure the current user is an admin.""" """Ensure the current user is an admin."""
if not current_user: if not current_user:
@ -69,68 +159,82 @@ async def require_admin(current_user: User = Depends(get_current_user)) -> User:
async def require_staff(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.""" """Ensure the current user is a staff member."""
if not current_user.is_staff: if not current_user.is_admin and not current_user.is_staff:
raise HTTPException(403, "Staff privileges required") raise HTTPException(403, "Staff privileges required")
return current_user return current_user
async def require_group( async def require_group(
group_name: str, group_name: str, current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user)
) -> User: ) -> User:
"""Ensure the current user belongs to the given group.""" """Ensure the current user belongs to the given group."""
user_groups = await current_user.groups.all() user_groups = await current_user.groups.all()
if not any(g.name == group_name for g in user_groups): if not any(g.name == group_name for g in user_groups):
raise HTTPException( raise HTTPException(
status_code=403, status_code=403, detail=f"User must belong to group '{group_name}'"
detail=f"User must belong to group '{group_name}'"
) )
return current_user return current_user
class LoginRequest(BaseModel): @router.post("/login", response_model=RefreshToken)
username: str
password: str
@router.post("/login")
async def login(form_data: LoginRequest = Body(...)): async def login(form_data: LoginRequest = Body(...)):
"""Login with username & password, returns access and refresh tokens.""" """Login with username & password, returns access and refresh tokens."""
user = await User.authenticate(form_data.username, form_data.password) user = await User.authenticate(form_data.username, form_data.password)
if not user: 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({"sub": user.username, "type": "access"}, ACCESS_TOKEN_EXPIRE_SECONDS) access_token = create_token(
refresh_token = create_token({"sub": user.username, "type": "refresh"}, REFRESH_TOKEN_EXPIRE_SECONDS) claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
)
refresh_token = create_token(
claims(TokenType.refresh, user), REFRESH_TOKEN_EXPIRE_SECONDS
)
return { return RefreshToken(
"access_token": access_token, token_type="bearer",
"refresh_token": refresh_token, access_token=access_token,
"token_type": "bearer" refresh_token=refresh_token,
} )
@router.post("/refresh") class TokenRefresh(BaseModel):
async def refresh_token(refresh_token: str): refresh_token: str
@router.post("/refresh", response_model=AccessToken)
async def refresh_token(refresh_token: TokenRefresh = Body(...)):
"""Exchange refresh token for new access token.""" """Exchange refresh token for new access token."""
payload = decode_token(refresh_token) payload = decode_token(refresh_token.refresh_token)
if payload.get("type") != "refresh": if payload.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
)
user_id = payload.get("sub")
try:
user = await User.get(id=user_id)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
username = payload.get("sub")
user = await User.filter(username=username).first()
if not user: 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({"sub": user.username, "type": "access"}, ACCESS_TOKEN_EXPIRE_SECONDS) new_access = create_token(
return {"access_token": new_access, "token_type": "bearer"} claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
)
return AccessToken(token_type="bearer", access_token=new_access)
@router.get("/me") @router.get("/introspect", response_model=Dict[str, Any])
async def me(current_user: User = Depends(get_current_user)): 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)):
"""Return the currently authenticated user.""" """Return the currently authenticated user."""
return { return await User.Schema().from_tortoise_orm(user)
"username": current_user.username,
"is_admin": current_user.is_admin,
"is_staff": current_user.is_staff,
}

View file

@ -0,0 +1,17 @@
import base64
import hashlib
import hmac
import settings
SECRET_KEY = getattr(settings, "SECRET_KEY", "OhMyAPI Secret Key")
def hmac_hash(data: str) -> str:
digest = hmac.new(
SECRET_KEY.encode("UTF-8"),
data.encode("utf-8"),
hashlib.sha256,
).digest()
return base64.urlsafe_b64encode(digest).decode("utf-8")

View file

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

View file

@ -0,0 +1,51 @@
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

@ -0,0 +1,53 @@
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,27 +1,45 @@
import asyncio import asyncio
import atexit
import importlib import importlib
import sys import sys
from getpass import getpass
from pathlib import Path
import typer import typer
import uvicorn import uvicorn
from getpass import getpass from ohmyapi.core import runtime, scaffolding
from ohmyapi.core import scaffolding, runtime from ohmyapi.core.logging import setup_logging
from pathlib import Path
app = typer.Typer(help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM.") logger = setup_logging()
banner = """OhMyAPI Shell | Project: {project_name}"""
app = typer.Typer(
help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM."
)
@app.command() @app.command()
def startproject(name: str): 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) scaffolding.startproject(name)
logger.info(f"✅ Project '{name}' created successfully.")
logger.info(f"🔧 Next, configure your project in {name}/settings.py")
@app.command() @app.command()
def startapp(app_name: str, root: str = "."): 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) 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() @app.command()
@ -31,36 +49,58 @@ def serve(root: str = ".", host="127.0.0.1", port=8000):
""" """
project_path = Path(root) project_path = Path(root)
project = runtime.Project(project_path) project = runtime.Project(project_path)
app_instance = project.app() app_instance = project.configure_app(project.app())
uvicorn.run(app_instance, host=host, port=int(port), reload=False) uvicorn.run(app_instance, host=host, port=int(port), reload=False, log_config=None)
@app.command() @app.command()
def shell(root: str = "."): 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_path = Path(root).resolve()
project = runtime.Project(project_path) 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: try:
from IPython import start_ipython from IPython import start_ipython
shell_vars = {
"settings": project.settings,
"project": Path(project_path).resolve(),
}
from traitlets.config.loader import Config from traitlets.config.loader import Config
c = Config() c = Config()
c.TerminalIPythonApp.display_banner = True c.TerminalIPythonApp.display_banner = True
c.TerminalInteractiveShell.banner1 = banner.format(**{ c.TerminalInteractiveShell.banner2 = banner
"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) start_ipython(argv=[], user_ns=shell_vars, config=c)
except ImportError: except ImportError:
typer.echo("IPython is not installed. Falling back to built-in Python shell.")
import code import code
code.interact(local={"settings": project.settings})
code.interact(local=shell_vars, banner=banner)
finally:
loop.run_until_complete(cleanup())
@app.command() @app.command()
@ -75,6 +115,8 @@ def makemigrations(app: str = "*", name: str = "auto", root: str = "."):
asyncio.run(project.makemigrations(app_label=app, name=name)) asyncio.run(project.makemigrations(app_label=app, name=name))
else: else:
asyncio.run(project.makemigrations(app_label=app, name=name)) 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() @app.command()
@ -89,26 +131,47 @@ def migrate(app: str = "*", root: str = "."):
asyncio.run(project.migrate(app)) asyncio.run(project.migrate(app))
else: else:
asyncio.run(project.migrate(app)) asyncio.run(project.migrate(app))
logger.info(f"✅ Migrations ran successfully.")
@app.command() @app.command()
def createsuperuser(root: str = "."): 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_path = Path(root).resolve()
project = runtime.Project(project_path) project = runtime.Project(project_path)
if not project.is_app_installed("ohmyapi_auth"): 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 return
import asyncio import asyncio
import ohmyapi_auth import ohmyapi_auth
email = input("E-Mail: ")
username = input("Username: ") username = input("Username: ")
password = getpass("Password: ") password1, password2 = "foo", "bar"
user = ohmyapi_auth.models.User(username=username, is_staff=True, is_admin=True) while password1 != password2:
user.set_password(password) password1 = getpass("Password: ")
asyncio.run(project.init_orm()) password2 = getpass("Repeat Password: ")
asyncio.run(user.save()) if password1 != password2:
asyncio.run(project.close_orm()) 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)
def main(): async def _run():
app() 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

@ -0,0 +1,38 @@
import logging
import os
import sys
def setup_logging():
"""Configure unified logging for ohmyapi + FastAPI/Uvicorn."""
log_level = os.getenv("OHMYAPI_LOG_LEVEL", "INFO").upper()
level = getattr(logging, log_level, logging.INFO)
# Root logger (affects FastAPI, uvicorn, etc.)
logging.basicConfig(
level=level,
format="[%(asctime)s] [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.StreamHandler(sys.stdout)
]
)
# Separate ohmyapi logger (optional)
logger = logging.getLogger("ohmyapi")
# Direct warnings/errors to stderr
class LevelFilter(logging.Filter):
def filter(self, record):
# Send warnings+ to stderr, everything else to stdout
if record.levelno >= logging.WARNING:
record.stream = sys.stderr
else:
record.stream = sys.stdout
return True
for handler in logger.handlers:
handler.addFilter(LevelFilter())
logger.setLevel(level)
return logger

View file

@ -1,30 +1,38 @@
# ohmyapi/core/runtime.py # ohmyapi/core/runtime.py
import copy
import importlib import importlib
import importlib.util import importlib.util
import json
import pkgutil import pkgutil
import sys import sys
from http import HTTPStatus
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from types import ModuleType
from typing import Any, Dict, Generator, List, Optional, Tuple, Type
import click import click
from aerich import Command as AerichCommand from aerich import Command as AerichCommand
from aerich.exceptions import NotInitedError from aerich.exceptions import NotInitedError
from fastapi import APIRouter, FastAPI
from tortoise import Tortoise from tortoise import Tortoise
from fastapi import FastAPI, APIRouter
from ohmyapi.core.logging import setup_logging
from ohmyapi.db.model import Model from ohmyapi.db.model import Model
logger = setup_logging()
class Project: class Project:
""" """
Project runtime loader + Tortoise/Aerich integration. Project runtime loader + Tortoise/Aerich integration.
- injects builtin apps as ohmyapi_<name> - aliases builtin apps as ohmyapi_<name>
- builds unified tortoise config for runtime - loads all INSTALLED_APPS into scope
- builds unified tortoise config for ORM runtime
- provides makemigrations/migrate methods using Aerich Command API - provides makemigrations/migrate methods using Aerich Command API
""" """
def __init__(self, project_path: str): def __init__(self, project_path: str):
logger.debug(f"Loading project: {project_path}")
self.project_path = Path(project_path).resolve() self.project_path = Path(project_path).resolve()
self._apps: Dict[str, App] = {} self._apps: Dict[str, App] = {}
self.migrations_dir = self.project_path / "migrations" self.migrations_dir = self.project_path / "migrations"
@ -32,8 +40,16 @@ class Project:
if str(self.project_path) not in sys.path: if str(self.project_path) not in sys.path:
sys.path.insert(0, str(self.project_path)) sys.path.insert(0, str(self.project_path))
# Pre-register builtin apps as ohmyapi_<name>. # Load settings.py
# This makes all builtin apps easily loadable via f"ohmyapi_{app_name}". 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 `.`.
spec = importlib.util.find_spec("ohmyapi.builtin") spec = importlib.util.find_spec("ohmyapi.builtin")
if spec and spec.submodule_search_locations: if spec and spec.submodule_search_locations:
for _, modname, _ in pkgutil.iter_modules(spec.submodule_search_locations): for _, modname, _ in pkgutil.iter_modules(spec.submodule_search_locations):
@ -43,16 +59,12 @@ class Project:
orig = importlib.import_module(full) orig = importlib.import_module(full)
sys.modules[alias] = orig sys.modules[alias] = orig
try: 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: except ModuleNotFoundError:
pass 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 # Load installed apps
for app_name in getattr(self.settings, "INSTALLED_APPS", []): for app_name in getattr(self.settings, "INSTALLED_APPS", []):
self._apps[app_name] = App(self, name=app_name) self._apps[app_name] = App(self, name=app_name)
@ -64,23 +76,39 @@ class Project:
def is_app_installed(self, name: str) -> bool: def is_app_installed(self, name: str) -> bool:
return name in getattr(self.settings, "INSTALLED_APPS", []) return name in getattr(self.settings, "INSTALLED_APPS", [])
def app(self, generate_schemas: bool = False) -> FastAPI: def app(self,
docs_url: str = "/docs",
) -> FastAPI:
""" """
Create a FastAPI app, attach all APIRouters from registered apps, Create and return a FastAPI app.
and register ORM lifecycle event handlers.
""" """
app = FastAPI(title=getattr(self.settings, "PROJECT_NAME", "OhMyAPI Project")) import ohmyapi
return FastAPI(
title=getattr(self.settings, "PROJECT_NAME", "OhMyAPI Project"),
description=getattr(self.settings, "PROJECT_DESCRIPTION", ""),
docs_url=getattr(self.settings, "DOCS_URL", "/docs"),
version=ohmyapi.__VERSION__,
)
# Attach routers from apps def configure_app(self, app: FastAPI) -> FastAPI:
"""
Attach project middlewares and routes and event handlers to given
FastAPI instance.
"""
app.router.prefix = getattr(self.settings, "API_PREFIX", "")
# Attach project middlewares and routes.
for app_name, app_def in self._apps.items(): for app_name, app_def in self._apps.items():
if app_def.router: for middleware, kwargs in app_def.middlewares:
app.include_router(app_def.router) app.add_middleware(middleware, **kwargs)
for router in app_def.routers:
app.include_router(router)
# Startup / shutdown events # Initialize ORM on startup
@app.on_event("startup") @app.on_event("startup")
async def _startup(): async def _startup():
await self.init_orm(generate_schemas=generate_schemas) await self.init_orm(generate_schemas=False)
# Close ORM on shutdown
@app.on_event("shutdown") @app.on_event("shutdown")
async def _shutdown(): async def _shutdown():
await self.close_orm() await self.close_orm()
@ -101,34 +129,43 @@ class Project:
} }
for app_name, app in self._apps.items(): for app_name, app in self._apps.items():
modules = list(dict.fromkeys(app.model_modules)) modules = list(app._models.keys())
if modules: if modules:
config["apps"][app_name] = {"models": modules, "default_connection": "default"} config["apps"][app_name] = {
"models": modules,
"default_connection": "default",
}
return config return config
def build_aerich_command(self, app_label: str, db_url: Optional[str] = None) -> AerichCommand: def build_aerich_command(
# Resolve label to flat_label self, app_label: str, db_url: Optional[str] = None
if app_label in self._apps: ) -> AerichCommand:
flat_label = app_label """
else: Build Aerich command for app with given app_label.
candidate = app_label.replace(".", "_")
if candidate in self._apps: Aerich needs to see only the app of interest, but with the extra model
flat_label = candidate "aerich.models".
else: """
raise RuntimeError(f"App '{app_label}' is not registered") if app_label not in self._apps:
raise RuntimeError(f"App '{app_label}' is not registered")
# Get a fresh copy of the config (without aerich.models anywhere) # Get a fresh copy of the config (without aerich.models anywhere)
tortoise_cfg = copy.deepcopy(self.build_tortoise_config(db_url=db_url)) tortoise_cfg = self.build_tortoise_config(db_url=db_url)
# 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": []}}
# Append aerich.models to the models list of the target app only # Append aerich.models to the models list of the target app only
if flat_label in tortoise_cfg["apps"]: tortoise_cfg["apps"][app_label]["models"].append("aerich.models")
tortoise_cfg["apps"][flat_label]["models"].append("aerich.models")
return AerichCommand( return AerichCommand(
tortoise_config=tortoise_cfg, tortoise_config=tortoise_cfg,
app=flat_label, app=app_label,
location=str(self.migrations_dir) location=str(self.migrations_dir),
) )
# --- ORM lifecycle --- # --- ORM lifecycle ---
@ -143,7 +180,9 @@ class Project:
await Tortoise.close_connections() await Tortoise.close_connections()
# --- Migration helpers --- # --- 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) cmd = self.build_aerich_command(app_label, db_url=db_url)
async with cmd as c: async with cmd as c:
await c.init() await c.init()
@ -157,7 +196,9 @@ class Project:
await c.init_db(safe=True) await c.init_db(safe=True)
await c.migrate(name=name) 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] labels: List[str]
if app_label: if app_label:
if app_label in self._apps: if app_label in self._apps:
@ -192,56 +233,186 @@ class App:
self.project = project self.project = project
self.name = name self.name = name
# The list of module paths (e.g. "ohmyapi_auth.models") for Tortoise and Aerich # Reference to this app's models modules. Tortoise needs to know the
self.model_modules: List[str] = [] # modules where to lookup models for this app.
self._models: Dict[str, ModuleType] = {}
# The APIRouter # Reference to this app's routes modules.
self.router: Optional[APIRouter] = None self._routers: Dict[str, ModuleType] = {}
# Reference to this apps middlewares.
self._middlewares: List[Tuple[Any, Dict[str, Any]]] = []
# Import the app, so its __init__.py runs. # Import the app, so its __init__.py runs.
importlib.import_module(self.name) mod: ModuleType = importlib.import_module(name)
# Load the models logger.debug(f"Loading app: {self.name}")
try: self.__load_models(f"{self.name}.models")
models_mod = importlib.import_module(f"{self.name}.models") self.__load_routes(f"{self.name}.routes")
self.model_modules.append(f"{self.name}.models") self.__load_middlewares(f"{self.name}.middlewares")
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): def __repr__(self):
out = "" return json.dumps(self.dict(), indent=2)
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): def __str__(self):
return self.__repr__() 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 @property
def models(self) -> List[Model]: def models(self) -> List[ModuleType]:
models: List[Model] = [] """
for mod in self.model_modules: Return a list of all loaded models.
models_mod = importlib.import_module(mod) """
for obj in models_mod.__dict__.values(): out = []
if isinstance(obj, type) and getattr(obj, "_meta", None) is not None and obj.__name__ != 'Model': for module in self._models:
models.append(obj) for model in self._models[module]:
return models 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
@property @property
def routes(self): def routes(self):
return self.router.routes """
Return an APIRouter with all loaded routes.
"""
out = []
for r in self.routers:
out.extend(r.routes)
return out
@property
def middlewares(self):
"""Returns the list of this app's middlewares."""
return self._middlewares
def dict(self) -> Dict[str, Any]:
"""
Convenience method for serializing the runtime data.
"""
# An app may come without any models
models = []
if f"{self.name}.models" in self._models:
models = [
f"{self.name}.{m.__name__}"
for m in self._models[f"{self.name}.models"]
]
return {
"models": models,
"middlewares": self.__serialize_middleware(),
"routes": self.__serialize_router(),
}

View file

@ -1,37 +1,54 @@
import os
from pathlib import Path from pathlib import Path
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from ohmyapi.core.logging import setup_logging
import shutil
# Base templates directory # Base templates directory
TEMPLATE_DIR = Path(__file__).parent / "templates" TEMPLATE_DIR = Path(__file__).parent / "templates"
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR))) env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
logger = setup_logging()
def render_template_file(template_path: Path, context: dict, output_path: Path): def render_template_file(template_path: Path, context: dict, output_path: Path):
"""Render a single Jinja2 template file to disk.""" """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) content = template.render(**context)
os.makedirs(output_path.parent, exist_ok=True) output_path.parent.mkdir(exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
f.write(content) 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. 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. If subdir_name is given, files are placed inside target_dir/subdir_name.
""" """
template_dir = TEMPLATE_DIR / template_subdir template_dir = TEMPLATE_DIR / template_subdir
for root, _, files in os.walk(template_dir): for root, _, files in template_dir.walk():
root_path = Path(root) 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: for f in files:
if not f.endswith(".j2"): if not f.endswith(".j2"):
continue continue
template_rel_path = rel_root / f 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 # optionally wrap in subdir_name
if subdir_name: if subdir_name:
@ -42,20 +59,38 @@ def render_template_dir(template_subdir: str, target_dir: Path, context: dict, s
render_template_file(template_dir / template_rel_path, context, output_path) 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): def startproject(name: str):
"""Create a new project: flat structure, all project templates go into <name>/""" """Create a new project: flat structure, all project templates go into <name>/"""
target_dir = Path(name).resolve() target_dir = Path(name).resolve()
os.makedirs(target_dir, exist_ok=True) target_dir.mkdir(exist_ok=True)
render_template_dir("project", target_dir, {"project_name": name}) 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): def startapp(name: str, project: str):
"""Create a new app inside a project: templates go into <project_dir>/<name>/""" """Create a new app inside a project: templates go into <project_dir>/<name>/"""
target_dir = Path(project) target_dir = Path(project)
os.makedirs(target_dir, exist_ok=True) target_dir.mkdir(exist_ok=True)
render_template_dir("app", target_dir, {"project_name": target_dir.resolve().name, "app_name": name}, subdir_name=name) render_template_dir(
print(f"✅ App '{name}' created in project '{target_dir}' successfully.") "app",
print(f"🔧 Remember to add '{name}' to your INSTALLED_APPS!") target_dir,
{"project_name": target_dir.resolve().name, "app_name": name},
subdir_name=name,
)

View file

@ -1,13 +1,44 @@
from ohmyapi.router import APIRouter from ohmyapi.router import APIRouter, HTTPException, HTTPStatus
from . import models from . import models
router = APIRouter(prefix="/{{ app_name }}") 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.get("/") @router.get("/", response_model=List)
def ping(): async def list():
return { """List all ..."""
"project": "{{ project_name }}", return []
"app": "{{ app_name }}",
}
@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)

View file

@ -0,0 +1,29 @@
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

@ -0,0 +1,17 @@
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

@ -0,0 +1,2 @@
# {{ project_name }}

View file

@ -1,13 +1,26 @@
[tool.poetry] [project]
name = "{{ project_name }}" name = "{{ project_name }}"
version = "0.1.0" version = "0.1.0"
description = "OhMyAPI project" description = "OhMyAPI project"
authors = ["You <you@example.com>"] authors = [
{ name = "You", email = "you@you.tld" }
]
requires-python = ">=3.13"
readme = "README.md"
license = { text = "MIT" }
[tool.poetry.dependencies] dependencies = [
python = "^3.10" # "asyncpg"
fastapi = "^0.115" ]
uvicorn = "^0.30"
tortoise-orm = "^0.20"
aerich = "^0.7"
[tool.poetry.group.dev.dependencies]
ipython = ">=9.5.0,<10.0.0"
[project.optional-dependencies]
auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"]
[tool.poetry]
package-mode = false
[project.scripts]
{{ project_name }} = "ohmyapi.cli:app"

View file

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

View file

@ -1,3 +1,12 @@
from tortoise import fields as field from tortoise.expressions import Q
from .model import Model 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

View file

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

View file

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

View file

@ -1,90 +0,0 @@
import asyncio
from pathlib import Path
from aerich import Command
from ohmyapi.core import runtime
class MigrationManager:
def __init__(self, project):
self.project = project
self._commands = {}
# Compute tortoise_config grouped by app module
self._tortoise_config = self._build_tortoise_config()
def _build_tortoise_config(self) -> dict:
"""
Build Tortoise config from the flat model_registry,
grouping models by app module for Aerich compatibility.
"""
db_url = self.project.settings.DATABASE_URL
registry = self.project.model_registry # flat: model_path -> class
apps_modules = {}
for model_path, model_cls in registry.items():
if not isinstance(model_cls, type):
raise TypeError(f"Registry value must be a class, got {type(model_cls)}: {model_cls}")
# Extract app module by removing the model class name
# Example: 'ohmyapi.apps.auth.User' -> 'ohmyapi.apps.auth'
app_module = ".".join(model_path.split(".")[:-1])
apps_modules.setdefault(app_module, []).append(model_cls)
# Build Tortoise config
apps_config = {}
for app_module, models in apps_modules.items():
modules_set = set(m.__module__ for m in models)
apps_config[app_module] = {
"models": list(modules_set),
"default_connection": "default",
}
return {
"connections": {"default": db_url},
"apps": apps_config,
}
def get_apps(self):
"""Return app modules extracted from the registry"""
return list(self._tortoise_config["apps"].keys())
def get_migration_location(self, app_module: str) -> str:
"""Return the path to the app's migrations folder"""
try:
module = __import__(app_module, fromlist=["migrations"])
if not hasattr(module, "__file__") or module.__file__ is None:
raise ValueError(f"Cannot determine filesystem path for app '{app_module}'")
app_path = Path(module.__file__).parent
migrations_path = app_path / "migrations"
migrations_path.mkdir(exist_ok=True)
return str(migrations_path)
except ModuleNotFoundError:
raise ValueError(f"App module '{app_module}' cannot be imported")
async def init_app_command(self, app_module: str) -> Command:
"""Initialize Aerich command for a specific app module"""
location = self.get_migration_location(app_module)
cmd = Command(
tortoise_config=self._tortoise_config,
app=app_module,
location=location,
)
await cmd.init()
self._commands[app_module] = cmd
return cmd
async def makemigrations(self, app_module: str):
"""Generate migrations for a specific app"""
cmd = self._commands.get(app_module) or await self.init_app_command(app_module)
await cmd.migrate()
async def migrate(self, app_module: str = None):
"""Apply migrations. If app_module is None, migrate all apps"""
apps_to_migrate = [app_module] if app_module else self.get_apps()
for app in apps_to_migrate:
cmd = self._commands.get(app) or await self.init_app_command(app)
await cmd.upgrade()
async def show_migrations(self, app_module: str):
"""List migrations for an app"""
cmd = self._commands.get(app_module) or await self.init_app_command(app_module)
await cmd.history()

View file

@ -1 +1 @@
from .model import Model, fields from .model import Model, field

View file

@ -1,39 +1,87 @@
from tortoise import fields from uuid import UUID
from tortoise.models import Model as TortoiseModel
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.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)
class ModelMeta(type(TortoiseModel)): class ModelMeta(type(TortoiseModel)):
def __new__(cls, name, bases, attrs): def __new__(mcls, name, bases, attrs):
new_cls = super().__new__(cls, name, bases, attrs) meta = attrs.get("Meta", None)
if meta is None:
class Meta:
pass
meta = Meta
attrs["Meta"] = meta
schema_opts = getattr(new_cls, "Schema", None) 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)
class BoundSchema: class BoundSchema:
def __call__(self, readonly: bool = False):
return self.get(readonly)
@property @property
def one(self): def model(self):
"""Return a Pydantic model class for 'one' results.""" """Return a Pydantic model class for serializing results."""
include = getattr(schema_opts, "include", None) include = getattr(schema_opts, "include", None)
exclude = getattr(schema_opts, "exclude", None) exclude = getattr(schema_opts, "exclude", None)
return pydantic_model_creator( return pydantic_model_creator(
new_cls, new_cls,
name=f"{new_cls.__name__}SchemaOne", name=f"{new_cls.__name__}Schema",
include=include,
exclude=exclude,
)
@property
def readonly(self):
"""Return a Pydantic model class for serializing readonly results."""
include = getattr(schema_opts, "include", None)
exclude = getattr(schema_opts, "exclude", None)
return pydantic_model_creator(
new_cls,
name=f"{new_cls.__name__}SchemaReadonly",
include=include, include=include,
exclude=exclude, exclude=exclude,
exclude_readonly=True, exclude_readonly=True,
) )
@property def get(self, readonly: bool = False):
def many(self): if readonly:
"""Return a Pydantic queryset class for 'many' results.""" return self.readonly
include = getattr(schema_opts, "include", None) return self.model
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() new_cls.Schema = BoundSchema()
return new_cls return new_cls
@ -43,4 +91,3 @@ class Model(TortoiseModel, metaclass=ModelMeta):
class Schema: class Schema:
include = None include = None
exclude = None exclude = None

View file

@ -0,0 +1,26 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from typing import Any, Dict, List, Tuple
import settings
DEFAULT_ORIGINS = ["http://localhost", "http://localhost:8000"]
DEFAULT_CREDENTIALS = False
DEFAULT_METHODS = ["*"]
DEFAULT_HEADERS = ["*"]
CORS_CONFIG: Dict[str, Any] = getattr(settings, "MIDDLEWARE_CORS", {})
if not isinstance(CORS_CONFIG, dict):
raise ValueError("MIDDLEWARE_CORS must be of type dict")
middleware = (
CORSMiddleware,
{
"allow_origins": CORS_CONFIG.get("ALLOW_ORIGINS", DEFAULT_ORIGINS),
"allow_credentials": CORS_CONFIG.get("ALLOW_CREDENTIALS", DEFAULT_CREDENTIALS),
"allow_methods": CORS_CONFIG.get("ALLOW_METHODS", DEFAULT_METHODS),
"allow_headers": CORS_CONFIG.get("ALLOW_HEADERS", DEFAULT_HEADERS),
}
)

View file

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