diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml new file mode 100644 index 0000000..cc44f46 --- /dev/null +++ b/.github/workflows/doc.yml @@ -0,0 +1,45 @@ +name: Build & Publish Doc + +on: + push: + branches: + - main + - docs/** + +jobs: + + Build-Doc: + runs-on: ubuntu-latest + concurrency: release + permissions: + contents: write + id-token: write + steps: + - name: 📥 checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: 🔧 setup uv + uses: ./.github/uv + - name: ⚙️ install deps + run: uv sync --extra docs + - name: 📙 mkdocs build + run: uv run mkdocs build + - name: 📦 Upload artifacts + uses: actions/upload-pages-artifact@v3 + with: + path: site + + Publish-Doc: + needs: Build-Doc + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} \ No newline at end of file diff --git a/README.md b/README.md index 85c29bd..b2e920a 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,119 @@ -# 🚀 FastSQLA +# FastSQLA + +_Async SQLAlchemy 2 for FastAPI — boilerplate, pagination, and seamless session management._ [![PyPI - Version](https://img.shields.io/pypi/v/FastSQLA?color=brightgreen)](https://pypi.org/project/FastSQLA/) +[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/hadrien/fastsqla/ci.yml?branch=main&logo=github&label=CI)](https://github.com/hadrien/FastSQLA/actions?query=branch%3Amain+event%3Apush) +[![Codecov](https://img.shields.io/codecov/c/github/hadrien/fastsqla?token=XK3YT60MWK&logo=codecov)](https://codecov.io/gh/hadrien/FastSQLA) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-brightgreen.svg)](https://conventionalcommits.org) -[![codecov](https://codecov.io/gh/hadrien/fastsqla/graph/badge.svg?token=XK3YT60MWK)](https://codecov.io/gh/hadrien/fastsqla) +[![GitHub License](https://img.shields.io/github/license/hadrien/fastsqla)](https://github.com/hadrien/FastSQLA/blob/main/LICENSE) +![🍁 With love from Canada](https://img.shields.io/badge/With%20love%20from%20Canada-ffffff?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2MDAiIGhlaWdodD0iNjAwIiB2aWV3Qm94PSItMjAxNSAtMjAwMCA0MDMwIDQwMzAiPjxwYXRoIGZpbGw9IiNmMDAiIGQ9Im0tOTAgMjAzMCA0NS04NjNhOTUgOTUgMCAwIDAtMTExLTk4bC04NTkgMTUxIDExNi0zMjBhNjUgNjUgMCAwIDAtMjAtNzNsLTk0MS03NjIgMjEyLTk5YTY1IDY1IDAgMCAwIDM0LTc5bC0xODYtNTcyIDU0MiAxMTVhNjUgNjUgMCAwIDAgNzMtMzhsMTA1LTI0NyA0MjMgNDU0YTY1IDY1IDAgMCAwIDExMS01N2wtMjA0LTEwNTIgMzI3IDE4OWE2NSA2NSAwIDAgMCA5MS0yN2wzMzItNjUyIDMzMiA2NTJhNjUgNjUgMCAwIDAgOTEgMjdsMzI3LTE4OS0yMDQgMTA1MmE2NSA2NSAwIDAgMCAxMTEgNTdsNDIzLTQ1NCAxMDUgMjQ3YTY1IDY1IDAgMCAwIDczIDM4bDU0Mi0xMTUtMTg2IDU3MmE2NSA2NSAwIDAgMCAzNCA3OWwyMTIgOTktOTQxIDc2MmE2NSA2NSAwIDAgMC0yMCA3M2wxMTYgMzIwLTg1OS0xNTFhOTUgOTUgMCAwIDAtMTExIDk4bDQ1IDg2M3oiLz48L3N2Zz4K) + +**Documentation**: [https://hadrien.github.io/FastSQLA/](https://hadrien.github.io/FastSQLA/) + +**Github Repo:** [https://github.com/hadrien/fastsqla](https://github.com/hadrien/fastsqla) + +----------------------------------------------------------------------------------------- + +`FastSQLA` is an [`SQLAlchemy 2`](https://docs.sqlalchemy.org/en/20/) extension for +[`FastAPI`](https://fastapi.tiangolo.com/). +It streamlines the configuration and asynchronous connection to relational databases by +providing boilerplate and intuitive helpers. Additionally, it offers built-in +customizable pagination and automatically manages the `SQLAlchemy` session lifecycle +following [`SQLAlchemy`'s best practices](https://docs.sqlalchemy.org/en/20/orm/session_basics.html#when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it). -`FastSQLA` is an [`SQLAlchemy`] extension for [`FastAPI`]. -It supports asynchronous `SQLAlchemy` sessions and includes built-in custimizable -pagination. ## Features -
- Automatic SQLAlchemy configuration at app startup. +* Easy setup at app startup using + [`FastAPI` Lifespan](https://fastapi.tiangolo.com/advanced/events/#lifespan): - Using [`FastAPI` Lifespan](https://fastapi.tiangolo.com/advanced/events/#lifespan): -```python -from fastapi import FastAPI -from fastsqla import lifespan + ```python + from fastapi import FastAPI + from fastsqla import lifespan -app = FastAPI(lifespan=lifespan) -``` -
-
- Async SQLAlchemy session as a FastAPI dependency. + app = FastAPI(lifespan=lifespan) + ``` -```python -... -from fastsqla import Session -from sqlalchemy import select -... +* `SQLAlchemy` async session dependency: -@app.get("/heros") -async def get_heros(session:Session): - stmt = select(...) - result = await session.execute(stmt) + ```python + ... + from fastsqla import Session + from sqlalchemy import select ... -``` -
-
- Built-in pagination. -```python -... -from fastsqla import Page, Paginate -from sqlalchemy import select -... + @app.get("/heros") + async def get_heros(session:Session): + stmt = select(...) + result = await session.execute(stmt) + ... + ``` -@app.get("/heros", response_model=Page[HeroModel]) -async def get_heros(paginate:Paginate): - return paginate(select(Hero)) -``` -
-
- Allows pagination customization. +* `SQLAlchemy` async session with an async context manager: -```python -... -from fastapi import Page, new_pagination -... + ```python + from fastsqla import open_session -Paginate = new_pagination(min_page_size=5, max_page_size=500) + async def background_job(): + async with open_session() as session: + stmt = select(...) + result = await session.execute(stmt) + ... + ``` -@app.get("/heros", response_model=Page[HeroModel]) -async def get_heros(paginate:Paginate): - return paginate(select(Hero)) -``` -
+* Built-in pagination: + + ```python + ... + from fastsqla import Page, Paginate + from sqlalchemy import select + ... + + @app.get("/heros", response_model=Page[HeroModel]) + async def get_heros(paginate:Paginate): + return await paginate(select(Hero)) + ``` +
👇👇👇
+ ```json + // /heros?offset=10&limit=10 + { + "data": [ + { + "name": "The Flash", + "secret_identity": "Barry Allen", + "id": 11 + }, + { + "name": "Green Lantern", + "secret_identity": "Hal Jordan", + "id": 12 + } + ], + "meta": { + "offset": 10, + "total_items": 12, + "total_pages": 2, + "page_number": 2 + } + } + ``` + +* Pagination customization: + ```python + ... + from fastapi import Page, new_pagination + ... + + Paginate = new_pagination(min_page_size=5, max_page_size=500) + + @app.get("/heros", response_model=Page[HeroModel]) + async def get_heros(paginate:Paginate): + return paginate(select(Hero)) + ``` +* Session lifecycle management: session is commited on request success or rollback on + failure. -And more ... - ## Installing @@ -84,17 +129,21 @@ pip install fastsqla ## Quick Example +### `example.py` + +Let's write some tiny app in `example.py`: + ```python # example.py from http import HTTPStatus from fastapi import FastAPI, HTTPException +from fastsqla import Base, Item, Page, Paginate, Session, lifespan from pydantic import BaseModel, ConfigDict from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Mapped, mapped_column -from fastsqla import Base, Item, Page, Paginate, Session, lifespan app = FastAPI(lifespan=lifespan) @@ -104,11 +153,13 @@ class Hero(Base): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(unique=True) secret_identity: Mapped[str] + age: Mapped[int] class HeroBase(BaseModel): name: str secret_identity: str + age: int class HeroModel(HeroBase): @@ -117,12 +168,13 @@ class HeroModel(HeroBase): @app.get("/heros", response_model=Page[HeroModel]) -async def list_users(paginate: Paginate): - return await paginate(select(Hero)) +async def list_heros(paginate: Paginate): + stmt = select(Hero) + return await paginate(stmt) @app.get("/heros/{hero_id}", response_model=Item[HeroModel]) -async def get_user(hero_id: int, session: Session): +async def get_hero(hero_id: int, session: Session): hero = await session.get(Hero, hero_id) if hero is None: raise HTTPException(HTTPStatus.NOT_FOUND, "Hero not found") @@ -130,7 +182,7 @@ async def get_user(hero_id: int, session: Session): @app.post("/heros", response_model=Item[HeroModel]) -async def create_user(new_hero: HeroBase, session: Session): +async def create_hero(new_hero: HeroBase, session: Session): hero = Hero(**new_hero.model_dump()) session.add(hero) try: @@ -140,55 +192,56 @@ async def create_user(new_hero: HeroBase, session: Session): return {"data": hero} ``` -> [!NOTE] -> Sqlite is used for the sake of the example. -> FastSQLA is compatible with all async db drivers that SQLAlchemy is compatible with. +### Database + +💡 This example uses an `SQLite` database for simplicity: `FastSQLA` is compatible with +all asynchronous db drivers that `SQLAlchemy` is compatible with. -
- Create an sqlite3 db: +Let's create an `SQLite` database using `sqlite3` and insert 12 rows in the `hero` table: ```bash sqlite3 db.sqlite < - -
- Install dependencies & run the app +### Run the app +Let's install required dependencies: ```bash pip install uvicorn aiosqlite fastsqla -sqlalchemy_url=sqlite+aiosqlite:///db.sqlite?check_same_thread=false uvicorn example:app +``` +Let's run the app: +``` +sqlalchemy_url=sqlite+aiosqlite:///db.sqlite?check_same_thread=false \ + uvicorn example:app ``` -
- -Execute `GET /heros?offset=10`: +### Check the result +Execute `GET /heros?offset=10&limit=10` using `curl`: ```bash -curl -X 'GET' \ -'http://127.0.0.1:8000/heros?offset=10&limit=10' \ --H 'accept: application/json' +curl -X 'GET' -H 'accept: application/json' 'http://127.0.0.1:8000/heros?offset=10&limit=10' ``` Returns: ```json @@ -214,5 +267,11 @@ Returns: } ``` -[`FastAPI`]: https://fastapi.tiangolo.com/ -[`SQLAlchemy`]: http://sqlalchemy.org/ +You can also check the generated openapi doc by opening your browser to +[http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs). + +![OpenAPI generated documentation of the example API](images/example-openapi-generated-doc.png) + +## License + +This project is licensed under the terms of the [MIT license](https://github.com/hadrien/FastSQLA/blob/main/LICENSE). diff --git a/CHANGELOG.md b/docs/changelog.md similarity index 99% rename from CHANGELOG.md rename to docs/changelog.md index 82e1f68..55703c1 100644 --- a/CHANGELOG.md +++ b/docs/changelog.md @@ -1,4 +1,4 @@ -# CHANGELOG +# Changelog ## v0.2.4 (2025-01-27) diff --git a/docs/images/example-openapi-generated-doc.png b/docs/images/example-openapi-generated-doc.png new file mode 100644 index 0000000..2067e77 Binary files /dev/null and b/docs/images/example-openapi-generated-doc.png differ diff --git a/docs/images/favicon.png b/docs/images/favicon.png new file mode 100644 index 0000000..4e3200c Binary files /dev/null and b/docs/images/favicon.png differ diff --git a/docs/index.md b/docs/index.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/docs/orm.md b/docs/orm.md new file mode 100644 index 0000000..f4c9e4f --- /dev/null +++ b/docs/orm.md @@ -0,0 +1,9 @@ +# Object-relational mapping + +## `fastsqla.Base` + +::: fastsqla.Base + options: + heading_level: false + show_source: false + show_bases: false diff --git a/docs/pagination.md b/docs/pagination.md new file mode 100644 index 0000000..38fcf45 --- /dev/null +++ b/docs/pagination.md @@ -0,0 +1,8 @@ +# Pagination + +## `fastsqla.Paginate[T]` + +::: fastsqla.Paginate + options: + heading_level: false + show_source: false diff --git a/docs/session.md b/docs/session.md new file mode 100644 index 0000000..de3b621 --- /dev/null +++ b/docs/session.md @@ -0,0 +1,39 @@ +# `SQLAlchemy` Session + +## Lifecycle + +[`SQLAlchemy` documentation](https://docs.sqlalchemy.org/en/20/orm/session_basics.html#when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it) +recommends the following: + +* Keep the lifecycle of the session **separate and external** from functions and + objects that access and/or manipulate database data. +* Make sure you have a clear notion of where transactions begin and end, and keep + transactions **short**, meaning, they end at the series of a sequence of operations, + instead of being held open indefinitely. + +`FastSQLA` automatically manages the session lifecycle: + +* If the request is successful, the session is committed. +* If the request fails, the session is rolled back. +* In all cases, at the end of the request, the session is closed and the associated + connection is returned to the connection pool. + + +To learn more about `SQLAlchemy` sessions: + +* [Session Basics](https://docs.sqlalchemy.org/en/20/orm/session_basics.html#) + + +## `fastsqla.Session` dependency + +::: fastsqla.Session + options: + heading_level: false + show_source: false + +## `fastsqla.open_session` context manager + +::: fastsqla.open_session + options: + heading_level: false + show_source: false diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..0f0b2ec --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,55 @@ +# Setup + +## `fastsqla.lifespan` + +::: fastsqla.lifespan + options: + heading_level: false + show_source: false + +## Configuration + +Configuration is done exclusively via environment variables, adhering to the +[**Twelve-Factor App methodology**](https://12factor.net/config). + +The only required key is **`SQLALCHEMY_URL`**, which defines the database URL. It +specifies the database driver in the URL's scheme and allows embedding driver parameters +in the query string. Example: + + sqlite+aiosqlite:////tmp/test.db?check_same_thread=false + +All parameters of [`sqlalchemy.create_engine`][] can be configured by setting environment +variables, with each parameter name prefixed by **`SQLALCHEMY_`**. + +!!! note + + FastSQLA is **case-insensitive** when reading environment variables, so parameter + names prefixed with **`SQLALCHEMY_`** can be provided in any letter case. + +### Examples + +1. :simple-postgresql: PostgreSQL url using + [`asyncpg`][sqlalchemy.dialects.postgresql.asyncpg] driver with a + [`pool_recycle`][sqlalchemy.create_engine.params.pool_recycle] of 30 minutes: + + ```bash + export SQLALCHEMY_URL=postgresql+asyncpg://postgres@localhost/postgres + export SQLALCHEMY_POOL_RECYCLE=1800 + ``` + +2. :simple-sqlite: SQLite db file using + [`aiosqlite`][sqlalchemy.dialects.sqlite.aiosqlite] driver with a + [`pool_size`][sqlalchemy.create_engine.params.pool_size] of 50: + + ```bash + export sqlalchemy_url=sqlite+aiosqlite:///tmp/test.db?check_same_thread=false + export sqlalchemy_pool_size=10 + ``` + +3. :simple-mariadb: MariaDB url using [`aiomysql`][sqlalchemy.dialects.mysql.aiomysql] + driver with [`echo`][sqlalchemy.create_engine.params.echo] parameter set to `True` + + ```bash + export sqlalchemy_url=mysql+aiomysql://bob:password!@db.example.com/app + export sqlalchemy_echo=true + ``` diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..ce8f493 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,90 @@ +site_name: FastSQLA +site_url: https://mydomain.org/FastSQLA +repo_name: hadrien/FastSQLA +repo_url: https://github.com/hadrien/fastsqla +edit_uri: edit/main/docs/ + +nav: +- Get Started: + - Welcome to FastSQLA: index.md +- Usage: + - Setup: setup.md + - Object-relational mapping: orm.md + - SQLAlchemy Session: session.md + - Pagination: pagination.md +- Changelog: changelog.md + +theme: + favicon: images/favicon.png + icon: + logo: material/database + name: material + features: + - announce.dismiss + - content.code.annotate + - content.code.copy + - content.tabs.link + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress + - navigation.path + - navigation.sections + - navigation.tabs + - navigation.top + - navigation.tracking + - search.suggest + - toc.follow + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/weather-sunny + name: Switch to dark mode + + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/weather-night + name: Switch to system preference + +plugins: + - autorefs + - glightbox + - mkdocstrings: + enable_inventory: true + handlers: + python: + import: + - https://docs.python.org/3/objects.inv + - https://docs.sqlalchemy.org/en/20/objects.inv + - https://fastapi.tiangolo.com/objects.inv + - search + +markdown_extensions: + - abbr + - admonition + - attr_list + - md_in_html + - pymdownx.blocks.caption + - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + + - toc: + permalink: true +watch: + - docs + - src diff --git a/pyproject.toml b/pyproject.toml index 7584534..cb07363 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,13 @@ Repository = "https://github.com/hadrien/fastsqla" Issues = "https://github.com/hadrien/fastsqla/issues" Changelog = "https://github.com/hadrien/fastsqla/releases" +[project.optional-dependencies] +docs = [ + "mkdocs-glightbox>=0.4.0", + "mkdocs-material>=9.5.50", + "mkdocstrings[python]>=0.27.0", +] + [tool.uv] package = true dev-dependencies = [ @@ -76,3 +83,6 @@ env = "GH_TOKEN" [tool.semantic_release] version_toml = ["pyproject.toml:project.version"] + +[tool.semantic_release.changelog.default_templates] +changelog_file = "./docs/changelog.md" diff --git a/src/fastsqla.py b/src/fastsqla.py index e8e6269..636afd4 100644 --- a/src/fastsqla.py +++ b/src/fastsqla.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager from typing import Annotated, Generic, TypeVar, TypedDict -from fastapi import Depends, Query +from fastapi import Depends, FastAPI, Query from pydantic import BaseModel, Field from sqlalchemy import Result, Select, func, select from sqlalchemy.ext.asyncio import ( @@ -36,6 +36,28 @@ class Base(DeclarativeBase, DeferredReflection): + """Inherit from `Base` to declare an `SQLAlchemy` model. + + Example: + ```py + from fastsqla import Base + from sqlalchemy.orm import Mapped, mapped_column + + + class Hero(Base): + __tablename__ = "hero" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(unique=True) + secret_identity: Mapped[str] + age: Mapped[int] + ``` + + To learn more on `SQLAlchemy` ORM & Declarative mapping: + + * [ORM Quick Start](https://docs.sqlalchemy.org/en/20/orm/quickstart.html) + * [Declarative Mapping](https://docs.sqlalchemy.org/en/20/orm/mapping_styles.html#declarative-mapping) + """ + __abstract__ = True @@ -44,7 +66,57 @@ class State(TypedDict): @asynccontextmanager -async def lifespan(_) -> AsyncGenerator[State, None]: +async def lifespan(app: FastAPI) -> AsyncGenerator[State, None]: + """Use `fastsqla.lifespan` to set up SQLAlchemy. + + In an ASGI application, [lifespan events](https://asgi.readthedocs.io/en/latest/specs/lifespan.html) + are used to communicate startup & shutdown events. + + The [`lifespan`](https://fastapi.tiangolo.com/advanced/events/#lifespan) parameter of + the `FastAPI` app can be assigned to a context manager, which is opened when the app + starts and closed when the app stops. + + In order for `FastSQLA` to setup `SQLAlchemy` before the app is started, set + `lifespan` parameter to `fastsqla.lifespan`: + + ```python + from fastapi import FastAPI + from fastsqla import lifespan + + + app = FastAPI(lifespan=lifespan) + ``` + + If multiple lifespan contexts are required, create an async context manager function + to handle them and set it as the app's lifespan: + + ```python + from collections.abc import AsyncGenerator + from contextlib import asynccontextmanager + + from fastapi import FastAPI + from fastsqla import lifespan as fastsqla_lifespan + from this_other_library import another_lifespan + + + @asynccontextmanager + async def lifespan(app:FastAPI) -> AsyncGenerator[dict, None]: + async with AsyncExitStack() as stack: + yield { + **stack.enter_async_context(lifespan(app)), + **stack.enter_async_context(another_lifespan(app)), + } + + + app = FastAPI(lifespan=lifespan) + ``` + + To learn more about lifespan protocol: + + * [Lifespan Protocol](https://asgi.readthedocs.io/en/latest/specs/lifespan.html) + * [Use Lifespan State instead of `app.state`](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#6-use-lifespan-state-instead-of-appstate) + * [FastAPI lifespan documentation](https://fastapi.tiangolo.com/advanced/events/) + """ prefix = "sqlalchemy_" sqla_config = {k.lower(): v for k, v in os.environ.items()} try: @@ -70,6 +142,25 @@ async def lifespan(_) -> AsyncGenerator[State, None]: @asynccontextmanager async def open_session() -> AsyncGenerator[AsyncSession, None]: + """An asynchronous context manager that opens a new `SQLAlchemy` async session. + + To the contrary of the [`Session`][fastsqla.Session] dependency which can only be + used in endpoints, `open_session` can be used anywhere such as in background tasks. + + On exit, it automatically commits the session if no errors occur inside the context, + or rolls back when an exception is raised. + In all cases, it closes the session and returns the associated connection to the + connection pool. + + ```python + from fastsqla import open_session + + async def example(): + async with open_session() as session: + await session.execute(...) + ``` + + """ session = SessionFactory() try: yield session @@ -100,6 +191,54 @@ async def new_session() -> AsyncGenerator[AsyncSession, None]: Session = Annotated[AsyncSession, Depends(new_session)] +"""A dependency used exclusively in endpoints to get an `SQLAlchemy` session. + +`Session` is a [`FastAPI` dependency](https://fastapi.tiangolo.com/tutorial/dependencies/) +that provides an asynchronous `SQLAlchemy` session. +By defining an argument with type `Session` in an endpoint, `FastAPI` will automatically +inject an `SQLAlchemy` async session into the endpoint. + +At the end of request handling: + +* If no exceptions are raised, the session is automatically committed. +* If an exception is raised, the session is automatically rolled back. +* In alls cases, the session is closed and the associated connection is returned to the + connection pool. + +Example: + +``` py title="example.py" hl_lines="3" +@app.get("/heros/{hero_id}", response_model=Item[HeroItem]) +async def get_items( + session: Session, # (1)! + item_id: int, +): + hero = await session.get(Hero, hero_id) + return {"data": hero} +``` + +1. Just define an argument with type `Session` to get an async session injected + in your endpoint. + +--- + +**Recommendation**: Unless there is a good reason to do so, avoid committing the session +manually, as `FastSQLA` handles it automatically. + +If you need data generated by the database server, such as auto-incremented IDs, flush +the session instead: + +```python +@app.post("/heros", response_model=Item[HeroItem]) +async def create_item(session: Session, new_hero: HeroBase): + hero = Hero(**new_hero.model_dump()) + session.add(hero) + await session.flush() + return {"data": hero} +``` + +Or use the [session context manager][fastsqla.open_session] instead. +""" class Meta(BaseModel): @@ -193,4 +332,74 @@ async def paginate(stmt: Select) -> Page: type PaginateType[T] = Callable[[Select], Awaitable[Page[T]]] + Paginate = Annotated[PaginateType[T], Depends(new_pagination())] +"""A dependency used in endpoints to paginate `SQLAlchemy` select queries. + +It adds `offset`and `limit` query parameters to the endpoint, which are used to paginate. +The model returned by the endpoint is a `Page` model. It contains a page of data and +metadata: + +```json +{ + "data": List[T], + "meta": { + "offset": int, + "total_items": int, + "total_pages": int, + "page_number": int, + } +} +``` + +----- + +Example: +``` py title="example.py" hl_lines="22 23 25" +from fastsqla import Base, Paginate, Page +from pydantic import BaseModel + + +class Hero(Base): + __tablename__ = "hero" + + +class Hero(Base): + __tablename__ = "hero" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(unique=True) + secret_identity: Mapped[str] + age: Mapped[int] + + +class HeroModel(HeroBase): + model_config = ConfigDict(from_attributes=True) + id: int + + +@app.get("/heros", response_model=Page[HeroModel]) # (1)! +async def list_heros(paginate: Paginate): # (2)! + stmt = select(Hero) + return await paginate(stmt) # (3)! +``` + +1. The endpoint returns a `Page` model of `HeroModel`. +2. Just define an argument with type `Paginate` to get an async `paginate` function + injected in your endpoint function. +3. Await the `paginate` function with the `SQLAlchemy` select statement to get the + paginated result. + +To add filtering, just add whatever query parameters you need to the endpoint: + +```python + +from fastsqla import Paginate, Page + +@app.get("/heros", response_model=Page[HeroModel]) +async def list_heros(paginate: Paginate, age:int | None = None): + stmt = select(Hero) + if age: + stmt = stmt.where(Hero.age == age) + return await paginate(stmt) +``` +""" diff --git a/uv.lock b/uv.lock index fa1d7ae..3976e36 100644 --- a/uv.lock +++ b/uv.lock @@ -51,6 +51,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895 }, ] +[[package]] +name = "babel" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/74/f1bc80f23eeba13393b7222b11d95ca3af2c1e28edca18af487137eefed9/babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316", size = 9348104 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, +] + [[package]] name = "certifi" version = "2024.8.30" @@ -255,7 +264,7 @@ wheels = [ [[package]] name = "fastsqla" -version = "0.2.3" +version = "0.2.4" source = { editable = "." } dependencies = [ { name = "fastapi" }, @@ -263,6 +272,13 @@ dependencies = [ { name = "structlog" }, ] +[package.optional-dependencies] +docs = [ + { name = "mkdocs-glightbox" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] + [package.dev-dependencies] dev = [ { name = "aiosqlite" }, @@ -283,6 +299,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115.6" }, + { name = "mkdocs-glightbox", marker = "extra == 'docs'", specifier = ">=0.4.0" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5.50" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.27.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.37" }, { name = "structlog", specifier = ">=24.4.0" }, ] @@ -304,6 +323,18 @@ dev = [ { name = "twine", specifier = ">=5.1.1" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, +] + [[package]] name = "gitdb" version = "4.0.11" @@ -361,6 +392,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 }, ] +[[package]] +name = "griffe" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/74/cd35a98cb11f79de0581e8e1e6fbd738aeeed1f2d90e9b5106728b63f5f7/griffe-1.5.5.tar.gz", hash = "sha256:35ee5b38b93d6a839098aad0f92207e6ad6b70c3e8866c08ca669275b8cba585", size = 391124 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/88/52c9422bc853cd7c2b6122090e887d17b5fad29b67f930e4277c9c557357/griffe-1.5.5-py3-none-any.whl", hash = "sha256:2761b1e8876c6f1f9ab1af274df93ea6bbadd65090de5f38f4cb5cc84897c7dd", size = 128221 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -509,6 +552,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/42/ea8c9726e5ee5ff0731978aaf7cd5fa16674cf549c46279b279d7167c2b4/keyring-25.3.0-py3-none-any.whl", hash = "sha256:8d963da00ccdf06e356acd9bf3b743208878751032d8599c6cc89eb51310ffae", size = 38742 }, ] +[[package]] +name = "markdown" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/08/83871f3c50fc983b88547c196d11cf8c3340e37c32d2e9d6152abe2c61f7/Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803", size = 106349 }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -548,6 +600,145 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/18/fb1e17fb705228b51bf7b2f791adaf83c0fa708e51bbc003411ba48ae21e/mkdocs_autorefs-1.3.0.tar.gz", hash = "sha256:6867764c099ace9025d6ac24fd07b85a98335fbd30107ef01053697c8f46db61", size = 42597 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/4a/960c441950f98becfa5dd419adab20274939fd575ab848aee2c87e3599ac/mkdocs_autorefs-1.3.0-py3-none-any.whl", hash = "sha256:d180f9778a04e78b7134e31418f238bba56f56d6a8af97873946ff661befffb3", size = 17642 }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521 }, +] + +[[package]] +name = "mkdocs-glightbox" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/5a/0bc456397ba0acc684b5b1daa4ca232ed717938fd37198251d8bcc4053bf/mkdocs-glightbox-0.4.0.tar.gz", hash = "sha256:392b34207bf95991071a16d5f8916d1d2f2cd5d5bb59ae2997485ccd778c70d9", size = 32010 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/72/b0c2128bb569c732c11ae8e49a777089e77d83c05946062caa19b841e6fb/mkdocs_glightbox-0.4.0-py3-none-any.whl", hash = "sha256:e0107beee75d3eb7380ac06ea2d6eac94c999eaa49f8c3cbab0e7be2ac006ccf", size = 31154 }, +] + +[[package]] +name = "mkdocs-material" +version = "9.5.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/16/c48d5a28bc4a67c49808180b6009d4d1b4c0753739ffee3cc37046ab29d7/mkdocs_material-9.5.50.tar.gz", hash = "sha256:ae5fe16f3d7c9ccd05bb6916a7da7420cf99a9ce5e33debd9d40403a090d5825", size = 3923354 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/b5/1bf29cd744896ae83bd38c72970782c843ba13e0240b1a85277bd3928637/mkdocs_material-9.5.50-py3-none-any.whl", hash = "sha256:f24100f234741f4d423a9d672a909d859668a4f404796be3cf035f10d6050385", size = 8645274 }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, +] + +[[package]] +name = "mkdocstrings" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "platformdirs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/5a/5de70538c2cefae7ac3a15b5601e306ef3717290cb2aab11d51cbbc2d1c0/mkdocstrings-0.27.0.tar.gz", hash = "sha256:16adca6d6b0a1f9e0c07ff0b02ced8e16f228a9d65a37c063ec4c14d7b76a657", size = 94830 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/10/4c27c3063c2b3681a4b7942f8dbdeb4fa34fecb2c19b594e7345ebf4f86f/mkdocstrings-0.27.0-py3-none-any.whl", hash = "sha256:6ceaa7ea830770959b55a16203ac63da24badd71325b96af950e59fd37366332", size = 30658 }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/ae/32703e35d74040051c672400fd9f5f2b48a6ea094f5071dd8a0e3be35322/mkdocstrings_python-1.13.0.tar.gz", hash = "sha256:2dbd5757e8375b9720e81db16f52f1856bf59905428fd7ef88005d1370e2f64c", size = 185697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/23/d02d86553327296c3bf369d444194ea83410cce8f0e690565264f37f3261/mkdocstrings_python-1.13.0-py3-none-any.whl", hash = "sha256:b88bbb207bab4086434743849f8e796788b373bd32e7bfefbf8560ac45d88f97", size = 112254 }, +] + [[package]] name = "more-itertools" version = "10.5.0" @@ -589,6 +780,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + [[package]] name = "pkginfo" version = "1.10.0" @@ -598,6 +807,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/09/054aea9b7534a15ad38a363a2bd974c20646ab1582a387a95b8df1bfea1c/pkginfo-1.10.0-py3-none-any.whl", hash = "sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097", size = 30392 }, ] +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -675,6 +893,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, ] +[[package]] +name = "pymdown-extensions" +version = "10.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/f7a412dc1630b1a6d7b288e7c736215ce878ee4aad24359f7f67b53bbaa9/pymdown_extensions-10.14.1.tar.gz", hash = "sha256:b65801996a0cd4f42a3110810c306c45b7313c09b0610a6f773730f2a9e3c96b", size = 845243 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/fb/79a8d27966e90feeeb686395c8b1bff8221727abcbd80d2485841393a955/pymdown_extensions-10.14.1-py3-none-any.whl", hash = "sha256:637951cbfbe9874ba28134fb3ce4b8bcadd6aca89ac4998ec29dcbafd554ae08", size = 264283 }, +] + [[package]] name = "pytest" version = "8.3.2" @@ -783,6 +1014,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/8e/da1c6c58f751b70f8ceb1eb25bc25d524e8f14fe16edcce3f4e3ba08629c/pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", size = 5631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911 }, +] + [[package]] name = "readme-renderer" version = "44.0" @@ -797,6 +1066,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310 }, ] +[[package]] +name = "regex" +version = "2024.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781 }, + { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455 }, + { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759 }, + { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976 }, + { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077 }, + { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160 }, + { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896 }, + { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997 }, + { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725 }, + { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481 }, + { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896 }, + { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138 }, + { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692 }, + { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135 }, + { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567 }, + { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525 }, + { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324 }, + { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617 }, + { url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023 }, + { url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072 }, + { url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130 }, + { url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857 }, + { url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006 }, + { url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650 }, + { url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545 }, + { url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045 }, + { url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182 }, + { url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733 }, + { url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122 }, + { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 }, +] + [[package]] name = "requests" version = "2.32.3"