mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-03-01 17:00:48 +01:00
Compare commits
10 Commits
73fae04333
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
8c8911fb27
|
|||
|
|
c0c3b38054 | ||
|
e17d385910
|
|||
|
|
6cf7df55ef | ||
|
|
7482bc5dad | ||
|
|
9d07dfea85 | ||
|
|
31678935aa | ||
|
|
823a0b3e36 | ||
|
1591cd3d64
|
|||
|
|
6714ceeb92 |
2
.github/workflows/build-release.yml
vendored
2
.github/workflows/build-release.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
run: uv python install 3.14
|
run: uv python install 3.14
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync
|
run: uv sync --group dev
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: uv build
|
run: uv build
|
||||||
|
|||||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
run: uv python install 3.13
|
run: uv python install 3.13
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --extra dev
|
run: uv sync --group dev
|
||||||
|
|
||||||
- name: Run Ruff linter
|
- name: Run Ruff linter
|
||||||
run: uv run ruff check .
|
run: uv run ruff check .
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
run: uv python install 3.13
|
run: uv python install 3.13
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --extra dev
|
run: uv sync --group dev
|
||||||
|
|
||||||
- name: Run ty
|
- name: Run ty
|
||||||
run: uv run ty check
|
run: uv run ty check
|
||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
run: uv python install ${{ matrix.python-version }}
|
run: uv python install ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --extra dev
|
run: uv sync --group dev
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
env:
|
env:
|
||||||
|
|||||||
38
.github/workflows/docs.yml
vendored
Normal file
38
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Documentation
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
run: uv python install 3.13
|
||||||
|
|
||||||
|
- run: uv sync --group dev
|
||||||
|
|
||||||
|
- run: uv run zensical build --clean
|
||||||
|
|
||||||
|
- uses: actions/upload-pages-artifact@v4
|
||||||
|
with:
|
||||||
|
path: site
|
||||||
|
|
||||||
|
- uses: actions/deploy-pages@v4
|
||||||
|
id: deployment
|
||||||
67
docs/index.md
Normal file
67
docs/index.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# FastAPI Toolsets
|
||||||
|
|
||||||
|
A modular collection of production-ready utilities for FastAPI. Install only what you need — from async CRUD and database helpers to CLI tooling, Prometheus metrics, and pytest fixtures. Each module is independently installable via optional extras, keeping your dependency footprint minimal.
|
||||||
|
|
||||||
|
[](https://github.com/d3vyce/fastapi-toolsets/actions/workflows/ci.yml)
|
||||||
|
[](https://codecov.io/gh/d3vyce/fastapi-toolsets)
|
||||||
|
[](https://github.com/astral-sh/ty)
|
||||||
|
[](https://github.com/astral-sh/uv)
|
||||||
|
[](https://github.com/astral-sh/ruff)
|
||||||
|
[](https://www.python.org/downloads/)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Documentation**: [https://fastapi-toolsets.d3vyce.fr](https://fastapi-toolsets.d3vyce.fr)
|
||||||
|
|
||||||
|
**Source Code**: [https://github.com/d3vyce/fastapi-toolsets](https://github.com/d3vyce/fastapi-toolsets)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, logging):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add fastapi-toolsets
|
||||||
|
```
|
||||||
|
|
||||||
|
Install only the extras you need:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add "fastapi-toolsets[cli]" # CLI (typer)
|
||||||
|
uv add "fastapi-toolsets[metrics]" # Prometheus metrics (prometheus_client)
|
||||||
|
uv add "fastapi-toolsets[pytest]" # Pytest helpers (httpx, pytest-xdist)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install everything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add "fastapi-toolsets[all]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in search with relationship traversal
|
||||||
|
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
||||||
|
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
||||||
|
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||||
|
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
||||||
|
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
||||||
|
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
|
||||||
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit issues and pull requests.
|
||||||
93
docs/module/cli.md
Normal file
93
docs/module/cli.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# CLI
|
||||||
|
|
||||||
|
Typer-based command-line interface for managing your FastAPI application, with built-in fixture commands integration.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
=== "uv"
|
||||||
|
``` bash
|
||||||
|
uv add "fastapi-toolsets[cli]"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "pip"
|
||||||
|
``` bash
|
||||||
|
pip install "fastapi-toolsets[cli]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `cli` module provides a `manager` entry point built with [Typer](https://typer.tiangolo.com/). It allow custom commands to be added in addition of the fixture commands when a [`FixtureRegistry`](../reference/fixtures.md#fastapi_toolsets.fixtures.registry.FixtureRegistry) and a database context are configured.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configure the CLI in your `pyproject.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.fastapi-toolsets]
|
||||||
|
cli = "myapp.cli:cli" # Custom Typer app
|
||||||
|
fixtures = "myapp.fixtures:registry" # FixtureRegistry instance
|
||||||
|
db_context = "myapp.db:db_context" # Async context manager for sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
All fields are optional. Without configuration, the `manager` command still works but no command are available.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manager commands
|
||||||
|
manager --help
|
||||||
|
|
||||||
|
Usage: manager [OPTIONS] COMMAND [ARGS]...
|
||||||
|
|
||||||
|
FastAPI utilities CLI.
|
||||||
|
|
||||||
|
╭─ Options ────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ --install-completion Install completion for the current shell. │
|
||||||
|
│ --show-completion Show completion for the current shell, to copy it │
|
||||||
|
│ or customize the installation. │
|
||||||
|
│ --help Show this message and exit. │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭─ Commands ───────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ check-db │
|
||||||
|
│ fixtures Manage database fixtures. │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
|
||||||
|
# Fixtures commands
|
||||||
|
manager fixtures --help
|
||||||
|
|
||||||
|
Usage: manager fixtures [OPTIONS] COMMAND [ARGS]...
|
||||||
|
|
||||||
|
Manage database fixtures.
|
||||||
|
|
||||||
|
╭─ Options ────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ --help Show this message and exit. │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭─ Commands ───────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ list List all registered fixtures. │
|
||||||
|
│ load Load fixtures into the database. │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom CLI
|
||||||
|
|
||||||
|
You can extend the CLI by providing your own Typer app. The `manager` entry point will merge your app's commands with the built-in ones:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# myapp/cli.py
|
||||||
|
import typer
|
||||||
|
|
||||||
|
cli = typer.Typer()
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def hello():
|
||||||
|
print("Hello from my app!")
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.fastapi-toolsets]
|
||||||
|
cli = "myapp.cli:cli"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/cli.md)
|
||||||
313
docs/module/crud.md
Normal file
313
docs/module/crud.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# CRUD
|
||||||
|
|
||||||
|
Generic async CRUD operations for SQLAlchemy models with search, pagination, and many-to-many support.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
This module has been coded and tested to be compatible with PostgreSQL only.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `crud` module provides [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud), an abstract base class with a full suite of async database operations, and [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory), a convenience function to instantiate it for a given model.
|
||||||
|
|
||||||
|
## Creating a CRUD class
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.crud import CrudFactory
|
||||||
|
from myapp.models import User
|
||||||
|
|
||||||
|
UserCrud = CrudFactory(model=User)
|
||||||
|
```
|
||||||
|
|
||||||
|
[`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory) dynamically creates a class named `AsyncUserCrud` with `User` as its model.
|
||||||
|
|
||||||
|
## Basic operations
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Create
|
||||||
|
user = await UserCrud.create(session=session, obj=UserCreateSchema(username="alice"))
|
||||||
|
|
||||||
|
# Get one (raises NotFoundError if not found)
|
||||||
|
user = await UserCrud.get(session=session, filters=[User.id == user_id])
|
||||||
|
|
||||||
|
# Get first or None
|
||||||
|
user = await UserCrud.first(session=session, filters=[User.email == email])
|
||||||
|
|
||||||
|
# Get multiple
|
||||||
|
users = await UserCrud.get_multi(session=session, filters=[User.is_active == True])
|
||||||
|
|
||||||
|
# Update
|
||||||
|
user = await UserCrud.update(session=session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id])
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
await UserCrud.delete(session=session, filters=[User.id == user_id])
|
||||||
|
|
||||||
|
# Count / exists
|
||||||
|
count = await UserCrud.count(session=session, filters=[User.is_active == True])
|
||||||
|
exists = await UserCrud.exists(session=session, filters=[User.email == email])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
!!! info "Added in `v1.1` (only offset_pagination via `paginate` if `<v1.1`)"
|
||||||
|
|
||||||
|
Two pagination strategies are available. Both return a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) but differ in how they navigate through results.
|
||||||
|
|
||||||
|
| | `offset_paginate` | `cursor_paginate` |
|
||||||
|
|---|---|---|
|
||||||
|
| Total count | Yes | No |
|
||||||
|
| Jump to arbitrary page | Yes | No |
|
||||||
|
| Performance on deep pages | Degrades | Constant |
|
||||||
|
| Stable under concurrent inserts | No | Yes |
|
||||||
|
| Search compatible | Yes | Yes |
|
||||||
|
| Use case | Admin panels, numbered pagination | Feeds, APIs, infinite scroll |
|
||||||
|
|
||||||
|
### Offset pagination
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=PaginatedResponse[User],
|
||||||
|
)
|
||||||
|
async def get_users(
|
||||||
|
session: SessionDep,
|
||||||
|
items_per_page: int = 50,
|
||||||
|
page: int = 1,
|
||||||
|
):
|
||||||
|
return await crud.UserCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) method returns a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) whose `pagination` field is an [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"data": ["..."],
|
||||||
|
"pagination": {
|
||||||
|
"total_count": 100,
|
||||||
|
"page": 1,
|
||||||
|
"items_per_page": 20,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! warning "Deprecated: `paginate`"
|
||||||
|
The `paginate` function is a backward-compatible alias for `offset_paginate`. This function is **deprecated** and will be removed in **v2.0**.
|
||||||
|
|
||||||
|
### Cursor pagination
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=PaginatedResponse[UserRead],
|
||||||
|
)
|
||||||
|
async def list_users(
|
||||||
|
session: SessionDep,
|
||||||
|
cursor: str | None = None,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
):
|
||||||
|
return await UserCrud.cursor_paginate(
|
||||||
|
session=session,
|
||||||
|
cursor=cursor,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) method returns a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) whose `pagination` field is a [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination) object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"data": ["..."],
|
||||||
|
"pagination": {
|
||||||
|
"next_cursor": "eyJ2YWx1ZSI6ICIzZjQ3YWM2OS0uLi4ifQ==",
|
||||||
|
"prev_cursor": null,
|
||||||
|
"items_per_page": 20,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `next_cursor` as the `cursor` query parameter on the next request to advance to the next page. `prev_cursor` is set on pages 2+ and points back to the first item of the current page. Both are `null` when there is no adjacent page.
|
||||||
|
|
||||||
|
#### Choosing a cursor column
|
||||||
|
|
||||||
|
The cursor column is set once on [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory) via the `cursor_column` parameter. It must be monotonically ordered for stable results:
|
||||||
|
|
||||||
|
- Auto-increment integer PKs
|
||||||
|
- UUID v7 PKs
|
||||||
|
- Timestamps
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Random UUID v4 PKs are **not** suitable as cursor columns because their ordering is non-deterministic.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
`cursor_column` is required. Calling [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) on a CRUD class that has no `cursor_column` configured raises a `ValueError`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Paginate by the primary key
|
||||||
|
PostCrud = CrudFactory(model=Post, cursor_column=Post.id)
|
||||||
|
|
||||||
|
# Paginate by a timestamp column instead
|
||||||
|
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
Declare searchable fields on the CRUD class. Relationship traversal is supported via tuples:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PostCrud = CrudFactory(
|
||||||
|
model=Post,
|
||||||
|
searchable_fields=[
|
||||||
|
Post.title,
|
||||||
|
Post.content,
|
||||||
|
(Post.author, User.username), # search across relationship
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=PaginatedResponse[User],
|
||||||
|
)
|
||||||
|
async def get_users(
|
||||||
|
session: SessionDep,
|
||||||
|
items_per_page: int = 50,
|
||||||
|
page: int = 1,
|
||||||
|
search: str | None = None,
|
||||||
|
):
|
||||||
|
return await crud.UserCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
page=page,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=PaginatedResponse[User],
|
||||||
|
)
|
||||||
|
async def get_users(
|
||||||
|
session: SessionDep,
|
||||||
|
cursor: str | None = None,
|
||||||
|
items_per_page: int = 50,
|
||||||
|
search: str | None = None,
|
||||||
|
):
|
||||||
|
return await crud.UserCrud.cursor_paginate(
|
||||||
|
session=session,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
cursor=cursor,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relationship loading
|
||||||
|
|
||||||
|
!!! info "Added in `v1.1`"
|
||||||
|
|
||||||
|
By default, SQLAlchemy relationships are not loaded unless explicitly requested. Instead of using `lazy="selectin"` on model definitions (which is implicit and applies globally), define a `default_load_options` on the CRUD class to control loading strategy explicitly.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Avoid using `lazy="selectin"` on model relationships. It fires silently on every query, cannot be disabled per-call, and can cause unexpected cascading loads through deep relationship chains. Use `default_load_options` instead.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
ArticleCrud = CrudFactory(
|
||||||
|
model=Article,
|
||||||
|
default_load_options=[
|
||||||
|
selectinload(Article.category),
|
||||||
|
selectinload(Article.tags),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`default_load_options` applies automatically to all read operations (`get`, `first`, `get_multi`, `offset_paginate`, `cursor_paginate`). When `load_options` is passed at call-site, it **fully replaces** `default_load_options` for that query — giving you precise per-call control:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Only loads category, tags are not loaded
|
||||||
|
article = await ArticleCrud.get(
|
||||||
|
session=session,
|
||||||
|
filters=[Article.id == article_id],
|
||||||
|
load_options=[selectinload(Article.category)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Loads nothing — useful for write-then-refresh flows or lightweight checks
|
||||||
|
articles = await ArticleCrud.get_multi(session=session, load_options=[])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Many-to-many relationships
|
||||||
|
|
||||||
|
Use `m2m_fields` to map schema fields containing lists of IDs to SQLAlchemy relationships. The CRUD class resolves and validates all IDs before persisting:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PostCrud = CrudFactory(
|
||||||
|
model=Post,
|
||||||
|
m2m_fields={"tag_ids": Post.tags},
|
||||||
|
)
|
||||||
|
|
||||||
|
post = await PostCrud.create(session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3]))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upsert
|
||||||
|
|
||||||
|
Atomic `INSERT ... ON CONFLICT DO UPDATE` using PostgreSQL:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await UserCrud.upsert(
|
||||||
|
session=session,
|
||||||
|
obj=UserCreateSchema(email="alice@example.com", username="alice"),
|
||||||
|
index_elements=[User.email],
|
||||||
|
set_={"username"},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## `schema` — typed response serialization
|
||||||
|
|
||||||
|
!!! info "Added in `v1.1`"
|
||||||
|
|
||||||
|
Pass a Pydantic schema class to `create`, `get`, `update`, or `offset_paginate` to serialize the result directly into that schema and wrap it in a [`Response[schema]`](../reference/schemas.md#fastapi_toolsets.schemas.Response) or [`PaginatedResponse[schema]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class UserRead(PydanticBase):
|
||||||
|
id: UUID
|
||||||
|
username: str
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{uuid}",
|
||||||
|
responses=generate_error_responses(NotFoundError),
|
||||||
|
)
|
||||||
|
async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
|
||||||
|
return await crud.UserCrud.get(
|
||||||
|
session=session,
|
||||||
|
filters=[User.id == uuid],
|
||||||
|
schema=UserRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_users(session: SessionDep, page: int = 1) -> PaginatedResponse[UserRead]:
|
||||||
|
return await crud.UserCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
page=page,
|
||||||
|
schema=UserRead,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
|
||||||
|
|
||||||
|
!!! warning "Deprecated: `as_response`"
|
||||||
|
The `as_response=True` parameter is **deprecated** and will be removed in **v2.0**. Replace it with `schema=YourSchema`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/crud.md)
|
||||||
92
docs/module/db.md
Normal file
92
docs/module/db.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# DB
|
||||||
|
|
||||||
|
SQLAlchemy async session management with transactions, table locking, and row-change polling.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
This module has been coded and tested to be compatible with PostgreSQL only.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `db` module provides helpers to create FastAPI dependencies and context managers for `AsyncSession`, along with utilities for nested transactions, table lock and polling for row changes.
|
||||||
|
|
||||||
|
## Session dependency
|
||||||
|
|
||||||
|
Use [`create_db_dependency`](../reference/db.md#fastapi_toolsets.db.create_db_dependency) to create a FastAPI dependency that yields a session and auto-commits on success:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
from fastapi_toolsets.db import create_db_dependency
|
||||||
|
|
||||||
|
engine = create_async_engine(url="postgresql+asyncpg://...", future=True)
|
||||||
|
session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
get_db = create_db_dependency(session_maker=session_maker)
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def list_users(session: AsyncSession = Depends(get_db)):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session context manager
|
||||||
|
|
||||||
|
Use [`create_db_context`](../reference/db.md#fastapi_toolsets.db.create_db_context) for sessions outside request handlers (e.g. background tasks, CLI commands):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import create_db_context
|
||||||
|
|
||||||
|
db_context = create_db_context(session_maker=session_maker)
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
async with db_context() as session:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nested transactions
|
||||||
|
|
||||||
|
[`get_transaction`](../reference/db.md#fastapi_toolsets.db.get_transaction) handles savepoints automatically, allowing safe nesting:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import get_transaction
|
||||||
|
|
||||||
|
async def create_user_with_role(session=session):
|
||||||
|
async with get_transaction(session=session):
|
||||||
|
...
|
||||||
|
async with get_transaction(session=session): # uses savepoint
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Table locking
|
||||||
|
|
||||||
|
[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import lock_tables
|
||||||
|
|
||||||
|
async with lock_tables(session=session, tables=[User], mode="EXCLUSIVE"):
|
||||||
|
# No other transaction can modify User until this block exits
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Available lock modes are defined in [`LockMode`](../reference/db.md#fastapi_toolsets.db.LockMode): `ACCESS_SHARE`, `ROW_SHARE`, `ROW_EXCLUSIVE`, `SHARE_UPDATE_EXCLUSIVE`, `SHARE`, `SHARE_ROW_EXCLUSIVE`, `EXCLUSIVE`, `ACCESS_EXCLUSIVE`.
|
||||||
|
|
||||||
|
## Row-change polling
|
||||||
|
|
||||||
|
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value, useful for waiting on async side effects:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import wait_for_row_change
|
||||||
|
|
||||||
|
# Wait up to 30s for order.status to change
|
||||||
|
await wait_for_row_change(
|
||||||
|
session=session,
|
||||||
|
model=Order,
|
||||||
|
pk_value=order_id,
|
||||||
|
columns=[Order.status],
|
||||||
|
interval=1.0,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/db.md)
|
||||||
50
docs/module/dependencies.md
Normal file
50
docs/module/dependencies.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Dependencies
|
||||||
|
|
||||||
|
FastAPI dependency factories for automatic model resolution from path and body parameters.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `dependencies` module provides two factory functions that create FastAPI dependencies to fetch a model instance from the database automatically — either from a path parameter or from a request body field — and inject it directly into your route handler.
|
||||||
|
|
||||||
|
## `PathDependency`
|
||||||
|
|
||||||
|
[`PathDependency`](../reference/dependencies.md#fastapi_toolsets.dependencies.PathDependency) resolves a model from a URL path parameter and injects it into the route handler. Raises [`NotFoundError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NotFoundError) automatically if the record does not exist.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.dependencies import PathDependency
|
||||||
|
|
||||||
|
UserDep = PathDependency(model=User, field=User.id, session_dep=get_db)
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}")
|
||||||
|
async def get_user(user: User = UserDep):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
By default the parameter name is inferred from the field (`user_id` for `User.id`). You can override it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
UserDep = PathDependency(model=User, field=User.id, session_dep=get_db, param_name="id")
|
||||||
|
|
||||||
|
@router.get("/users/{id}")
|
||||||
|
async def get_user(user: User = UserDep):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
## `BodyDependency`
|
||||||
|
|
||||||
|
[`BodyDependency`](../reference/dependencies.md#fastapi_toolsets.dependencies.BodyDependency) resolves a model from a field in the request body. Useful when a body contains a foreign key and you want the full object injected:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.dependencies import BodyDependency
|
||||||
|
|
||||||
|
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=get_db, body_field="role_id")
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
async def create_user(body: UserCreateSchema, role: Role = RoleDep):
|
||||||
|
user = User(username=body.username, role=role)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/dependencies.md)
|
||||||
85
docs/module/exceptions.md
Normal file
85
docs/module/exceptions.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Exceptions
|
||||||
|
|
||||||
|
Structured API exceptions with consistent error responses and automatic OpenAPI documentation.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `exceptions` module provides a set of pre-built HTTP exceptions and a FastAPI exception handler that formats all errors — including validation errors — into a uniform [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Register the exception handlers on your FastAPI app at startup:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app=app)
|
||||||
|
```
|
||||||
|
|
||||||
|
This registers handlers for:
|
||||||
|
|
||||||
|
- [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ApiException) — all custom exceptions below
|
||||||
|
- `RequestValidationError` — Pydantic request validation (422)
|
||||||
|
- `ResponseValidationError` — Pydantic response validation (422)
|
||||||
|
- `Exception` — unhandled errors (500)
|
||||||
|
|
||||||
|
## Built-in exceptions
|
||||||
|
|
||||||
|
| Exception | Status | Default message |
|
||||||
|
|-----------|--------|-----------------|
|
||||||
|
| [`UnauthorizedError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.UnauthorizedError) | 401 | Unauthorized |
|
||||||
|
| [`ForbiddenError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ForbiddenError) | 403 | Forbidden |
|
||||||
|
| [`NotFoundError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NotFoundError) | 404 | Not found |
|
||||||
|
| [`ConflictError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ConflictError) | 409 | Conflict |
|
||||||
|
| [`NoSearchableFieldsError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError) | 400 | No searchable fields |
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.exceptions import NotFoundError
|
||||||
|
|
||||||
|
@router.get("/users/{id}")
|
||||||
|
async def get_user(id: int, session: AsyncSession = Depends(get_db)):
|
||||||
|
user = await UserCrud.first(session=session, filters=[User.id == id])
|
||||||
|
if not user:
|
||||||
|
raise NotFoundError
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom exceptions
|
||||||
|
|
||||||
|
Subclass [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ApiException) and define an `api_error` class variable:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.exceptions import ApiException
|
||||||
|
from fastapi_toolsets.schemas import ApiError
|
||||||
|
|
||||||
|
class PaymentRequiredError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=402,
|
||||||
|
msg="Payment required",
|
||||||
|
desc="Your subscription has expired.",
|
||||||
|
err_code="PAYMENT_REQUIRED",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenAPI response documentation
|
||||||
|
|
||||||
|
Use [`generate_error_responses`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.generate_error_responses) to add error schemas to your endpoint's OpenAPI spec:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.exceptions import generate_error_responses, NotFoundError, ForbiddenError
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/users/{id}",
|
||||||
|
responses=generate_error_responses(NotFoundError, ForbiddenError),
|
||||||
|
)
|
||||||
|
async def get_user(...): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
The pydantic validation error is automatically added by FastAPI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/exceptions.md)
|
||||||
123
docs/module/fixtures.md
Normal file
123
docs/module/fixtures.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Fixtures
|
||||||
|
|
||||||
|
Dependency-aware database seeding with context-based loading strategies.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `fixtures` module lets you define named fixtures with dependencies between them, then load them into the database in the correct order. Fixtures can be scoped to contexts (e.g. base data, testing data) so that only the relevant ones are loaded for each environment.
|
||||||
|
|
||||||
|
## Defining fixtures
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.fixtures import FixtureRegistry, Context
|
||||||
|
|
||||||
|
fixtures = FixtureRegistry()
|
||||||
|
|
||||||
|
@fixtures.register
|
||||||
|
def roles():
|
||||||
|
return [
|
||||||
|
Role(id=1, name="admin"),
|
||||||
|
Role(id=2, name="user"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
|
||||||
|
def test_users():
|
||||||
|
return [
|
||||||
|
User(id=1, username="alice", role_id=1),
|
||||||
|
User(id=2, username="bob", role_id=2),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies declared via `depends_on` are resolved topologically — `roles` will always be loaded before `test_users`.
|
||||||
|
|
||||||
|
## Loading fixtures
|
||||||
|
|
||||||
|
By context with [`load_fixtures_by_context`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.load_fixtures_by_context):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.fixtures import load_fixtures_by_context
|
||||||
|
|
||||||
|
async with db_context() as session:
|
||||||
|
await load_fixtures_by_context(session=session, registry=fixtures, context=Context.TESTING)
|
||||||
|
```
|
||||||
|
|
||||||
|
Directly with [`load_fixtures`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.load_fixtures):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.fixtures import load_fixtures
|
||||||
|
|
||||||
|
async with db_context() as session:
|
||||||
|
await load_fixtures(session=session, registry=fixtures)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contexts
|
||||||
|
|
||||||
|
[`Context`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.Context) is an enum with predefined values:
|
||||||
|
|
||||||
|
| Context | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `Context.BASE` | Core data required in all environments |
|
||||||
|
| `Context.TESTING` | Data only loaded during tests |
|
||||||
|
| `Context.PRODUCTION` | Data only loaded in production |
|
||||||
|
|
||||||
|
A fixture with no `contexts` defined takes `Context.BASE` by default.
|
||||||
|
|
||||||
|
## Load strategies
|
||||||
|
|
||||||
|
[`LoadStrategy`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.LoadStrategy) controls how the fixture loader handles rows that already exist:
|
||||||
|
|
||||||
|
| Strategy | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `LoadStrategy.INSERT` | Insert only, fail on duplicates |
|
||||||
|
| `LoadStrategy.UPSERT` | Insert or update on conflict |
|
||||||
|
| `LoadStrategy.SKIP` | Skip rows that already exist |
|
||||||
|
|
||||||
|
## Merging registries
|
||||||
|
|
||||||
|
Split fixtures definitions across modules and merge them:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myapp.fixtures.dev import dev_fixtures
|
||||||
|
from myapp.fixtures.prod import prod_fixtures
|
||||||
|
|
||||||
|
fixtures = fixturesRegistry()
|
||||||
|
fixtures.include_registry(registry=dev_fixtures)
|
||||||
|
fixtures.include_registry(registry=prod_fixtures)
|
||||||
|
|
||||||
|
## Pytest integration
|
||||||
|
|
||||||
|
Use [`register_fixtures`](../reference/pytest.md#fastapi_toolsets.pytest.plugin.register_fixtures) to expose each fixture in your registry as an injectable pytest fixture named `fixture_{name}` by default:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# conftest.py
|
||||||
|
import pytest
|
||||||
|
from fastapi_toolsets.pytest import create_db_session, register_fixtures
|
||||||
|
from app.fixtures import registry
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/test_db"
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session():
|
||||||
|
async with create_db_session(database_url=DATABASE_URL, base=Base, cleanup=True) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
register_fixtures(registry=registry, namespace=globals())
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_users.py
|
||||||
|
async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Role]):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
The load order is resolved automatically from the `depends_on` declarations in your registry. Each generated fixture receives `db_session` as a dependency and returns the list of loaded model instances.
|
||||||
|
|
||||||
|
## CLI integration
|
||||||
|
|
||||||
|
Fixtures can be triggered from the CLI. See the [CLI module](cli.md) for setup instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/fixtures.md)
|
||||||
39
docs/module/logger.md
Normal file
39
docs/module/logger.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Logger
|
||||||
|
|
||||||
|
Lightweight logging utilities with consistent formatting and uvicorn integration.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `logger` module provides two helpers: one to configure the root logger (and uvicorn loggers) at startup, and one to retrieve a named logger anywhere in your codebase.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Call [`configure_logging`](../reference/logger.md#fastapi_toolsets.logger.configure_logging) once at application startup:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.logger import configure_logging
|
||||||
|
|
||||||
|
configure_logging(level="INFO")
|
||||||
|
```
|
||||||
|
|
||||||
|
This sets up a stdout handler with a consistent format and also configures uvicorn's access and error loggers so all log output shares the same style.
|
||||||
|
|
||||||
|
## Getting a logger
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(name=__name__)
|
||||||
|
logger.info("User created")
|
||||||
|
```
|
||||||
|
|
||||||
|
When called without arguments, [`get_logger`](../reference/logger.md#fastapi_toolsets.logger.get_logger) auto-detects the caller's module name via frame inspection:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Equivalent to get_logger(name=__name__)
|
||||||
|
logger = get_logger()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/logger.md)
|
||||||
86
docs/module/metrics.md
Normal file
86
docs/module/metrics.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Metrics
|
||||||
|
|
||||||
|
Prometheus metrics integration with a decorator-based registry and multi-process support.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
=== "uv"
|
||||||
|
``` bash
|
||||||
|
uv add "fastapi-toolsets[metrics]"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "pip"
|
||||||
|
``` bash
|
||||||
|
pip install "fastapi-toolsets[metrics]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `metrics` module provides a [`MetricsRegistry`](../reference/metrics.md#fastapi_toolsets.metrics.registry.MetricsRegistry) to declare Prometheus metrics with decorators, and an [`init_metrics`](../reference/metrics.md#fastapi_toolsets.metrics.handler.init_metrics) function to mount a `/metrics` endpoint on your FastAPI app.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi_toolsets.metrics import MetricsRegistry, init_metrics
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
metrics = MetricsRegistry()
|
||||||
|
|
||||||
|
init_metrics(app=app, registry=metrics)
|
||||||
|
```
|
||||||
|
|
||||||
|
This mounts the `/metrics` endpoint that Prometheus can scrape.
|
||||||
|
|
||||||
|
## Declaring metrics
|
||||||
|
|
||||||
|
### Providers
|
||||||
|
|
||||||
|
Providers are called once at startup and register metrics that are updated externally (e.g. counters, histograms):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from prometheus_client import Counter, Histogram
|
||||||
|
|
||||||
|
@metrics.register
|
||||||
|
def http_requests():
|
||||||
|
return Counter("http_requests_total", "Total HTTP requests", ["method", "status"])
|
||||||
|
|
||||||
|
@metrics.register
|
||||||
|
def request_duration():
|
||||||
|
return Histogram("request_duration_seconds", "Request duration")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collectors
|
||||||
|
|
||||||
|
Collectors are called on every scrape. Use them for metrics that reflect current state (e.g. gauges):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@metrics.register(collect=True)
|
||||||
|
def queue_depth():
|
||||||
|
gauge = Gauge("queue_depth", "Current queue depth")
|
||||||
|
gauge.set(get_current_queue_depth())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Merging registries
|
||||||
|
|
||||||
|
Split metrics definitions across modules and merge them:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myapp.metrics.http import http_metrics
|
||||||
|
from myapp.metrics.db import db_metrics
|
||||||
|
|
||||||
|
metrics = MetricsRegistry()
|
||||||
|
metrics.include_registry(registry=http_metrics)
|
||||||
|
metrics.include_registry(registry=db_metrics)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-process mode
|
||||||
|
|
||||||
|
Multi-process support is enabled automatically when the `PROMETHEUS_MULTIPROC_DIR` environment variable is set. No code changes are required.
|
||||||
|
|
||||||
|
!!! warning "Environment variable name"
|
||||||
|
The correct variable is `PROMETHEUS_MULTIPROC_DIR` (not `PROMETHEUS_MULTIPROCESS_DIR`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/metrics.md)
|
||||||
86
docs/module/pytest.md
Normal file
86
docs/module/pytest.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Pytest
|
||||||
|
|
||||||
|
Testing helpers for FastAPI applications with async client, database sessions, and parallel worker support.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
=== "uv"
|
||||||
|
``` bash
|
||||||
|
uv add "fastapi-toolsets[pytest]"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "pip"
|
||||||
|
``` bash
|
||||||
|
pip install "fastapi-toolsets[pytest]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `pytest` module provides utilities for setting up async test clients, managing test database sessions, and supporting parallel test execution with `pytest-xdist`.
|
||||||
|
|
||||||
|
## Creating an async client
|
||||||
|
|
||||||
|
Use [`create_async_client`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_async_client) to get an `httpx.AsyncClient` configured for your FastAPI app:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.pytest import create_async_client
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def http_client(db_session):
|
||||||
|
async def _override_get_db():
|
||||||
|
yield db_session
|
||||||
|
|
||||||
|
async with create_async_client(
|
||||||
|
app=app,
|
||||||
|
base_url="http://127.0.0.1/api/v1",
|
||||||
|
dependency_overrides={get_db: _override_get_db},
|
||||||
|
) as c:
|
||||||
|
yield c
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database sessions in tests
|
||||||
|
|
||||||
|
Use [`create_db_session`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_db_session) to create an isolated `AsyncSession` for a test:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.pytest import create_db_session, create_worker_database
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def worker_db_url():
|
||||||
|
async with create_worker_database(
|
||||||
|
database_url=str(settings.SQLALCHEMY_DATABASE_URI)
|
||||||
|
) as url:
|
||||||
|
yield url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(worker_db_url):
|
||||||
|
async with create_db_session(
|
||||||
|
database_url=worker_db_url, base=Base, cleanup=True
|
||||||
|
) as session:
|
||||||
|
yield session
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
In this example, the database is reset between each test using the argument `cleanup=True`.
|
||||||
|
|
||||||
|
## Parallel testing with pytest-xdist
|
||||||
|
|
||||||
|
The examples above are already compatible with parallel test execution with `pytest-xdist`.
|
||||||
|
|
||||||
|
## Cleaning up tables
|
||||||
|
|
||||||
|
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/pytest.md#fastapi_toolsets.pytest.utils.cleanup_tables), this will truncates all tables between tests for fast isolation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.pytest import cleanup_tables
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def clean(db_session):
|
||||||
|
yield
|
||||||
|
await cleanup_tables(session=db_session, base=Base)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/pytest.md)
|
||||||
49
docs/module/schemas.md
Normal file
49
docs/module/schemas.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Schemas
|
||||||
|
|
||||||
|
Standardized Pydantic response models for consistent API responses across your FastAPI application.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `schemas` module provides generic response wrappers that enforce a uniform response structure. All models use `from_attributes=True` for ORM compatibility and `validate_assignment=True` for runtime type safety.
|
||||||
|
|
||||||
|
## Response models
|
||||||
|
|
||||||
|
### [`Response[T]`](../reference/schemas.md#fastapi_toolsets.schemas.Response)
|
||||||
|
|
||||||
|
The most common wrapper for a single resource response.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
@router.get("/users/{id}")
|
||||||
|
async def get_user(user: User = UserDep) -> Response[UserSchema]:
|
||||||
|
return Response(data=user, message="User retrieved")
|
||||||
|
```
|
||||||
|
|
||||||
|
### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
|
||||||
|
|
||||||
|
Wraps a list of items with pagination metadata.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.schemas import PaginatedResponse, Pagination
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def list_users() -> PaginatedResponse[UserSchema]:
|
||||||
|
return PaginatedResponse(
|
||||||
|
data=users,
|
||||||
|
pagination=Pagination(
|
||||||
|
total_count=100,
|
||||||
|
items_per_page=10,
|
||||||
|
page=1,
|
||||||
|
has_more=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)
|
||||||
|
|
||||||
|
Returned automatically by the exceptions handler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/schemas.md)
|
||||||
7
docs/overrides/main.html
Normal file
7
docs/overrides/main.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% extends "base.html" %} {% block extrahead %}
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="https://analytics.d3vyce.fr/script.js"
|
||||||
|
data-website-id="338b8816-7b99-4c6a-82f3-15595be3fd47"
|
||||||
|
></script>
|
||||||
|
{{ super() }} {% endblock %}
|
||||||
27
docs/reference/cli.md
Normal file
27
docs/reference/cli.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# `cli`
|
||||||
|
|
||||||
|
Here's the reference for the CLI configuration helpers used to load settings from `pyproject.toml`.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.cli.config`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.cli.config import (
|
||||||
|
import_from_string,
|
||||||
|
get_config_value,
|
||||||
|
get_fixtures_registry,
|
||||||
|
get_db_context,
|
||||||
|
get_custom_cli,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.cli.config.import_from_string
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.cli.config.get_config_value
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.cli.config.get_fixtures_registry
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.cli.config.get_db_context
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.cli.config.get_custom_cli
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.cli.utils.async_command
|
||||||
20
docs/reference/crud.md
Normal file
20
docs/reference/crud.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# `crud`
|
||||||
|
|
||||||
|
Here's the reference for the CRUD classes, factory, and search utilities.
|
||||||
|
|
||||||
|
You can import the main symbols from `fastapi_toolsets.crud`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.crud import CrudFactory, AsyncCrud
|
||||||
|
from fastapi_toolsets.crud.search import SearchConfig, get_searchable_fields, build_search_filters
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.crud.factory.AsyncCrud
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.crud.factory.CrudFactory
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.crud.search.SearchConfig
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.crud.search.get_searchable_fields
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.crud.search.build_search_filters
|
||||||
28
docs/reference/db.md
Normal file
28
docs/reference/db.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# `db`
|
||||||
|
|
||||||
|
Here's the reference for all database session utilities, transaction helpers, and locking functions.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.db`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import (
|
||||||
|
LockMode,
|
||||||
|
create_db_dependency,
|
||||||
|
create_db_context,
|
||||||
|
get_transaction,
|
||||||
|
lock_tables,
|
||||||
|
wait_for_row_change,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.LockMode
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.create_db_dependency
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.create_db_context
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.get_transaction
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.lock_tables
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.wait_for_row_change
|
||||||
13
docs/reference/dependencies.md
Normal file
13
docs/reference/dependencies.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# `dependencies`
|
||||||
|
|
||||||
|
Here's the reference for the FastAPI dependency factory functions.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.dependencies`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.dependencies import PathDependency, BodyDependency
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.dependencies.PathDependency
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.dependencies.BodyDependency
|
||||||
34
docs/reference/exceptions.md
Normal file
34
docs/reference/exceptions.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# `exceptions`
|
||||||
|
|
||||||
|
Here's the reference for all exception classes and handler utilities.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.exceptions`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.exceptions import (
|
||||||
|
ApiException,
|
||||||
|
UnauthorizedError,
|
||||||
|
ForbiddenError,
|
||||||
|
NotFoundError,
|
||||||
|
ConflictError,
|
||||||
|
NoSearchableFieldsError,
|
||||||
|
generate_error_responses,
|
||||||
|
init_exceptions_handlers,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.exceptions.exceptions.ApiException
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.exceptions.exceptions.UnauthorizedError
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.exceptions.exceptions.ForbiddenError
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.exceptions.exceptions.NotFoundError
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.exceptions.exceptions.ConflictError
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.exceptions.exceptions.generate_error_responses
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.exceptions.handler.init_exceptions_handlers
|
||||||
31
docs/reference/fixtures.md
Normal file
31
docs/reference/fixtures.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# `fixtures`
|
||||||
|
|
||||||
|
Here's the reference for the fixture registry, enums, and loading utilities.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.fixtures`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.fixtures import (
|
||||||
|
Context,
|
||||||
|
LoadStrategy,
|
||||||
|
Fixture,
|
||||||
|
FixtureRegistry,
|
||||||
|
load_fixtures,
|
||||||
|
load_fixtures_by_context,
|
||||||
|
get_obj_by_attr,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.fixtures.enum.Context
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.fixtures.enum.LoadStrategy
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.fixtures.registry.Fixture
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.fixtures.registry.FixtureRegistry
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.fixtures.utils.load_fixtures
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.fixtures.utils.load_fixtures_by_context
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.fixtures.utils.get_obj_by_attr
|
||||||
13
docs/reference/logger.md
Normal file
13
docs/reference/logger.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# `logger`
|
||||||
|
|
||||||
|
Here's the reference for the logging utilities.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.logger`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.logger import configure_logging, get_logger
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.logger.configure_logging
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.logger.get_logger
|
||||||
15
docs/reference/metrics.md
Normal file
15
docs/reference/metrics.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# `metrics`
|
||||||
|
|
||||||
|
Here's the reference for the Prometheus metrics registry and endpoint handler.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.metrics`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.metrics import Metric, MetricsRegistry, init_metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.metrics.registry.Metric
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.metrics.registry.MetricsRegistry
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.metrics.handler.init_metrics
|
||||||
28
docs/reference/pytest.md
Normal file
28
docs/reference/pytest.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# `pytest`
|
||||||
|
|
||||||
|
Here's the reference for all testing utilities and pytest fixtures.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.pytest`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.pytest import (
|
||||||
|
register_fixtures,
|
||||||
|
create_async_client,
|
||||||
|
create_db_session,
|
||||||
|
worker_database_url,
|
||||||
|
create_worker_database,
|
||||||
|
cleanup_tables,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.pytest.plugin.register_fixtures
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.pytest.utils.create_async_client
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.pytest.utils.create_db_session
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.pytest.utils.worker_database_url
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.pytest.utils.create_worker_database
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.pytest.utils.cleanup_tables
|
||||||
34
docs/reference/schemas.md
Normal file
34
docs/reference/schemas.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# `schemas` module
|
||||||
|
|
||||||
|
Here's the reference for all response models and types provided by the `schemas` module.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.schemas`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.schemas import (
|
||||||
|
PydanticBase,
|
||||||
|
ResponseStatus,
|
||||||
|
ApiError,
|
||||||
|
BaseResponse,
|
||||||
|
Response,
|
||||||
|
ErrorResponse,
|
||||||
|
Pagination,
|
||||||
|
PaginatedResponse,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.PydanticBase
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.ResponseStatus
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.ApiError
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.BaseResponse
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.Response
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.ErrorResponse
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.Pagination
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.PaginatedResponse
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "0.10.0"
|
version = "1.1.1"
|
||||||
description = "Reusable tools for FastAPI: async CRUD, fixtures, CLI, and standardized responses for SQLAlchemy + PostgreSQL"
|
description = "Reusable tools for FastAPI: async CRUD, fixtures, CLI, and standardized responses for SQLAlchemy + PostgreSQL"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -58,21 +58,31 @@ pytest = [
|
|||||||
all = [
|
all = [
|
||||||
"fastapi-toolsets[cli,metrics,pytest]",
|
"fastapi-toolsets[cli,metrics,pytest]",
|
||||||
]
|
]
|
||||||
test = [
|
|
||||||
"coverage>=7.0.0",
|
|
||||||
"fastapi-toolsets[pytest]",
|
|
||||||
"pytest-anyio>=0.0.0",
|
|
||||||
"pytest-cov>=4.0.0",
|
|
||||||
]
|
|
||||||
dev = [
|
|
||||||
"fastapi-toolsets[all,test]",
|
|
||||||
"ruff>=0.1.0",
|
|
||||||
"ty>=0.0.1a0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
manager = "fastapi_toolsets.cli.app:cli"
|
manager = "fastapi_toolsets.cli.app:cli"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
{include-group = "tests"},
|
||||||
|
{include-group = "docs"},
|
||||||
|
"fastapi-toolsets[all]",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"ty>=0.0.1a0",
|
||||||
|
]
|
||||||
|
tests = [
|
||||||
|
"coverage>=7.0.0",
|
||||||
|
"httpx>=0.25.0",
|
||||||
|
"pytest-anyio>=0.0.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
|
"pytest-xdist>=3.0.0",
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
]
|
||||||
|
docs = [
|
||||||
|
"mkdocstrings-python>=2.0.2",
|
||||||
|
"zensical>=0.0.23",
|
||||||
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.10,<0.11.0"]
|
requires = ["uv_build>=0.10,<0.11.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.10.0"
|
__version__ = "1.1.1"
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
"""CLI configuration and dynamic imports."""
|
"""CLI configuration and dynamic imports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import sys
|
import sys
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from .pyproject import find_pyproject, load_pyproject
|
from .pyproject import find_pyproject, load_pyproject
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..fixtures import FixtureRegistry
|
||||||
|
|
||||||
|
|
||||||
def _ensure_project_in_path():
|
def _ensure_project_in_path():
|
||||||
"""Add project root to sys.path if not installed in editable mode."""
|
"""Add project root to sys.path if not installed in editable mode."""
|
||||||
@@ -17,7 +23,7 @@ def _ensure_project_in_path():
|
|||||||
sys.path.insert(0, project_root)
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
|
||||||
def import_from_string(import_path: str):
|
def import_from_string(import_path: str) -> Any:
|
||||||
"""Import an object from a dotted string path.
|
"""Import an object from a dotted string path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -51,7 +57,13 @@ def import_from_string(import_path: str):
|
|||||||
return getattr(module, attr_name)
|
return getattr(module, attr_name)
|
||||||
|
|
||||||
|
|
||||||
def get_config_value(key: str, required: bool = False):
|
@overload
|
||||||
|
def get_config_value(key: str, required: Literal[True]) -> Any: ... # pragma: no cover
|
||||||
|
@overload
|
||||||
|
def get_config_value(
|
||||||
|
key: str, required: bool = False
|
||||||
|
) -> Any | None: ... # pragma: no cover
|
||||||
|
def get_config_value(key: str, required: bool = False) -> Any | None:
|
||||||
"""Get a configuration value from pyproject.toml.
|
"""Get a configuration value from pyproject.toml.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -76,7 +88,7 @@ def get_config_value(key: str, required: bool = False):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def get_fixtures_registry():
|
def get_fixtures_registry() -> FixtureRegistry:
|
||||||
"""Import and return the fixtures registry from config."""
|
"""Import and return the fixtures registry from config."""
|
||||||
from ..fixtures import FixtureRegistry
|
from ..fixtures import FixtureRegistry
|
||||||
|
|
||||||
@@ -91,7 +103,7 @@ def get_fixtures_registry():
|
|||||||
return registry
|
return registry
|
||||||
|
|
||||||
|
|
||||||
def get_db_context():
|
def get_db_context() -> Any:
|
||||||
"""Import and return the db_context function from config."""
|
"""Import and return the db_context function from config."""
|
||||||
import_path = get_config_value("db_context", required=True)
|
import_path = get_config_value("db_context", required=True)
|
||||||
return import_from_string(import_path)
|
return import_from_string(import_path)
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ def async_command(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
|
|||||||
"""Decorator to run an async function as a sync CLI command.
|
"""Decorator to run an async function as a sync CLI command.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
@fixture_cli.command("load")
|
@fixture_cli.command("load")
|
||||||
@async_command
|
@async_command
|
||||||
async def load(ctx: typer.Context) -> None:
|
async def load(ctx: typer.Context) -> None:
|
||||||
async with get_db_context() as session:
|
async with get_db_context() as session:
|
||||||
await load_fixtures(session, registry)
|
await load_fixtures(session, registry)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
|
|||||||
@@ -2,28 +2,44 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import uuid as uuid_module
|
||||||
|
import warnings
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from typing import Any, ClassVar, Generic, Literal, Self, TypeVar, cast, overload
|
from typing import Any, ClassVar, Generic, Literal, Self, TypeVar, cast, overload
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import Integer, Uuid, and_, func, select
|
||||||
from sqlalchemy import delete as sql_delete
|
from sqlalchemy import delete as sql_delete
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
from sqlalchemy.exc import NoResultFound
|
from sqlalchemy.exc import NoResultFound
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
||||||
|
from sqlalchemy.sql.base import ExecutableOption
|
||||||
from sqlalchemy.sql.roles import WhereHavingRole
|
from sqlalchemy.sql.roles import WhereHavingRole
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import get_transaction
|
||||||
from ..exceptions import NotFoundError
|
from ..exceptions import NotFoundError
|
||||||
from ..schemas import PaginatedResponse, Pagination, Response
|
from ..schemas import CursorPagination, OffsetPagination, PaginatedResponse, Response
|
||||||
from .search import SearchConfig, SearchFieldType, build_search_filters
|
from .search import SearchConfig, SearchFieldType, build_search_filters
|
||||||
|
|
||||||
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
||||||
|
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
||||||
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
||||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_cursor(value: Any) -> str:
|
||||||
|
"""Encode cursor column value as an base64 string."""
|
||||||
|
return base64.b64encode(json.dumps(str(value)).encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cursor(cursor: str) -> str:
|
||||||
|
"""Decode cursor base64 string."""
|
||||||
|
return json.loads(base64.b64decode(cursor.encode()).decode())
|
||||||
|
|
||||||
|
|
||||||
class AsyncCrud(Generic[ModelType]):
|
class AsyncCrud(Generic[ModelType]):
|
||||||
"""Generic async CRUD operations for SQLAlchemy models.
|
"""Generic async CRUD operations for SQLAlchemy models.
|
||||||
|
|
||||||
@@ -33,26 +49,17 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
model: ClassVar[type[DeclarativeBase]]
|
model: ClassVar[type[DeclarativeBase]]
|
||||||
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
|
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
|
||||||
m2m_fields: ClassVar[M2MFieldType | None] = None
|
m2m_fields: ClassVar[M2MFieldType | None] = None
|
||||||
|
default_load_options: ClassVar[list[ExecutableOption] | None] = None
|
||||||
|
cursor_column: ClassVar[Any | None] = None
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def create( # pragma: no cover
|
def _resolve_load_options(
|
||||||
cls: type[Self],
|
cls, load_options: list[ExecutableOption] | None
|
||||||
session: AsyncSession,
|
) -> list[ExecutableOption] | None:
|
||||||
obj: BaseModel,
|
"""Return load_options if provided, else fall back to default_load_options."""
|
||||||
*,
|
if load_options is not None:
|
||||||
as_response: Literal[True],
|
return load_options
|
||||||
) -> Response[ModelType]: ...
|
return cls.default_load_options
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
async def create( # pragma: no cover
|
|
||||||
cls: type[Self],
|
|
||||||
session: AsyncSession,
|
|
||||||
obj: BaseModel,
|
|
||||||
*,
|
|
||||||
as_response: Literal[False] = ...,
|
|
||||||
) -> ModelType: ...
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _resolve_m2m(
|
async def _resolve_m2m(
|
||||||
@@ -110,6 +117,40 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
return set()
|
return set()
|
||||||
return set(cls.m2m_fields.keys())
|
return set(cls.m2m_fields.keys())
|
||||||
|
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def create( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
obj: BaseModel,
|
||||||
|
*,
|
||||||
|
schema: type[SchemaType],
|
||||||
|
as_response: bool = ...,
|
||||||
|
) -> Response[SchemaType]: ...
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def create( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
obj: BaseModel,
|
||||||
|
*,
|
||||||
|
as_response: Literal[True],
|
||||||
|
schema: None = ...,
|
||||||
|
) -> Response[ModelType]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def create( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
obj: BaseModel,
|
||||||
|
*,
|
||||||
|
as_response: Literal[False] = ...,
|
||||||
|
schema: None = ...,
|
||||||
|
) -> ModelType: ...
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def create(
|
async def create(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
@@ -117,17 +158,28 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
obj: BaseModel,
|
obj: BaseModel,
|
||||||
*,
|
*,
|
||||||
as_response: bool = False,
|
as_response: bool = False,
|
||||||
) -> ModelType | Response[ModelType]:
|
schema: type[BaseModel] | None = None,
|
||||||
|
) -> ModelType | Response[ModelType] | Response[Any]:
|
||||||
"""Create a new record in the database.
|
"""Create a new record in the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: DB async session
|
session: DB async session
|
||||||
obj: Pydantic model with data to create
|
obj: Pydantic model with data to create
|
||||||
as_response: If True, wrap result in Response object
|
as_response: Deprecated. Use ``schema`` instead. Will be removed in v2.0.
|
||||||
|
schema: Pydantic schema to serialize the result into. When provided,
|
||||||
|
the result is automatically wrapped in a ``Response[schema]``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created model instance or Response wrapping it
|
Created model instance, or ``Response[schema]`` when ``schema`` is given,
|
||||||
|
or ``Response[ModelType]`` when ``as_response=True`` (deprecated).
|
||||||
"""
|
"""
|
||||||
|
if as_response and schema is None:
|
||||||
|
warnings.warn(
|
||||||
|
"as_response is deprecated and will be removed in v2.0. "
|
||||||
|
"Use schema=YourSchema instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
async with get_transaction(session):
|
async with get_transaction(session):
|
||||||
m2m_exclude = cls._m2m_schema_fields()
|
m2m_exclude = cls._m2m_schema_fields()
|
||||||
data = (
|
data = (
|
||||||
@@ -143,8 +195,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session.add(db_model)
|
session.add(db_model)
|
||||||
await session.refresh(db_model)
|
await session.refresh(db_model)
|
||||||
result = cast(ModelType, db_model)
|
result = cast(ModelType, db_model)
|
||||||
if as_response:
|
if as_response or schema:
|
||||||
return Response(data=result)
|
data_out = schema.model_validate(result) if schema else result
|
||||||
|
return Response(data=data_out)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -157,8 +210,25 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: bool = False,
|
with_for_update: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
schema: type[SchemaType],
|
||||||
|
as_response: bool = ...,
|
||||||
|
) -> Response[SchemaType]: ...
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def get( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
filters: list[Any],
|
||||||
|
*,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
with_for_update: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
as_response: Literal[True],
|
as_response: Literal[True],
|
||||||
|
schema: None = ...,
|
||||||
) -> Response[ModelType]: ...
|
) -> Response[ModelType]: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -171,8 +241,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: bool = False,
|
with_for_update: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
as_response: Literal[False] = ...,
|
as_response: Literal[False] = ...,
|
||||||
|
schema: None = ...,
|
||||||
) -> ModelType: ...
|
) -> ModelType: ...
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -184,9 +255,10 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: bool = False,
|
with_for_update: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
as_response: bool = False,
|
as_response: bool = False,
|
||||||
) -> ModelType | Response[ModelType]:
|
schema: type[BaseModel] | None = None,
|
||||||
|
) -> ModelType | Response[ModelType] | Response[Any]:
|
||||||
"""Get exactly one record. Raises NotFoundError if not found.
|
"""Get exactly one record. Raises NotFoundError if not found.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -196,15 +268,25 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
|
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
|
||||||
with_for_update: Lock the row for update
|
with_for_update: Lock the row for update
|
||||||
load_options: SQLAlchemy loader options (e.g., selectinload)
|
load_options: SQLAlchemy loader options (e.g., selectinload)
|
||||||
as_response: If True, wrap result in Response object
|
as_response: Deprecated. Use ``schema`` instead. Will be removed in v2.0.
|
||||||
|
schema: Pydantic schema to serialize the result into. When provided,
|
||||||
|
the result is automatically wrapped in a ``Response[schema]``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Model instance or Response wrapping it
|
Model instance, or ``Response[schema]`` when ``schema`` is given,
|
||||||
|
or ``Response[ModelType]`` when ``as_response=True`` (deprecated).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotFoundError: If no record found
|
NotFoundError: If no record found
|
||||||
MultipleResultsFound: If more than one record found
|
MultipleResultsFound: If more than one record found
|
||||||
"""
|
"""
|
||||||
|
if as_response and schema is None:
|
||||||
|
warnings.warn(
|
||||||
|
"as_response is deprecated and will be removed in v2.0. "
|
||||||
|
"Use schema=YourSchema instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
if joins:
|
if joins:
|
||||||
for model, condition in joins:
|
for model, condition in joins:
|
||||||
@@ -214,8 +296,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
else q.join(model, condition)
|
else q.join(model, condition)
|
||||||
)
|
)
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if load_options:
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*load_options)
|
q = q.options(*resolved)
|
||||||
if with_for_update:
|
if with_for_update:
|
||||||
q = q.with_for_update()
|
q = q.with_for_update()
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
@@ -223,8 +305,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
if not item:
|
if not item:
|
||||||
raise NotFoundError()
|
raise NotFoundError()
|
||||||
result = cast(ModelType, item)
|
result = cast(ModelType, item)
|
||||||
if as_response:
|
if as_response or schema:
|
||||||
return Response(data=result)
|
data_out = schema.model_validate(result) if schema else result
|
||||||
|
return Response(data=data_out)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -235,7 +318,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
) -> ModelType | None:
|
) -> ModelType | None:
|
||||||
"""Get the first matching record, or None.
|
"""Get the first matching record, or None.
|
||||||
|
|
||||||
@@ -259,8 +342,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
)
|
)
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if load_options:
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*load_options)
|
q = q.options(*resolved)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return cast(ModelType | None, result.unique().scalars().first())
|
return cast(ModelType | None, result.unique().scalars().first())
|
||||||
|
|
||||||
@@ -272,7 +355,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters: list[Any] | None = None,
|
filters: list[Any] | None = None,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
order_by: Any | None = None,
|
order_by: Any | None = None,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
offset: int | None = None,
|
offset: int | None = None,
|
||||||
@@ -302,8 +385,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
)
|
)
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if load_options:
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*load_options)
|
q = q.options(*resolved)
|
||||||
if order_by is not None:
|
if order_by is not None:
|
||||||
q = q.order_by(order_by)
|
q = q.order_by(order_by)
|
||||||
if offset is not None:
|
if offset is not None:
|
||||||
@@ -313,6 +396,21 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return cast(Sequence[ModelType], result.unique().scalars().all())
|
return cast(Sequence[ModelType], result.unique().scalars().all())
|
||||||
|
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def update( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
obj: BaseModel,
|
||||||
|
filters: list[Any],
|
||||||
|
*,
|
||||||
|
exclude_unset: bool = True,
|
||||||
|
exclude_none: bool = False,
|
||||||
|
schema: type[SchemaType],
|
||||||
|
as_response: bool = ...,
|
||||||
|
) -> Response[SchemaType]: ...
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
@overload
|
@overload
|
||||||
@classmethod
|
@classmethod
|
||||||
async def update( # pragma: no cover
|
async def update( # pragma: no cover
|
||||||
@@ -324,6 +422,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
as_response: Literal[True],
|
as_response: Literal[True],
|
||||||
|
schema: None = ...,
|
||||||
) -> Response[ModelType]: ...
|
) -> Response[ModelType]: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -337,6 +436,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
as_response: Literal[False] = ...,
|
as_response: Literal[False] = ...,
|
||||||
|
schema: None = ...,
|
||||||
) -> ModelType: ...
|
) -> ModelType: ...
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -349,7 +449,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
as_response: bool = False,
|
as_response: bool = False,
|
||||||
) -> ModelType | Response[ModelType]:
|
schema: type[BaseModel] | None = None,
|
||||||
|
) -> ModelType | Response[ModelType] | Response[Any]:
|
||||||
"""Update a record in the database.
|
"""Update a record in the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -358,20 +459,30 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters: List of SQLAlchemy filter conditions
|
filters: List of SQLAlchemy filter conditions
|
||||||
exclude_unset: Exclude fields not explicitly set in the schema
|
exclude_unset: Exclude fields not explicitly set in the schema
|
||||||
exclude_none: Exclude fields with None value
|
exclude_none: Exclude fields with None value
|
||||||
as_response: If True, wrap result in Response object
|
as_response: Deprecated. Use ``schema`` instead. Will be removed in v2.0.
|
||||||
|
schema: Pydantic schema to serialize the result into. When provided,
|
||||||
|
the result is automatically wrapped in a ``Response[schema]``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated model instance or Response wrapping it
|
Updated model instance, or ``Response[schema]`` when ``schema`` is given,
|
||||||
|
or ``Response[ModelType]`` when ``as_response=True`` (deprecated).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotFoundError: If no record found
|
NotFoundError: If no record found
|
||||||
"""
|
"""
|
||||||
|
if as_response and schema is None:
|
||||||
|
warnings.warn(
|
||||||
|
"as_response is deprecated and will be removed in v2.0. "
|
||||||
|
"Use schema=YourSchema instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
async with get_transaction(session):
|
async with get_transaction(session):
|
||||||
m2m_exclude = cls._m2m_schema_fields()
|
m2m_exclude = cls._m2m_schema_fields()
|
||||||
|
|
||||||
# Eagerly load M2M relationships that will be updated so that
|
# Eagerly load M2M relationships that will be updated so that
|
||||||
# setattr does not trigger a lazy load (which fails in async).
|
# setattr does not trigger a lazy load (which fails in async).
|
||||||
m2m_load_options: list[Any] = []
|
m2m_load_options: list[ExecutableOption] = []
|
||||||
if m2m_exclude and cls.m2m_fields:
|
if m2m_exclude and cls.m2m_fields:
|
||||||
for schema_field, rel in cls.m2m_fields.items():
|
for schema_field, rel in cls.m2m_fields.items():
|
||||||
if schema_field in obj.model_fields_set:
|
if schema_field in obj.model_fields_set:
|
||||||
@@ -395,8 +506,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
for rel_attr, related_instances in m2m_resolved.items():
|
for rel_attr, related_instances in m2m_resolved.items():
|
||||||
setattr(db_model, rel_attr, related_instances)
|
setattr(db_model, rel_attr, related_instances)
|
||||||
await session.refresh(db_model)
|
await session.refresh(db_model)
|
||||||
if as_response:
|
if as_response or schema:
|
||||||
return Response(data=db_model)
|
data_out = schema.model_validate(db_model) if schema else db_model
|
||||||
|
return Response(data=data_out)
|
||||||
return db_model
|
return db_model
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -478,11 +590,20 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Args:
|
Args:
|
||||||
session: DB async session
|
session: DB async session
|
||||||
filters: List of SQLAlchemy filter conditions
|
filters: List of SQLAlchemy filter conditions
|
||||||
as_response: If True, wrap result in Response object
|
as_response: Deprecated. Will be removed in v2.0. When ``True``,
|
||||||
|
returns ``Response[None]`` instead of ``bool``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if deletion was executed, or Response wrapping it
|
``True`` if deletion was executed, or ``Response[None]`` when
|
||||||
|
``as_response=True`` (deprecated).
|
||||||
"""
|
"""
|
||||||
|
if as_response:
|
||||||
|
warnings.warn(
|
||||||
|
"as_response is deprecated and will be removed in v2.0. "
|
||||||
|
"Use schema=YourSchema instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
async with get_transaction(session):
|
async with get_transaction(session):
|
||||||
q = sql_delete(cls.model).where(and_(*filters))
|
q = sql_delete(cls.model).where(and_(*filters))
|
||||||
await session.execute(q)
|
await session.execute(q)
|
||||||
@@ -555,22 +676,60 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return bool(result.scalar())
|
return bool(result.scalar())
|
||||||
|
|
||||||
|
@overload
|
||||||
@classmethod
|
@classmethod
|
||||||
async def paginate(
|
async def offset_paginate( # pragma: no cover
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
filters: list[Any] | None = None,
|
filters: list[Any] | None = None,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: list[Any] | None = None,
|
load_options: list[ExecutableOption] | None = None,
|
||||||
order_by: Any | None = None,
|
order_by: Any | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
items_per_page: int = 20,
|
items_per_page: int = 20,
|
||||||
search: str | SearchConfig | None = None,
|
search: str | SearchConfig | None = None,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
) -> PaginatedResponse[ModelType]:
|
schema: type[SchemaType],
|
||||||
"""Get paginated results with metadata.
|
) -> PaginatedResponse[SchemaType]: ...
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def offset_paginate( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
filters: list[Any] | None = None,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
order_by: Any | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
search: str | SearchConfig | None = None,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
schema: None = ...,
|
||||||
|
) -> PaginatedResponse[ModelType]: ...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def offset_paginate(
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
filters: list[Any] | None = None,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
order_by: Any | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
search: str | SearchConfig | None = None,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
schema: type[BaseModel] | None = None,
|
||||||
|
) -> PaginatedResponse[ModelType] | PaginatedResponse[Any]:
|
||||||
|
"""Get paginated results using offset-based pagination.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: DB async session
|
session: DB async session
|
||||||
@@ -583,9 +742,10 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
items_per_page: Number of items per page
|
items_per_page: Number of items per page
|
||||||
search: Search query string or SearchConfig object
|
search: Search query string or SearchConfig object
|
||||||
search_fields: Fields to search in (overrides class default)
|
search_fields: Fields to search in (overrides class default)
|
||||||
|
schema: Optional Pydantic schema to serialize each item into.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with 'data' and 'pagination' keys
|
PaginatedResponse with OffsetPagination metadata
|
||||||
"""
|
"""
|
||||||
filters = list(filters) if filters else []
|
filters = list(filters) if filters else []
|
||||||
offset = (page - 1) * items_per_page
|
offset = (page - 1) * items_per_page
|
||||||
@@ -619,14 +779,17 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if load_options:
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*load_options)
|
q = q.options(*resolved)
|
||||||
if order_by is not None:
|
if order_by is not None:
|
||||||
q = q.order_by(order_by)
|
q = q.order_by(order_by)
|
||||||
|
|
||||||
q = q.offset(offset).limit(items_per_page)
|
q = q.offset(offset).limit(items_per_page)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
items = cast(list[ModelType], result.unique().scalars().all())
|
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||||
|
items: list[Any] = (
|
||||||
|
[schema.model_validate(item) for item in raw_items] if schema else raw_items
|
||||||
|
)
|
||||||
|
|
||||||
# Count query (with same joins and filters)
|
# Count query (with same joins and filters)
|
||||||
pk_col = cls.model.__mapper__.primary_key[0]
|
pk_col = cls.model.__mapper__.primary_key[0]
|
||||||
@@ -654,7 +817,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
|
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
data=items,
|
data=items,
|
||||||
pagination=Pagination(
|
pagination=OffsetPagination(
|
||||||
total_count=total_count,
|
total_count=total_count,
|
||||||
items_per_page=items_per_page,
|
items_per_page=items_per_page,
|
||||||
page=page,
|
page=page,
|
||||||
@@ -662,12 +825,183 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
paginate = offset_paginate
|
||||||
|
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def cursor_paginate( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
cursor: str | None = None,
|
||||||
|
filters: list[Any] | None = None,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
order_by: Any | None = None,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
search: str | SearchConfig | None = None,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
schema: type[SchemaType],
|
||||||
|
) -> PaginatedResponse[SchemaType]: ...
|
||||||
|
|
||||||
|
# Backward-compatible - will be removed in v2.0
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def cursor_paginate( # pragma: no cover
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
cursor: str | None = None,
|
||||||
|
filters: list[Any] | None = None,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
order_by: Any | None = None,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
search: str | SearchConfig | None = None,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
schema: None = ...,
|
||||||
|
) -> PaginatedResponse[ModelType]: ...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def cursor_paginate(
|
||||||
|
cls: type[Self],
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
cursor: str | None = None,
|
||||||
|
filters: list[Any] | None = None,
|
||||||
|
joins: JoinType | None = None,
|
||||||
|
outer_join: bool = False,
|
||||||
|
load_options: list[ExecutableOption] | None = None,
|
||||||
|
order_by: Any | None = None,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
search: str | SearchConfig | None = None,
|
||||||
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
schema: type[BaseModel] | None = None,
|
||||||
|
) -> PaginatedResponse[ModelType] | PaginatedResponse[Any]:
|
||||||
|
"""Get paginated results using cursor-based pagination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB async session.
|
||||||
|
cursor: Cursor string from a previous ``CursorPagination``.
|
||||||
|
Omit (or pass ``None``) to start from the beginning.
|
||||||
|
filters: List of SQLAlchemy filter conditions.
|
||||||
|
joins: List of ``(model, condition)`` tuples for joining related
|
||||||
|
tables.
|
||||||
|
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN.
|
||||||
|
load_options: SQLAlchemy loader options. Falls back to
|
||||||
|
``default_load_options`` when not provided.
|
||||||
|
order_by: Additional ordering applied after the cursor column.
|
||||||
|
items_per_page: Number of items per page (default 20).
|
||||||
|
search: Search query string or SearchConfig object.
|
||||||
|
search_fields: Fields to search in (overrides class default).
|
||||||
|
schema: Optional Pydantic schema to serialize each item into.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedResponse with CursorPagination metadata
|
||||||
|
"""
|
||||||
|
filters = list(filters) if filters else []
|
||||||
|
search_joins: list[Any] = []
|
||||||
|
|
||||||
|
if cls.cursor_column is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{cls.__name__}.cursor_column is not set. "
|
||||||
|
"Pass cursor_column=<column> to CrudFactory() to use cursor_paginate."
|
||||||
|
)
|
||||||
|
cursor_column: Any = cls.cursor_column
|
||||||
|
cursor_col_name: str = cursor_column.key
|
||||||
|
|
||||||
|
if cursor is not None:
|
||||||
|
raw_val = _decode_cursor(cursor)
|
||||||
|
col_type = cursor_column.property.columns[0].type
|
||||||
|
if isinstance(col_type, Integer):
|
||||||
|
cursor_val: Any = int(raw_val)
|
||||||
|
elif isinstance(col_type, Uuid):
|
||||||
|
cursor_val = uuid_module.UUID(raw_val)
|
||||||
|
else:
|
||||||
|
cursor_val = raw_val
|
||||||
|
filters.append(cursor_column > cursor_val)
|
||||||
|
|
||||||
|
# Build search filters
|
||||||
|
if search:
|
||||||
|
search_filters, search_joins = build_search_filters(
|
||||||
|
cls.model,
|
||||||
|
search,
|
||||||
|
search_fields=search_fields,
|
||||||
|
default_fields=cls.searchable_fields,
|
||||||
|
)
|
||||||
|
filters.extend(search_filters)
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
q = select(cls.model)
|
||||||
|
|
||||||
|
# Apply explicit joins
|
||||||
|
if joins:
|
||||||
|
for model, condition in joins:
|
||||||
|
q = (
|
||||||
|
q.outerjoin(model, condition)
|
||||||
|
if outer_join
|
||||||
|
else q.join(model, condition)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply search joins (always outer joins)
|
||||||
|
for join_rel in search_joins:
|
||||||
|
q = q.outerjoin(join_rel)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
q = q.where(and_(*filters))
|
||||||
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
|
q = q.options(*resolved)
|
||||||
|
|
||||||
|
# Cursor column is always the primary sort
|
||||||
|
q = q.order_by(cursor_column)
|
||||||
|
if order_by is not None:
|
||||||
|
q = q.order_by(order_by)
|
||||||
|
|
||||||
|
# Fetch one extra to detect whether a next page exists
|
||||||
|
q = q.limit(items_per_page + 1)
|
||||||
|
result = await session.execute(q)
|
||||||
|
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
||||||
|
|
||||||
|
has_more = len(raw_items) > items_per_page
|
||||||
|
items_page = raw_items[:items_per_page]
|
||||||
|
|
||||||
|
# next_cursor points past the last item on this page
|
||||||
|
next_cursor: str | None = None
|
||||||
|
if has_more and items_page:
|
||||||
|
next_cursor = _encode_cursor(getattr(items_page[-1], cursor_col_name))
|
||||||
|
|
||||||
|
# prev_cursor points to the first item on this page or None when on the first page
|
||||||
|
prev_cursor: str | None = None
|
||||||
|
if cursor is not None and items_page:
|
||||||
|
prev_cursor = _encode_cursor(getattr(items_page[0], cursor_col_name))
|
||||||
|
|
||||||
|
items: list[Any] = (
|
||||||
|
[schema.model_validate(item) for item in items_page]
|
||||||
|
if schema
|
||||||
|
else items_page
|
||||||
|
)
|
||||||
|
|
||||||
|
return PaginatedResponse(
|
||||||
|
data=items,
|
||||||
|
pagination=CursorPagination(
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
prev_cursor=prev_cursor,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
has_more=has_more,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def CrudFactory(
|
def CrudFactory(
|
||||||
model: type[ModelType],
|
model: type[ModelType],
|
||||||
*,
|
*,
|
||||||
searchable_fields: Sequence[SearchFieldType] | None = None,
|
searchable_fields: Sequence[SearchFieldType] | None = None,
|
||||||
m2m_fields: M2MFieldType | None = None,
|
m2m_fields: M2MFieldType | None = None,
|
||||||
|
default_load_options: list[ExecutableOption] | None = None,
|
||||||
|
cursor_column: Any | None = None,
|
||||||
) -> type[AsyncCrud[ModelType]]:
|
) -> type[AsyncCrud[ModelType]]:
|
||||||
"""Create a CRUD class for a specific model.
|
"""Create a CRUD class for a specific model.
|
||||||
|
|
||||||
@@ -677,11 +1011,19 @@ def CrudFactory(
|
|||||||
m2m_fields: Optional mapping for many-to-many relationships.
|
m2m_fields: Optional mapping for many-to-many relationships.
|
||||||
Maps schema field names (containing lists of IDs) to
|
Maps schema field names (containing lists of IDs) to
|
||||||
SQLAlchemy relationship attributes.
|
SQLAlchemy relationship attributes.
|
||||||
|
default_load_options: Default SQLAlchemy loader options applied to all read
|
||||||
|
queries when no explicit ``load_options`` are passed. Use this
|
||||||
|
instead of ``lazy="selectin"`` on the model so that loading
|
||||||
|
strategy is explicit and per-CRUD. Overridden entirely (not
|
||||||
|
merged) when ``load_options`` is provided at call-site.
|
||||||
|
cursor_column: Required to call ``cursor_paginate``
|
||||||
|
Must be monotonically ordered (e.g. integer PK, UUID v7, timestamp).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AsyncCrud subclass bound to the model
|
AsyncCrud subclass bound to the model
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi_toolsets.crud import CrudFactory
|
from fastapi_toolsets.crud import CrudFactory
|
||||||
from myapp.models import User, Post
|
from myapp.models import User, Post
|
||||||
|
|
||||||
@@ -701,6 +1043,25 @@ def CrudFactory(
|
|||||||
m2m_fields={"tag_ids": Post.tags},
|
m2m_fields={"tag_ids": Post.tags},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# With a fixed cursor column for cursor_paginate:
|
||||||
|
PostCrud = CrudFactory(
|
||||||
|
Post,
|
||||||
|
cursor_column=Post.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# With default load strategy (replaces lazy="selectin" on the model):
|
||||||
|
ArticleCrud = CrudFactory(
|
||||||
|
Article,
|
||||||
|
default_load_options=[selectinload(Article.category), selectinload(Article.tags)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Override default_load_options for a specific call:
|
||||||
|
article = await ArticleCrud.get(
|
||||||
|
session,
|
||||||
|
[Article.id == 1],
|
||||||
|
load_options=[selectinload(Article.category)], # tags won't load
|
||||||
|
)
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
user = await UserCrud.get(session, [User.id == 1])
|
user = await UserCrud.get(session, [User.id == 1])
|
||||||
posts = await PostCrud.get_multi(session, filters=[Post.user_id == user.id])
|
posts = await PostCrud.get_multi(session, filters=[Post.user_id == user.id])
|
||||||
@@ -709,7 +1070,7 @@ def CrudFactory(
|
|||||||
post = await PostCrud.create(session, PostCreate(title="Hello", tag_ids=[id1, id2]))
|
post = await PostCrud.create(session, PostCreate(title="Hello", tag_ids=[id1, id2]))
|
||||||
|
|
||||||
# With search
|
# With search
|
||||||
result = await UserCrud.paginate(session, search="john")
|
result = await UserCrud.offset_paginate(session, search="john")
|
||||||
|
|
||||||
# With joins (inner join by default):
|
# With joins (inner join by default):
|
||||||
users = await UserCrud.get_multi(
|
users = await UserCrud.get_multi(
|
||||||
@@ -724,6 +1085,7 @@ def CrudFactory(
|
|||||||
joins=[(Post, Post.user_id == User.id)],
|
joins=[(Post, Post.user_id == User.id)],
|
||||||
outer_join=True,
|
outer_join=True,
|
||||||
)
|
)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
cls = type(
|
cls = type(
|
||||||
f"Async{model.__name__}Crud",
|
f"Async{model.__name__}Crud",
|
||||||
@@ -732,6 +1094,8 @@ def CrudFactory(
|
|||||||
"model": model,
|
"model": model,
|
||||||
"searchable_fields": searchable_fields,
|
"searchable_fields": searchable_fields,
|
||||||
"m2m_fields": m2m_fields,
|
"m2m_fields": m2m_fields,
|
||||||
|
"default_load_options": default_load_options,
|
||||||
|
"cursor_column": cursor_column,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return cast(type[AsyncCrud[ModelType]], cls)
|
return cast(type[AsyncCrud[ModelType]], cls)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ def create_db_dependency(
|
|||||||
An async generator function usable with FastAPI's Depends()
|
An async generator function usable with FastAPI's Depends()
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
from fastapi_toolsets.db import create_db_dependency
|
from fastapi_toolsets.db import create_db_dependency
|
||||||
@@ -46,6 +47,7 @@ def create_db_dependency(
|
|||||||
@app.get("/users")
|
@app.get("/users")
|
||||||
async def list_users(session: AsyncSession = Depends(get_db)):
|
async def list_users(session: AsyncSession = Depends(get_db)):
|
||||||
...
|
...
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
@@ -72,6 +74,7 @@ def create_db_context(
|
|||||||
An async context manager function
|
An async context manager function
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
from fastapi_toolsets.db import create_db_context
|
from fastapi_toolsets.db import create_db_context
|
||||||
|
|
||||||
@@ -83,6 +86,7 @@ def create_db_context(
|
|||||||
async with get_db_context() as session:
|
async with get_db_context() as session:
|
||||||
user = await UserCrud.get(session, [User.id == 1])
|
user = await UserCrud.get(session, [User.id == 1])
|
||||||
...
|
...
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
get_db = create_db_dependency(session_maker)
|
get_db = create_db_dependency(session_maker)
|
||||||
return asynccontextmanager(get_db)
|
return asynccontextmanager(get_db)
|
||||||
@@ -104,9 +108,11 @@ async def get_transaction(
|
|||||||
The session within the transaction context
|
The session within the transaction context
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
async with get_transaction(session):
|
async with get_transaction(session):
|
||||||
session.add(model)
|
session.add(model)
|
||||||
# Auto-commits on exit, rolls back on exception
|
# Auto-commits on exit, rolls back on exception
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
if session.in_transaction():
|
if session.in_transaction():
|
||||||
async with session.begin_nested():
|
async with session.begin_nested():
|
||||||
@@ -158,6 +164,7 @@ async def lock_tables(
|
|||||||
SQLAlchemyError: If lock cannot be acquired within timeout
|
SQLAlchemyError: If lock cannot be acquired within timeout
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi_toolsets.db import lock_tables, LockMode
|
from fastapi_toolsets.db import lock_tables, LockMode
|
||||||
|
|
||||||
async with lock_tables(session, [User, Account]):
|
async with lock_tables(session, [User, Account]):
|
||||||
@@ -169,6 +176,7 @@ async def lock_tables(
|
|||||||
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
|
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
|
||||||
# Exclusive lock - no other transactions can access
|
# Exclusive lock - no other transactions can access
|
||||||
await process_order(session, order_id)
|
await process_order(session, order_id)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
table_names = ",".join(table.__tablename__ for table in tables)
|
table_names = ",".join(table.__tablename__ for table in tables)
|
||||||
|
|
||||||
@@ -212,6 +220,7 @@ async def wait_for_row_change(
|
|||||||
TimeoutError: If timeout expires before a change is detected
|
TimeoutError: If timeout expires before a change is detected
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi_toolsets.db import wait_for_row_change
|
from fastapi_toolsets.db import wait_for_row_change
|
||||||
|
|
||||||
# Wait for any column to change
|
# Wait for any column to change
|
||||||
@@ -224,6 +233,7 @@ async def wait_for_row_change(
|
|||||||
interval=1.0,
|
interval=1.0,
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
)
|
)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
instance = await session.get(model, pk_value)
|
instance = await session.get(model, pk_value)
|
||||||
if instance is None:
|
if instance is None:
|
||||||
|
|||||||
@@ -38,12 +38,14 @@ def PathDependency(
|
|||||||
NotFoundError: If no matching record is found
|
NotFoundError: If no matching record is found
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
UserDep = PathDependency(User, User.id, session_dep=get_db)
|
UserDep = PathDependency(User, User.id, session_dep=get_db)
|
||||||
|
|
||||||
@router.get("/user/{id}")
|
@router.get("/user/{id}")
|
||||||
async def get(
|
async def get(
|
||||||
user: User = UserDep,
|
user: User = UserDep,
|
||||||
): ...
|
): ...
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
crud = CrudFactory(model)
|
crud = CrudFactory(model)
|
||||||
name = (
|
name = (
|
||||||
@@ -102,6 +104,7 @@ def BodyDependency(
|
|||||||
NotFoundError: If no matching record is found
|
NotFoundError: If no matching record is found
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
UserDep = BodyDependency(
|
UserDep = BodyDependency(
|
||||||
User, User.ctfd_id, session_dep=get_db, body_field="user_id"
|
User, User.ctfd_id, session_dep=get_db, body_field="user_id"
|
||||||
)
|
)
|
||||||
@@ -110,6 +113,7 @@ def BodyDependency(
|
|||||||
async def assign(
|
async def assign(
|
||||||
user: User = UserDep,
|
user: User = UserDep,
|
||||||
): ...
|
): ...
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
crud = CrudFactory(model)
|
crud = CrudFactory(model)
|
||||||
python_type = field.type.python_type
|
python_type = field.type.python_type
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class ApiException(Exception):
|
|||||||
The exception handler will use api_error to generate the response.
|
The exception handler will use api_error to generate the response.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
class CustomError(ApiException):
|
class CustomError(ApiException):
|
||||||
api_error = ApiError(
|
api_error = ApiError(
|
||||||
code=400,
|
code=400,
|
||||||
@@ -19,6 +20,7 @@ class ApiException(Exception):
|
|||||||
desc="The request was invalid.",
|
desc="The request was invalid.",
|
||||||
err_code="CUSTOM-400",
|
err_code="CUSTOM-400",
|
||||||
)
|
)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
api_error: ClassVar[ApiError]
|
api_error: ClassVar[ApiError]
|
||||||
@@ -114,6 +116,7 @@ def generate_error_responses(
|
|||||||
Dict suitable for FastAPI's responses parameter
|
Dict suitable for FastAPI's responses parameter
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi_toolsets.exceptions import generate_error_responses, UnauthorizedError, ForbiddenError
|
from fastapi_toolsets.exceptions import generate_error_responses, UnauthorizedError, ForbiddenError
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
@@ -122,6 +125,7 @@ def generate_error_responses(
|
|||||||
)
|
)
|
||||||
async def admin_endpoint():
|
async def admin_endpoint():
|
||||||
...
|
...
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
responses: dict[int | str, dict[str, Any]] = {}
|
responses: dict[int | str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ def init_exceptions_handlers(app: FastAPI) -> FastAPI:
|
|||||||
The same FastAPI instance (for chaining)
|
The same FastAPI instance (for chaining)
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
init_exceptions_handlers(app)
|
init_exceptions_handlers(app)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
_register_exception_handlers(app)
|
_register_exception_handlers(app)
|
||||||
app.openapi = lambda: _custom_openapi(app) # type: ignore[method-assign]
|
app.openapi = lambda: _custom_openapi(app) # type: ignore[method-assign]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class FixtureRegistry:
|
|||||||
"""Registry for managing fixtures with dependencies.
|
"""Registry for managing fixtures with dependencies.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi_toolsets.fixtures import FixtureRegistry, Context
|
from fastapi_toolsets.fixtures import FixtureRegistry, Context
|
||||||
|
|
||||||
fixtures = FixtureRegistry()
|
fixtures = FixtureRegistry()
|
||||||
@@ -48,6 +49,7 @@ class FixtureRegistry:
|
|||||||
return [
|
return [
|
||||||
Post(id=1, title="Test", user_id=1),
|
Post(id=1, title="Test", user_id=1),
|
||||||
]
|
]
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -80,6 +82,7 @@ class FixtureRegistry:
|
|||||||
contexts: List of contexts this fixture belongs to
|
contexts: List of contexts this fixture belongs to
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
@fixtures.register
|
@fixtures.register
|
||||||
def roles():
|
def roles():
|
||||||
return [Role(id=1, name="admin")]
|
return [Role(id=1, name="admin")]
|
||||||
@@ -87,6 +90,7 @@ class FixtureRegistry:
|
|||||||
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
|
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
|
||||||
def test_users():
|
def test_users():
|
||||||
return [User(id=1, username="test", role_id=1)]
|
return [User(id=1, username="test", role_id=1)]
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(
|
def decorator(
|
||||||
@@ -124,6 +128,7 @@ class FixtureRegistry:
|
|||||||
ValueError: If a fixture name already exists in the current registry
|
ValueError: If a fixture name already exists in the current registry
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
registry = FixtureRegistry()
|
registry = FixtureRegistry()
|
||||||
dev_registry = FixtureRegistry()
|
dev_registry = FixtureRegistry()
|
||||||
|
|
||||||
@@ -132,6 +137,7 @@ class FixtureRegistry:
|
|||||||
return [...]
|
return [...]
|
||||||
|
|
||||||
registry.include_registry(registry=dev_registry)
|
registry.include_registry(registry=dev_registry)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
for name, fixture in registry._fixtures.items():
|
for name, fixture in registry._fixtures.items():
|
||||||
if name in self._fixtures:
|
if name in self._fixtures:
|
||||||
|
|||||||
@@ -59,9 +59,11 @@ async def load_fixtures(
|
|||||||
Dict mapping fixture names to loaded instances
|
Dict mapping fixture names to loaded instances
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
# Loads 'roles' first (dependency), then 'users'
|
# Loads 'roles' first (dependency), then 'users'
|
||||||
result = await load_fixtures(session, fixtures, "users")
|
result = await load_fixtures(session, fixtures, "users")
|
||||||
print(result["users"]) # [User(...), ...]
|
print(result["users"]) # [User(...), ...]
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
ordered = registry.resolve_dependencies(*names)
|
ordered = registry.resolve_dependencies(*names)
|
||||||
return await _load_ordered(session, registry, ordered, strategy)
|
return await _load_ordered(session, registry, ordered, strategy)
|
||||||
@@ -85,11 +87,13 @@ async def load_fixtures_by_context(
|
|||||||
Dict mapping fixture names to loaded instances
|
Dict mapping fixture names to loaded instances
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
# Load base + testing fixtures
|
# Load base + testing fixtures
|
||||||
await load_fixtures_by_context(
|
await load_fixtures_by_context(
|
||||||
session, fixtures,
|
session, fixtures,
|
||||||
Context.BASE, Context.TESTING
|
Context.BASE, Context.TESTING
|
||||||
)
|
)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
ordered = registry.resolve_context_dependencies(*contexts)
|
ordered = registry.resolve_context_dependencies(*contexts)
|
||||||
return await _load_ordered(session, registry, ordered, strategy)
|
return await _load_ordered(session, registry, ordered, strategy)
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ def configure_logging(
|
|||||||
The configured Logger instance.
|
The configured Logger instance.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi_toolsets.logger import configure_logging
|
from fastapi_toolsets.logger import configure_logging
|
||||||
|
|
||||||
logger = configure_logging("DEBUG")
|
logger = configure_logging("DEBUG")
|
||||||
logger.info("Application started")
|
logger.info("Application started")
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
formatter = logging.Formatter(fmt)
|
formatter = logging.Formatter(fmt)
|
||||||
|
|
||||||
@@ -83,11 +85,13 @@ def get_logger(name: str | None = _SENTINEL) -> logging.Logger: # type: ignore[
|
|||||||
A Logger instance.
|
A Logger instance.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi_toolsets.logger import get_logger
|
from fastapi_toolsets.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger() # uses caller's __name__
|
logger = get_logger() # uses caller's __name__
|
||||||
logger = get_logger("myapp") # explicit name
|
logger = get_logger("myapp") # explicit name
|
||||||
logger = get_logger(None) # root logger
|
logger = get_logger(None) # root logger
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
if name is _SENTINEL:
|
if name is _SENTINEL:
|
||||||
name = sys._getframe(1).f_globals.get("__name__")
|
name = sys._getframe(1).f_globals.get("__name__")
|
||||||
|
|||||||
@@ -40,12 +40,14 @@ def init_metrics(
|
|||||||
The same FastAPI instance (for chaining).
|
The same FastAPI instance (for chaining).
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi_toolsets.metrics import MetricsRegistry, init_metrics
|
from fastapi_toolsets.metrics import MetricsRegistry, init_metrics
|
||||||
|
|
||||||
metrics = MetricsRegistry()
|
metrics = MetricsRegistry()
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
init_metrics(app, registry=metrics)
|
init_metrics(app, registry=metrics)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
for provider in registry.get_providers():
|
for provider in registry.get_providers():
|
||||||
logger.debug("Initialising metric provider '%s'", provider.name)
|
logger.debug("Initialising metric provider '%s'", provider.name)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class MetricsRegistry:
|
|||||||
"""Registry for managing Prometheus metric providers and collectors.
|
"""Registry for managing Prometheus metric providers and collectors.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from prometheus_client import Counter, Gauge
|
from prometheus_client import Counter, Gauge
|
||||||
from fastapi_toolsets.metrics import MetricsRegistry
|
from fastapi_toolsets.metrics import MetricsRegistry
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ class MetricsRegistry:
|
|||||||
@metrics.register(collect=True)
|
@metrics.register(collect=True)
|
||||||
def collect_queue_depth(gauge=Gauge("queue_depth", "Current queue depth")):
|
def collect_queue_depth(gauge=Gauge("queue_depth", "Current queue depth")):
|
||||||
gauge.set(get_current_queue_depth())
|
gauge.set(get_current_queue_depth())
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -61,6 +63,7 @@ class MetricsRegistry:
|
|||||||
If ``False`` (default), called once at init time.
|
If ``False`` (default), called once at init time.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
@metrics.register
|
@metrics.register
|
||||||
def my_counter():
|
def my_counter():
|
||||||
return Counter("my_counter", "A counter")
|
return Counter("my_counter", "A counter")
|
||||||
@@ -68,6 +71,7 @@ class MetricsRegistry:
|
|||||||
@metrics.register(collect=True, name="queue")
|
@metrics.register(collect=True, name="queue")
|
||||||
def collect_queue_depth():
|
def collect_queue_depth():
|
||||||
gauge.set(compute_depth())
|
gauge.set(compute_depth())
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
@@ -93,6 +97,7 @@ class MetricsRegistry:
|
|||||||
ValueError: If a metric name already exists in the current registry.
|
ValueError: If a metric name already exists in the current registry.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
main = MetricsRegistry()
|
main = MetricsRegistry()
|
||||||
sub = MetricsRegistry()
|
sub = MetricsRegistry()
|
||||||
|
|
||||||
@@ -101,6 +106,7 @@ class MetricsRegistry:
|
|||||||
return Counter("sub_total", "Sub counter")
|
return Counter("sub_total", "Sub counter")
|
||||||
|
|
||||||
main.include_registry(sub)
|
main.include_registry(sub)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
for metric_name, definition in registry._metrics.items():
|
for metric_name, definition in registry._metrics.items():
|
||||||
if metric_name in self._metrics:
|
if metric_name in self._metrics:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ def register_fixtures(
|
|||||||
List of created fixture names
|
List of created fixture names
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
# conftest.py
|
# conftest.py
|
||||||
from app.fixtures import fixtures
|
from app.fixtures import fixtures
|
||||||
from fastapi_toolsets.pytest_plugin import register_fixtures
|
from fastapi_toolsets.pytest_plugin import register_fixtures
|
||||||
@@ -45,6 +46,7 @@ def register_fixtures(
|
|||||||
# - fixture_roles
|
# - fixture_roles
|
||||||
# - fixture_users (depends on fixture_roles if users depends on roles)
|
# - fixture_users (depends on fixture_roles if users depends on roles)
|
||||||
# - fixture_posts (depends on fixture_users if posts depends on users)
|
# - fixture_posts (depends on fixture_users if posts depends on users)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
created_fixtures: list[str] = []
|
created_fixtures: list[str] = []
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ async def create_async_client(
|
|||||||
An AsyncClient configured for the app.
|
An AsyncClient configured for the app.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi_toolsets.pytest import create_async_client
|
from fastapi_toolsets.pytest import create_async_client
|
||||||
|
|
||||||
@@ -50,8 +51,10 @@ async def create_async_client(
|
|||||||
async def test_endpoint(client: AsyncClient):
|
async def test_endpoint(client: AsyncClient):
|
||||||
response = await client.get("/health")
|
response = await client.get("/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
```
|
||||||
|
|
||||||
Example with dependency overrides:
|
Example with dependency overrides:
|
||||||
|
```python
|
||||||
from fastapi_toolsets.pytest import create_async_client, create_db_session
|
from fastapi_toolsets.pytest import create_async_client, create_db_session
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ async def create_async_client(
|
|||||||
app, dependency_overrides={get_db: override}
|
app, dependency_overrides={get_db: override}
|
||||||
) as c:
|
) as c:
|
||||||
yield c
|
yield c
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
if dependency_overrides:
|
if dependency_overrides:
|
||||||
app.dependency_overrides.update(dependency_overrides)
|
app.dependency_overrides.update(dependency_overrides)
|
||||||
@@ -111,6 +115,7 @@ async def create_db_session(
|
|||||||
An AsyncSession ready for database operations.
|
An AsyncSession ready for database operations.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi_toolsets.pytest import create_db_session
|
from fastapi_toolsets.pytest import create_db_session
|
||||||
from app.models import Base
|
from app.models import Base
|
||||||
|
|
||||||
@@ -127,6 +132,7 @@ async def create_db_session(
|
|||||||
user = User(name="test")
|
user = User(name="test")
|
||||||
db_session.add(user)
|
db_session.add(user)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
engine = create_async_engine(database_url, echo=echo)
|
engine = create_async_engine(database_url, echo=echo)
|
||||||
|
|
||||||
@@ -186,6 +192,7 @@ def worker_database_url(database_url: str, default_test_db: str) -> str:
|
|||||||
A database URL with a worker- or default-specific database name.
|
A database URL with a worker- or default-specific database name.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
# With PYTEST_XDIST_WORKER="gw0":
|
# With PYTEST_XDIST_WORKER="gw0":
|
||||||
url = worker_database_url(
|
url = worker_database_url(
|
||||||
"postgresql+asyncpg://user:pass@localhost/test_db",
|
"postgresql+asyncpg://user:pass@localhost/test_db",
|
||||||
@@ -199,6 +206,7 @@ def worker_database_url(database_url: str, default_test_db: str) -> str:
|
|||||||
default_test_db="test",
|
default_test_db="test",
|
||||||
)
|
)
|
||||||
# "postgresql+asyncpg://user:pass@localhost/test_db_test"
|
# "postgresql+asyncpg://user:pass@localhost/test_db_test"
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
worker = _get_xdist_worker(default_test_db=default_test_db)
|
worker = _get_xdist_worker(default_test_db=default_test_db)
|
||||||
|
|
||||||
@@ -231,6 +239,7 @@ async def create_worker_database(
|
|||||||
The worker-specific database URL.
|
The worker-specific database URL.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
from fastapi_toolsets.pytest import (
|
from fastapi_toolsets.pytest import (
|
||||||
create_worker_database, create_db_session,
|
create_worker_database, create_db_session,
|
||||||
)
|
)
|
||||||
@@ -248,6 +257,7 @@ async def create_worker_database(
|
|||||||
worker_db_url, Base, cleanup=True
|
worker_db_url, Base, cleanup=True
|
||||||
) as session:
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
worker_url = worker_database_url(
|
worker_url = worker_database_url(
|
||||||
database_url=database_url, default_test_db=default_test_db
|
database_url=database_url, default_test_db=default_test_db
|
||||||
@@ -288,11 +298,13 @@ async def cleanup_tables(
|
|||||||
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def db_session(worker_db_url):
|
async def db_session(worker_db_url):
|
||||||
async with create_db_session(worker_db_url, Base) as session:
|
async with create_db_session(worker_db_url, Base) as session:
|
||||||
yield session
|
yield session
|
||||||
await cleanup_tables(session, Base)
|
await cleanup_tables(session, Base)
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
tables = base.metadata.sorted_tables
|
tables = base.metadata.sorted_tables
|
||||||
if not tables:
|
if not tables:
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ApiError",
|
"ApiError",
|
||||||
|
"CursorPagination",
|
||||||
"ErrorResponse",
|
"ErrorResponse",
|
||||||
|
"OffsetPagination",
|
||||||
"Pagination",
|
"Pagination",
|
||||||
"PaginatedResponse",
|
"PaginatedResponse",
|
||||||
"PydanticBase",
|
"PydanticBase",
|
||||||
@@ -71,7 +73,9 @@ class Response(BaseResponse, Generic[DataT]):
|
|||||||
"""Generic API response with data payload.
|
"""Generic API response with data payload.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
```python
|
||||||
Response[UserRead](data=user, message="User retrieved")
|
Response[UserRead](data=user, message="User retrieved")
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
data: DataT | None = None
|
data: DataT | None = None
|
||||||
@@ -88,8 +92,8 @@ class ErrorResponse(BaseResponse):
|
|||||||
data: Any | None = None
|
data: Any | None = None
|
||||||
|
|
||||||
|
|
||||||
class Pagination(PydanticBase):
|
class OffsetPagination(PydanticBase):
|
||||||
"""Pagination metadata for list responses.
|
"""Pagination metadata for offset-based list responses.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
total_count: Total number of items across all pages
|
total_count: Total number of items across all pages
|
||||||
@@ -104,15 +108,28 @@ class Pagination(PydanticBase):
|
|||||||
has_more: bool
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
class PaginatedResponse(BaseResponse, Generic[DataT]):
|
# Backward-compatible - will be removed in v2.0
|
||||||
"""Paginated API response for list endpoints.
|
Pagination = OffsetPagination
|
||||||
|
|
||||||
Example:
|
|
||||||
PaginatedResponse[UserRead](
|
class CursorPagination(PydanticBase):
|
||||||
data=users,
|
"""Pagination metadata for cursor-based list responses.
|
||||||
pagination=Pagination(total_count=100, items_per_page=10, page=1, has_more=True)
|
|
||||||
)
|
Attributes:
|
||||||
|
next_cursor: Encoded cursor for the next page, or None on the last page.
|
||||||
|
prev_cursor: Encoded cursor for the previous page, or None on the first page.
|
||||||
|
items_per_page: Number of items requested per page.
|
||||||
|
has_more: Whether there is at least one more page after this one.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
next_cursor: str | None
|
||||||
|
prev_cursor: str | None = None
|
||||||
|
items_per_page: int
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseResponse, Generic[DataT]):
|
||||||
|
"""Paginated API response for list endpoints."""
|
||||||
|
|
||||||
data: list[DataT]
|
data: list[DataT]
|
||||||
pagination: Pagination
|
pagination: OffsetPagination | CursorPagination
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import uuid
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import Column, ForeignKey, String, Table, Uuid
|
from sqlalchemy import Column, ForeignKey, Integer, String, Table, Uuid
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from fastapi_toolsets.crud import CrudFactory
|
from fastapi_toolsets.crud import CrudFactory
|
||||||
|
from fastapi_toolsets.schemas import PydanticBase
|
||||||
|
|
||||||
DATABASE_URL = os.getenv(
|
DATABASE_URL = os.getenv(
|
||||||
key="DATABASE_URL",
|
key="DATABASE_URL",
|
||||||
@@ -69,6 +70,15 @@ post_tags = Table(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IntRole(Base):
|
||||||
|
"""Test role model with auto-increment integer PK."""
|
||||||
|
|
||||||
|
__tablename__ = "int_roles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(50), unique=True)
|
||||||
|
|
||||||
|
|
||||||
class Post(Base):
|
class Post(Base):
|
||||||
"""Test post model."""
|
"""Test post model."""
|
||||||
|
|
||||||
@@ -90,6 +100,13 @@ class RoleCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class RoleRead(PydanticBase):
|
||||||
|
"""Schema for reading a role."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class RoleUpdate(BaseModel):
|
class RoleUpdate(BaseModel):
|
||||||
"""Schema for updating a role."""
|
"""Schema for updating a role."""
|
||||||
|
|
||||||
@@ -106,6 +123,13 @@ class UserCreate(BaseModel):
|
|||||||
role_id: uuid.UUID | None = None
|
role_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(PydanticBase):
|
||||||
|
"""Schema for reading a user (subset of fields — no email)."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
"""Schema for updating a user."""
|
"""Schema for updating a user."""
|
||||||
|
|
||||||
@@ -160,8 +184,17 @@ class PostM2MUpdate(BaseModel):
|
|||||||
tag_ids: list[uuid.UUID] | None = None
|
tag_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IntRoleCreate(BaseModel):
|
||||||
|
"""Schema for creating an IntRole."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
RoleCrud = CrudFactory(Role)
|
RoleCrud = CrudFactory(Role)
|
||||||
|
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
|
||||||
|
IntRoleCursorCrud = CrudFactory(IntRole, cursor_column=IntRole.id)
|
||||||
UserCrud = CrudFactory(User)
|
UserCrud = CrudFactory(User)
|
||||||
|
UserCursorCrud = CrudFactory(User, cursor_column=User.id)
|
||||||
PostCrud = CrudFactory(Post)
|
PostCrud = CrudFactory(Post)
|
||||||
TagCrud = CrudFactory(Tag)
|
TagCrud = CrudFactory(Tag)
|
||||||
PostM2MCrud = CrudFactory(Post, m2m_fields={"tag_ids": Post.tags})
|
PostM2MCrud = CrudFactory(Post, m2m_fields={"tag_ids": Post.tags})
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from fastapi_toolsets.crud.factory import AsyncCrud
|
|||||||
from fastapi_toolsets.exceptions import NotFoundError
|
from fastapi_toolsets.exceptions import NotFoundError
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
|
IntRoleCreate,
|
||||||
|
IntRoleCursorCrud,
|
||||||
Post,
|
Post,
|
||||||
PostCreate,
|
PostCreate,
|
||||||
PostCrud,
|
PostCrud,
|
||||||
@@ -20,12 +22,16 @@ from .conftest import (
|
|||||||
Role,
|
Role,
|
||||||
RoleCreate,
|
RoleCreate,
|
||||||
RoleCrud,
|
RoleCrud,
|
||||||
|
RoleCursorCrud,
|
||||||
|
RoleRead,
|
||||||
RoleUpdate,
|
RoleUpdate,
|
||||||
TagCreate,
|
TagCreate,
|
||||||
TagCrud,
|
TagCrud,
|
||||||
User,
|
User,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserCrud,
|
UserCrud,
|
||||||
|
UserCursorCrud,
|
||||||
|
UserRead,
|
||||||
UserUpdate,
|
UserUpdate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +56,152 @@ class TestCrudFactory:
|
|||||||
crud = CrudFactory(User)
|
crud = CrudFactory(User)
|
||||||
assert "User" in crud.__name__
|
assert "User" in crud.__name__
|
||||||
|
|
||||||
|
def test_default_load_options_none_by_default(self):
|
||||||
|
"""default_load_options is None when not specified."""
|
||||||
|
crud = CrudFactory(User)
|
||||||
|
assert crud.default_load_options is None
|
||||||
|
|
||||||
|
def test_default_load_options_set(self):
|
||||||
|
"""default_load_options is stored on the class."""
|
||||||
|
options = [selectinload(User.role)]
|
||||||
|
crud = CrudFactory(User, default_load_options=options)
|
||||||
|
assert crud.default_load_options == options
|
||||||
|
|
||||||
|
def test_default_load_options_not_shared_between_classes(self):
|
||||||
|
"""default_load_options is isolated per factory call."""
|
||||||
|
options = [selectinload(User.role)]
|
||||||
|
crud_with = CrudFactory(User, default_load_options=options)
|
||||||
|
crud_without = CrudFactory(User)
|
||||||
|
assert crud_with.default_load_options == options
|
||||||
|
assert crud_without.default_load_options is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveLoadOptions:
|
||||||
|
"""Tests for _resolve_load_options logic."""
|
||||||
|
|
||||||
|
def test_returns_load_options_when_provided(self):
|
||||||
|
"""Explicit load_options takes priority over default_load_options."""
|
||||||
|
options = [selectinload(User.role)]
|
||||||
|
default = [selectinload(Post.tags)]
|
||||||
|
crud = CrudFactory(User, default_load_options=default)
|
||||||
|
assert crud._resolve_load_options(options) == options
|
||||||
|
|
||||||
|
def test_returns_default_when_load_options_is_none(self):
|
||||||
|
"""Falls back to default_load_options when load_options is None."""
|
||||||
|
default = [selectinload(User.role)]
|
||||||
|
crud = CrudFactory(User, default_load_options=default)
|
||||||
|
assert crud._resolve_load_options(None) == default
|
||||||
|
|
||||||
|
def test_returns_none_when_both_are_none(self):
|
||||||
|
"""Returns None when neither load_options nor default_load_options set."""
|
||||||
|
crud = CrudFactory(User)
|
||||||
|
assert crud._resolve_load_options(None) is None
|
||||||
|
|
||||||
|
def test_empty_list_overrides_default(self):
|
||||||
|
"""An empty list is a valid override and disables default_load_options."""
|
||||||
|
default = [selectinload(User.role)]
|
||||||
|
crud = CrudFactory(User, default_load_options=default)
|
||||||
|
# Empty list is not None, so it should replace default
|
||||||
|
assert crud._resolve_load_options([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultLoadOptionsIntegration:
|
||||||
|
"""Integration tests for default_load_options with real DB queries."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_default_load_options_applied_to_get(self, db_session: AsyncSession):
|
||||||
|
"""default_load_options loads relationships automatically on get()."""
|
||||||
|
UserWithDefaultLoad = CrudFactory(
|
||||||
|
User, default_load_options=[selectinload(User.role)]
|
||||||
|
)
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
user = await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
fetched = await UserWithDefaultLoad.get(db_session, [User.id == user.id])
|
||||||
|
assert fetched.role is not None
|
||||||
|
assert fetched.role.name == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_default_load_options_applied_to_get_multi(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""default_load_options loads relationships automatically on get_multi()."""
|
||||||
|
UserWithDefaultLoad = CrudFactory(
|
||||||
|
User, default_load_options=[selectinload(User.role)]
|
||||||
|
)
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
users = await UserWithDefaultLoad.get_multi(db_session)
|
||||||
|
assert users[0].role is not None
|
||||||
|
assert users[0].role.name == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_default_load_options_applied_to_first(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""default_load_options loads relationships automatically on first()."""
|
||||||
|
UserWithDefaultLoad = CrudFactory(
|
||||||
|
User, default_load_options=[selectinload(User.role)]
|
||||||
|
)
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
user = await UserWithDefaultLoad.first(db_session)
|
||||||
|
assert user is not None
|
||||||
|
assert user.role is not None
|
||||||
|
assert user.role.name == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_default_load_options_applied_to_paginate(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""default_load_options loads relationships automatically on paginate()."""
|
||||||
|
UserWithDefaultLoad = CrudFactory(
|
||||||
|
User, default_load_options=[selectinload(User.role)]
|
||||||
|
)
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
result = await UserWithDefaultLoad.paginate(db_session)
|
||||||
|
assert result.data[0].role is not None
|
||||||
|
assert result.data[0].role.name == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_load_options_overrides_default_load_options(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""Explicit load_options fully replaces default_load_options."""
|
||||||
|
PostWithDefaultLoad = CrudFactory(
|
||||||
|
Post,
|
||||||
|
default_load_options=[selectinload(Post.tags)],
|
||||||
|
)
|
||||||
|
user = await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="alice", email="alice@test.com"),
|
||||||
|
)
|
||||||
|
post = await PostCrud.create(
|
||||||
|
db_session,
|
||||||
|
PostCreate(title="Hello", author_id=user.id),
|
||||||
|
)
|
||||||
|
# Pass empty load_options to override default — tags should not load
|
||||||
|
fetched = await PostWithDefaultLoad.get(
|
||||||
|
db_session,
|
||||||
|
[Post.id == post.id],
|
||||||
|
load_options=[],
|
||||||
|
)
|
||||||
|
# tags were not loaded — accessing them would lazy-load or return empty
|
||||||
|
# We just assert the fetch itself succeeded with the override
|
||||||
|
assert fetched.id == post.id
|
||||||
|
|
||||||
|
|
||||||
class TestCrudCreate:
|
class TestCrudCreate:
|
||||||
"""Tests for CRUD create operations."""
|
"""Tests for CRUD create operations."""
|
||||||
@@ -433,8 +585,11 @@ class TestCrudPaginate:
|
|||||||
for i in range(25):
|
for i in range(25):
|
||||||
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
result = await RoleCrud.paginate(db_session, page=1, items_per_page=10)
|
result = await RoleCrud.paginate(db_session, page=1, items_per_page=10)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert len(result.data) == 10
|
assert len(result.data) == 10
|
||||||
assert result.pagination.total_count == 25
|
assert result.pagination.total_count == 25
|
||||||
assert result.pagination.page == 1
|
assert result.pagination.page == 1
|
||||||
@@ -465,6 +620,8 @@ class TestCrudPaginate:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
result = await UserCrud.paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
filters=[User.is_active == True], # noqa: E712
|
filters=[User.is_active == True], # noqa: E712
|
||||||
@@ -472,6 +629,7 @@ class TestCrudPaginate:
|
|||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 5
|
assert result.pagination.total_count == 5
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -687,6 +845,8 @@ class TestCrudJoins:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
# Paginate users with published posts
|
# Paginate users with published posts
|
||||||
result = await UserCrud.paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
@@ -696,6 +856,7 @@ class TestCrudJoins:
|
|||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 3
|
assert result.pagination.total_count == 3
|
||||||
assert len(result.data) == 3
|
assert len(result.data) == 3
|
||||||
|
|
||||||
@@ -718,6 +879,8 @@ class TestCrudJoins:
|
|||||||
UserCreate(username="without_post", email="without@test.com"),
|
UserCreate(username="without_post", email="without@test.com"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
# Paginate with outer join
|
# Paginate with outer join
|
||||||
result = await UserCrud.paginate(
|
result = await UserCrud.paginate(
|
||||||
db_session,
|
db_session,
|
||||||
@@ -727,6 +890,7 @@ class TestCrudJoins:
|
|||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
assert len(result.data) == 2
|
assert len(result.data) == 2
|
||||||
|
|
||||||
@@ -761,14 +925,15 @@ class TestCrudJoins:
|
|||||||
|
|
||||||
|
|
||||||
class TestAsResponse:
|
class TestAsResponse:
|
||||||
"""Tests for as_response parameter."""
|
"""Tests for as_response parameter (deprecated, kept for backward compat)."""
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_create_as_response(self, db_session: AsyncSession):
|
async def test_create_as_response(self, db_session: AsyncSession):
|
||||||
"""Create with as_response=True returns Response."""
|
"""Create with as_response=True returns Response and emits DeprecationWarning."""
|
||||||
from fastapi_toolsets.schemas import Response
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
data = RoleCreate(name="response_role")
|
data = RoleCreate(name="response_role")
|
||||||
|
with pytest.warns(DeprecationWarning, match="as_response is deprecated"):
|
||||||
result = await RoleCrud.create(db_session, data, as_response=True)
|
result = await RoleCrud.create(db_session, data, as_response=True)
|
||||||
|
|
||||||
assert isinstance(result, Response)
|
assert isinstance(result, Response)
|
||||||
@@ -777,10 +942,11 @@ class TestAsResponse:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_as_response(self, db_session: AsyncSession):
|
async def test_get_as_response(self, db_session: AsyncSession):
|
||||||
"""Get with as_response=True returns Response."""
|
"""Get with as_response=True returns Response and emits DeprecationWarning."""
|
||||||
from fastapi_toolsets.schemas import Response
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
created = await RoleCrud.create(db_session, RoleCreate(name="get_response"))
|
created = await RoleCrud.create(db_session, RoleCreate(name="get_response"))
|
||||||
|
with pytest.warns(DeprecationWarning, match="as_response is deprecated"):
|
||||||
result = await RoleCrud.get(
|
result = await RoleCrud.get(
|
||||||
db_session, [Role.id == created.id], as_response=True
|
db_session, [Role.id == created.id], as_response=True
|
||||||
)
|
)
|
||||||
@@ -791,10 +957,11 @@ class TestAsResponse:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_update_as_response(self, db_session: AsyncSession):
|
async def test_update_as_response(self, db_session: AsyncSession):
|
||||||
"""Update with as_response=True returns Response."""
|
"""Update with as_response=True returns Response and emits DeprecationWarning."""
|
||||||
from fastapi_toolsets.schemas import Response
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
created = await RoleCrud.create(db_session, RoleCreate(name="old_name"))
|
created = await RoleCrud.create(db_session, RoleCreate(name="old_name"))
|
||||||
|
with pytest.warns(DeprecationWarning, match="as_response is deprecated"):
|
||||||
result = await RoleCrud.update(
|
result = await RoleCrud.update(
|
||||||
db_session,
|
db_session,
|
||||||
RoleUpdate(name="new_name"),
|
RoleUpdate(name="new_name"),
|
||||||
@@ -808,10 +975,11 @@ class TestAsResponse:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_delete_as_response(self, db_session: AsyncSession):
|
async def test_delete_as_response(self, db_session: AsyncSession):
|
||||||
"""Delete with as_response=True returns Response."""
|
"""Delete with as_response=True returns Response and emits DeprecationWarning."""
|
||||||
from fastapi_toolsets.schemas import Response
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
created = await RoleCrud.create(db_session, RoleCreate(name="to_delete"))
|
created = await RoleCrud.create(db_session, RoleCreate(name="to_delete"))
|
||||||
|
with pytest.warns(DeprecationWarning, match="as_response is deprecated"):
|
||||||
result = await RoleCrud.delete(
|
result = await RoleCrud.delete(
|
||||||
db_session, [Role.id == created.id], as_response=True
|
db_session, [Role.id == created.id], as_response=True
|
||||||
)
|
)
|
||||||
@@ -1198,3 +1366,656 @@ class TestM2MWithNonM2MCrud:
|
|||||||
[Post.id == post.id],
|
[Post.id == post.id],
|
||||||
)
|
)
|
||||||
assert updated.title == "Updated Plain"
|
assert updated.title == "Updated Plain"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaResponse:
|
||||||
|
"""Tests for the schema parameter on as_response methods."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_with_schema(self, db_session: AsyncSession):
|
||||||
|
"""create with schema returns Response[SchemaType]."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
result = await RoleCrud.create(
|
||||||
|
db_session, RoleCreate(name="schema_role"), schema=RoleRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert isinstance(result.data, RoleRead)
|
||||||
|
assert result.data.name == "schema_role"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_schema_implies_as_response(self, db_session: AsyncSession):
|
||||||
|
"""create with schema alone wraps in Response without as_response=True."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
result = await RoleCrud.create(
|
||||||
|
db_session, RoleCreate(name="implicit"), schema=RoleRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_schema_filters_fields(self, db_session: AsyncSession):
|
||||||
|
"""create with schema only exposes schema fields, not all model fields."""
|
||||||
|
result = await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="filtered", email="filtered@test.com"),
|
||||||
|
schema=UserRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.data, UserRead)
|
||||||
|
assert result.data.username == "filtered"
|
||||||
|
assert not hasattr(result.data, "email")
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_with_schema(self, db_session: AsyncSession):
|
||||||
|
"""get with schema returns Response[SchemaType]."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
created = await RoleCrud.create(db_session, RoleCreate(name="get_schema"))
|
||||||
|
result = await RoleCrud.get(
|
||||||
|
db_session, [Role.id == created.id], schema=RoleRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert isinstance(result.data, RoleRead)
|
||||||
|
assert result.data.id == created.id
|
||||||
|
assert result.data.name == "get_schema"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_schema_implies_as_response(self, db_session: AsyncSession):
|
||||||
|
"""get with schema alone wraps in Response without as_response=True."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
created = await RoleCrud.create(db_session, RoleCreate(name="implicit_get"))
|
||||||
|
result = await RoleCrud.get(
|
||||||
|
db_session, [Role.id == created.id], schema=RoleRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_with_schema(self, db_session: AsyncSession):
|
||||||
|
"""update with schema returns Response[SchemaType]."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
created = await RoleCrud.create(db_session, RoleCreate(name="before"))
|
||||||
|
result = await RoleCrud.update(
|
||||||
|
db_session,
|
||||||
|
RoleUpdate(name="after"),
|
||||||
|
[Role.id == created.id],
|
||||||
|
schema=RoleRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert isinstance(result.data, RoleRead)
|
||||||
|
assert result.data.name == "after"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_schema_implies_as_response(self, db_session: AsyncSession):
|
||||||
|
"""update with schema alone wraps in Response without as_response=True."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
created = await RoleCrud.create(db_session, RoleCreate(name="before2"))
|
||||||
|
result = await RoleCrud.update(
|
||||||
|
db_session,
|
||||||
|
RoleUpdate(name="after2"),
|
||||||
|
[Role.id == created.id],
|
||||||
|
schema=RoleRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_paginate_with_schema(self, db_session: AsyncSession):
|
||||||
|
"""paginate with schema returns PaginatedResponse[SchemaType]."""
|
||||||
|
from fastapi_toolsets.schemas import PaginatedResponse
|
||||||
|
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name="p_role1"))
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name="p_role2"))
|
||||||
|
|
||||||
|
result = await RoleCrud.paginate(db_session, schema=RoleRead)
|
||||||
|
|
||||||
|
assert isinstance(result, PaginatedResponse)
|
||||||
|
assert len(result.data) == 2
|
||||||
|
assert all(isinstance(item, RoleRead) for item in result.data)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_paginate_schema_filters_fields(self, db_session: AsyncSession):
|
||||||
|
"""paginate with schema only exposes schema fields per item."""
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="pg_user", email="pg@test.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserCrud.paginate(db_session, schema=UserRead)
|
||||||
|
|
||||||
|
assert isinstance(result.data[0], UserRead)
|
||||||
|
assert result.data[0].username == "pg_user"
|
||||||
|
assert not hasattr(result.data[0], "email")
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_as_response_true_without_schema_unchanged(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""as_response=True without schema still returns Response[ModelType] with a warning."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
created = await RoleCrud.create(db_session, RoleCreate(name="compat"))
|
||||||
|
with pytest.warns(DeprecationWarning, match="as_response is deprecated"):
|
||||||
|
result = await RoleCrud.get(
|
||||||
|
db_session, [Role.id == created.id], as_response=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert isinstance(result.data, Role)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_schema_with_explicit_as_response_true(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""schema combined with explicit as_response=True works correctly."""
|
||||||
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
created = await RoleCrud.create(db_session, RoleCreate(name="combined"))
|
||||||
|
result = await RoleCrud.get(
|
||||||
|
db_session,
|
||||||
|
[Role.id == created.id],
|
||||||
|
as_response=True,
|
||||||
|
schema=RoleRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert isinstance(result.data, RoleRead)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaginateAlias:
|
||||||
|
"""Tests that paginate is a backward-compatible alias for offset_paginate."""
|
||||||
|
|
||||||
|
def test_paginate_is_alias_of_offset_paginate(self):
|
||||||
|
"""paginate and offset_paginate are the same underlying function."""
|
||||||
|
assert RoleCrud.paginate.__func__ is RoleCrud.offset_paginate.__func__
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_paginate_alias_returns_offset_pagination(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""paginate() still works and returns PaginatedResponse with OffsetPagination."""
|
||||||
|
from fastapi_toolsets.schemas import OffsetPagination, PaginatedResponse
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
result = await RoleCrud.paginate(db_session, page=1, items_per_page=10)
|
||||||
|
|
||||||
|
assert isinstance(result, PaginatedResponse)
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count == 3
|
||||||
|
assert result.pagination.page == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorPaginate:
|
||||||
|
"""Tests for cursor-based pagination via cursor_paginate()."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_first_page_no_cursor(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate without cursor returns the first page."""
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination, PaginatedResponse
|
||||||
|
|
||||||
|
for i in range(25):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
|
||||||
|
|
||||||
|
assert isinstance(result, PaginatedResponse)
|
||||||
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
|
assert len(result.data) == 10
|
||||||
|
assert result.pagination.has_more is True
|
||||||
|
assert result.pagination.next_cursor is not None
|
||||||
|
assert result.pagination.prev_cursor is None
|
||||||
|
assert result.pagination.items_per_page == 10
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_last_page(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate returns has_more=False and next_cursor=None on last page."""
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
|
assert len(result.data) == 5
|
||||||
|
assert result.pagination.has_more is False
|
||||||
|
assert result.pagination.next_cursor is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_advances_correctly(self, db_session: AsyncSession):
|
||||||
|
"""Providing next_cursor from the first page returns the next page."""
|
||||||
|
for i in range(15):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
|
||||||
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
|
assert len(page1.data) == 10
|
||||||
|
assert page1.pagination.has_more is True
|
||||||
|
|
||||||
|
cursor = page1.pagination.next_cursor
|
||||||
|
page2 = await RoleCursorCrud.cursor_paginate(
|
||||||
|
db_session, cursor=cursor, items_per_page=10
|
||||||
|
)
|
||||||
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
|
assert len(page2.data) == 5
|
||||||
|
assert page2.pagination.has_more is False
|
||||||
|
assert page2.pagination.next_cursor is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_no_duplicates_across_pages(self, db_session: AsyncSession):
|
||||||
|
"""Items from consecutive cursor pages are non-overlapping and cover all rows."""
|
||||||
|
for i in range(7):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=4)
|
||||||
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
|
page2 = await RoleCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
cursor=page1.pagination.next_cursor,
|
||||||
|
items_per_page=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
ids_page1 = {r.id for r in page1.data}
|
||||||
|
ids_page2 = {r.id for r in page2.data}
|
||||||
|
assert ids_page1.isdisjoint(ids_page2)
|
||||||
|
assert len(ids_page1 | ids_page2) == 7
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_empty_table(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate on an empty table returns empty data with no cursor."""
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
|
assert result.data == []
|
||||||
|
assert result.pagination.has_more is False
|
||||||
|
assert result.pagination.next_cursor is None
|
||||||
|
assert result.pagination.prev_cursor is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_with_filters(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate respects filters."""
|
||||||
|
for i in range(10):
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(
|
||||||
|
username=f"user{i}",
|
||||||
|
email=f"user{i}@test.com",
|
||||||
|
is_active=i % 2 == 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
filters=[User.is_active == True], # noqa: E712
|
||||||
|
items_per_page=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result.data) == 5
|
||||||
|
assert all(u.is_active for u in result.data)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_with_schema(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate with schema serializes items into the schema."""
|
||||||
|
from fastapi_toolsets.schemas import PaginatedResponse
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
result = await RoleCursorCrud.cursor_paginate(db_session, schema=RoleRead)
|
||||||
|
|
||||||
|
assert isinstance(result, PaginatedResponse)
|
||||||
|
assert all(isinstance(item, RoleRead) for item in result.data)
|
||||||
|
assert all(
|
||||||
|
hasattr(item, "id") and hasattr(item, "name") for item in result.data
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_with_cursor_column(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate uses cursor_column set on CrudFactory."""
|
||||||
|
from fastapi_toolsets.crud import CrudFactory
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
RoleNameCrud = CrudFactory(Role, cursor_column=Role.name)
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
await RoleNameCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
result = await RoleNameCrud.cursor_paginate(db_session, items_per_page=3)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
|
assert len(result.data) == 3
|
||||||
|
assert result.pagination.has_more is True
|
||||||
|
assert result.pagination.next_cursor is not None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_raises_without_cursor_column(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate raises ValueError when cursor_column is not configured."""
|
||||||
|
with pytest.raises(ValueError, match="cursor_column is not set"):
|
||||||
|
await RoleCrud.cursor_paginate(db_session)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorPaginatePrevCursor:
|
||||||
|
"""Tests for prev_cursor behavior in cursor_paginate()."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_prev_cursor_none_on_first_page(self, db_session: AsyncSession):
|
||||||
|
"""prev_cursor is None when no cursor was provided (first page)."""
|
||||||
|
for i in range(5):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=3)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
|
assert result.pagination.prev_cursor is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_prev_cursor_set_on_subsequent_pages(self, db_session: AsyncSession):
|
||||||
|
"""prev_cursor is set when a cursor was provided (subsequent pages)."""
|
||||||
|
for i in range(10):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=5)
|
||||||
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
|
page2 = await RoleCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
cursor=page1.pagination.next_cursor,
|
||||||
|
items_per_page=5,
|
||||||
|
)
|
||||||
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
|
assert page2.pagination.prev_cursor is not None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_prev_cursor_points_to_first_item(self, db_session: AsyncSession):
|
||||||
|
"""prev_cursor encodes the value of the first item on the current page."""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=5)
|
||||||
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
|
page2 = await RoleCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
cursor=page1.pagination.next_cursor,
|
||||||
|
items_per_page=5,
|
||||||
|
)
|
||||||
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
|
assert page2.pagination.prev_cursor is not None
|
||||||
|
|
||||||
|
# Decode prev_cursor and compare to first item's id
|
||||||
|
decoded = json.loads(
|
||||||
|
base64.b64decode(page2.pagination.prev_cursor.encode()).decode()
|
||||||
|
)
|
||||||
|
first_item_id = str(page2.data[0].id)
|
||||||
|
assert decoded == first_item_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorPaginateWithSearch:
|
||||||
|
"""Tests for cursor_paginate() combined with search."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cursor_paginate_with_search(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate respects search filters alongside cursor predicate."""
|
||||||
|
from fastapi_toolsets.crud import CrudFactory
|
||||||
|
|
||||||
|
# Create a CRUD with searchable fields and cursor column
|
||||||
|
SearchableRoleCrud = CrudFactory(
|
||||||
|
Role, searchable_fields=[Role.name], cursor_column=Role.id
|
||||||
|
)
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"admin{i:02d}"))
|
||||||
|
for i in range(5):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"user{i:02d}"))
|
||||||
|
|
||||||
|
result = await SearchableRoleCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
search="admin",
|
||||||
|
items_per_page=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result.data) == 5
|
||||||
|
assert all("admin" in r.name for r in result.data)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorPaginateExtraOptions:
|
||||||
|
"""Tests for cursor_paginate() covering joins, load_options, and order_by."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_with_joins(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate applies explicit inner joins."""
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="member"))
|
||||||
|
for i in range(5):
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(
|
||||||
|
username=f"u{i}",
|
||||||
|
email=f"u{i}@test.com",
|
||||||
|
role_id=role.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# One user without a role to confirm inner join excludes them
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="norole", email="norole@test.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
joins=[(Role, User.role_id == Role.id)],
|
||||||
|
items_per_page=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
|
# Only users with a role are returned (inner join)
|
||||||
|
assert len(result.data) == 5
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_with_outer_join(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate applies LEFT OUTER JOIN when outer_join=True."""
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="member"))
|
||||||
|
for i in range(3):
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(
|
||||||
|
username=f"u{i}",
|
||||||
|
email=f"u{i}@test.com",
|
||||||
|
role_id=role.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="norole", email="norole@test.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
joins=[(Role, User.role_id == Role.id)],
|
||||||
|
outer_join=True,
|
||||||
|
items_per_page=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
|
# All users are included (outer join)
|
||||||
|
assert len(result.data) == 4
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_with_load_options(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate passes load_options to the query."""
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="manager"))
|
||||||
|
for i in range(3):
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(
|
||||||
|
username=f"u{i}",
|
||||||
|
email=f"u{i}@test.com",
|
||||||
|
role_id=role.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
load_options=[selectinload(User.role)],
|
||||||
|
items_per_page=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
|
assert len(result.data) == 3
|
||||||
|
# Relationship was eagerly loaded
|
||||||
|
assert all(u.role is not None for u in result.data)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_with_order_by(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate applies additional order_by after the cursor column."""
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
result = await RoleCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
order_by=Role.name.desc(),
|
||||||
|
items_per_page=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
|
assert len(result.data) == 3
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_integer_cursor_column(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate decodes Integer cursor values correctly."""
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
await IntRoleCursorCrud.create(db_session, IntRoleCreate(name=f"role{i}"))
|
||||||
|
|
||||||
|
page1 = await IntRoleCursorCrud.cursor_paginate(db_session, items_per_page=3)
|
||||||
|
|
||||||
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
|
assert len(page1.data) == 3
|
||||||
|
assert page1.pagination.has_more is True
|
||||||
|
|
||||||
|
page2 = await IntRoleCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
cursor=page1.pagination.next_cursor,
|
||||||
|
items_per_page=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
|
assert len(page2.data) == 2
|
||||||
|
assert page2.pagination.has_more is False
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_string_cursor_column(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate decodes non-UUID/non-Integer cursor values (string branch)."""
|
||||||
|
from fastapi_toolsets.crud import CrudFactory
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
RoleNameCursorCrud = CrudFactory(Role, cursor_column=Role.name)
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
|
||||||
|
|
||||||
|
page1 = await RoleNameCursorCrud.cursor_paginate(db_session, items_per_page=3)
|
||||||
|
|
||||||
|
assert isinstance(page1.pagination, CursorPagination)
|
||||||
|
assert len(page1.data) == 3
|
||||||
|
assert page1.pagination.has_more is True
|
||||||
|
|
||||||
|
page2 = await RoleNameCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
cursor=page1.pagination.next_cursor,
|
||||||
|
items_per_page=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(page2.pagination, CursorPagination)
|
||||||
|
assert len(page2.data) == 2
|
||||||
|
assert page2.pagination.has_more is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorPaginateSearchJoins:
|
||||||
|
"""Tests for cursor_paginate() search that traverses relationships (search_joins)."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_search_via_relationship(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate outerjoin search-join when searching through a relationship."""
|
||||||
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
|
role_admin = await RoleCrud.create(db_session, RoleCreate(name="administrator"))
|
||||||
|
role_user = await RoleCrud.create(db_session, RoleCreate(name="regularuser"))
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(
|
||||||
|
username=f"admin_u{i}",
|
||||||
|
email=f"admin_u{i}@test.com",
|
||||||
|
role_id=role_admin.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for i in range(2):
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(
|
||||||
|
username=f"reg_u{i}",
|
||||||
|
email=f"reg_u{i}@test.com",
|
||||||
|
role_id=role_user.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
search="administrator",
|
||||||
|
search_fields=[(User.role, Role.name)],
|
||||||
|
items_per_page=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, CursorPagination)
|
||||||
|
assert len(result.data) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetWithForUpdate:
|
||||||
|
"""Tests for get() with with_for_update=True."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_with_for_update(self, db_session: AsyncSession):
|
||||||
|
"""get() with with_for_update=True locks the row."""
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="locked"))
|
||||||
|
|
||||||
|
result = await RoleCrud.get(
|
||||||
|
db_session,
|
||||||
|
filters=[Role.id == role.id],
|
||||||
|
with_for_update=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.id == role.id
|
||||||
|
assert result.name == "locked"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pytest
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from fastapi_toolsets.crud import SearchConfig, get_searchable_fields
|
from fastapi_toolsets.crud import SearchConfig, get_searchable_fields
|
||||||
|
from fastapi_toolsets.schemas import OffsetPagination
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
Role,
|
Role,
|
||||||
@@ -39,6 +40,7 @@ class TestPaginateSearch:
|
|||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -57,6 +59,7 @@ class TestPaginateSearch:
|
|||||||
search_fields=[User.username, User.email],
|
search_fields=[User.username, User.email],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -84,6 +87,7 @@ class TestPaginateSearch:
|
|||||||
search_fields=[(User.role, Role.name)],
|
search_fields=[(User.role, Role.name)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -102,6 +106,7 @@ class TestPaginateSearch:
|
|||||||
search_fields=[User.username, (User.role, Role.name)],
|
search_fields=[User.username, (User.role, Role.name)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 1
|
assert result.pagination.total_count == 1
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -117,6 +122,7 @@ class TestPaginateSearch:
|
|||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 1
|
assert result.pagination.total_count == 1
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -132,6 +138,7 @@ class TestPaginateSearch:
|
|||||||
search=SearchConfig(query="johndoe", case_sensitive=True),
|
search=SearchConfig(query="johndoe", case_sensitive=True),
|
||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
)
|
)
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 0
|
assert result.pagination.total_count == 0
|
||||||
|
|
||||||
# Should find (case match)
|
# Should find (case match)
|
||||||
@@ -140,6 +147,7 @@ class TestPaginateSearch:
|
|||||||
search=SearchConfig(query="JohnDoe", case_sensitive=True),
|
search=SearchConfig(query="JohnDoe", case_sensitive=True),
|
||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
)
|
)
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 1
|
assert result.pagination.total_count == 1
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -153,9 +161,11 @@ class TestPaginateSearch:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await UserCrud.paginate(db_session, search="")
|
result = await UserCrud.paginate(db_session, search="")
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
|
|
||||||
result = await UserCrud.paginate(db_session, search=None)
|
result = await UserCrud.paginate(db_session, search=None)
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -177,6 +187,7 @@ class TestPaginateSearch:
|
|||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 1
|
assert result.pagination.total_count == 1
|
||||||
assert result.data[0].username == "active_john"
|
assert result.data[0].username == "active_john"
|
||||||
|
|
||||||
@@ -189,6 +200,7 @@ class TestPaginateSearch:
|
|||||||
|
|
||||||
result = await UserCrud.paginate(db_session, search="findme")
|
result = await UserCrud.paginate(db_session, search="findme")
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 1
|
assert result.pagination.total_count == 1
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -204,6 +216,7 @@ class TestPaginateSearch:
|
|||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 0
|
assert result.pagination.total_count == 0
|
||||||
assert result.data == []
|
assert result.data == []
|
||||||
|
|
||||||
@@ -224,6 +237,7 @@ class TestPaginateSearch:
|
|||||||
items_per_page=5,
|
items_per_page=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 15
|
assert result.pagination.total_count == 15
|
||||||
assert len(result.data) == 5
|
assert len(result.data) == 5
|
||||||
assert result.pagination.has_more is True
|
assert result.pagination.has_more is True
|
||||||
@@ -248,6 +262,7 @@ class TestPaginateSearch:
|
|||||||
search_fields=[User.username],
|
search_fields=[User.username],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -270,6 +285,7 @@ class TestPaginateSearch:
|
|||||||
order_by=User.username,
|
order_by=User.username,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 3
|
assert result.pagination.total_count == 3
|
||||||
usernames = [u.username for u in result.data]
|
usernames = [u.username for u in result.data]
|
||||||
assert usernames == ["alice", "bob", "charlie"]
|
assert usernames == ["alice", "bob", "charlie"]
|
||||||
@@ -292,6 +308,7 @@ class TestPaginateSearch:
|
|||||||
search_fields=[User.id, User.username],
|
search_fields=[User.id, User.username],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 1
|
assert result.pagination.total_count == 1
|
||||||
assert result.data[0].id == user_id
|
assert result.data[0].id == user_id
|
||||||
|
|
||||||
@@ -318,6 +335,7 @@ class TestSearchConfig:
|
|||||||
search_fields=[User.username, User.email],
|
search_fields=[User.username, User.email],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 1
|
assert result.pagination.total_count == 1
|
||||||
assert result.data[0].username == "john_test"
|
assert result.data[0].username == "john_test"
|
||||||
|
|
||||||
@@ -333,6 +351,7 @@ class TestSearchConfig:
|
|||||||
search=SearchConfig(query="findme", fields=[User.email]),
|
search=SearchConfig(query="findme", fields=[User.email]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 1
|
assert result.pagination.total_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from fastapi_toolsets.schemas import (
|
from fastapi_toolsets.schemas import (
|
||||||
ApiError,
|
ApiError,
|
||||||
|
CursorPagination,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
|
OffsetPagination,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Pagination,
|
Pagination,
|
||||||
Response,
|
Response,
|
||||||
@@ -154,12 +156,12 @@ class TestErrorResponse:
|
|||||||
assert data["description"] == "Details"
|
assert data["description"] == "Details"
|
||||||
|
|
||||||
|
|
||||||
class TestPagination:
|
class TestOffsetPagination:
|
||||||
"""Tests for Pagination schema."""
|
"""Tests for OffsetPagination schema (canonical name for offset-based pagination)."""
|
||||||
|
|
||||||
def test_create_pagination(self):
|
def test_create_pagination(self):
|
||||||
"""Create Pagination with all fields."""
|
"""Create OffsetPagination with all fields."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=100,
|
total_count=100,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -173,7 +175,7 @@ class TestPagination:
|
|||||||
|
|
||||||
def test_last_page_has_more_false(self):
|
def test_last_page_has_more_false(self):
|
||||||
"""Last page has has_more=False."""
|
"""Last page has has_more=False."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=25,
|
total_count=25,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=3,
|
page=3,
|
||||||
@@ -183,8 +185,8 @@ class TestPagination:
|
|||||||
assert pagination.has_more is False
|
assert pagination.has_more is False
|
||||||
|
|
||||||
def test_serialization(self):
|
def test_serialization(self):
|
||||||
"""Pagination serializes correctly."""
|
"""OffsetPagination serializes correctly."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=50,
|
total_count=50,
|
||||||
items_per_page=20,
|
items_per_page=20,
|
||||||
page=2,
|
page=2,
|
||||||
@@ -197,6 +199,77 @@ class TestPagination:
|
|||||||
assert data["page"] == 2
|
assert data["page"] == 2
|
||||||
assert data["has_more"] is True
|
assert data["has_more"] is True
|
||||||
|
|
||||||
|
def test_pagination_alias_is_offset_pagination(self):
|
||||||
|
"""Pagination is a backward-compatible alias for OffsetPagination."""
|
||||||
|
assert Pagination is OffsetPagination
|
||||||
|
|
||||||
|
def test_pagination_alias_constructs_offset_pagination(self):
|
||||||
|
"""Code using Pagination(...) still works unchanged."""
|
||||||
|
pagination = Pagination(
|
||||||
|
total_count=10,
|
||||||
|
items_per_page=5,
|
||||||
|
page=2,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
assert isinstance(pagination, OffsetPagination)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorPagination:
|
||||||
|
"""Tests for CursorPagination schema."""
|
||||||
|
|
||||||
|
def test_create_with_next_cursor(self):
|
||||||
|
"""CursorPagination with a next cursor indicates more pages."""
|
||||||
|
pagination = CursorPagination(
|
||||||
|
next_cursor="eyJ2YWx1ZSI6ICIxMjMifQ==",
|
||||||
|
items_per_page=20,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
assert pagination.next_cursor == "eyJ2YWx1ZSI6ICIxMjMifQ=="
|
||||||
|
assert pagination.prev_cursor is None
|
||||||
|
assert pagination.items_per_page == 20
|
||||||
|
assert pagination.has_more is True
|
||||||
|
|
||||||
|
def test_create_last_page(self):
|
||||||
|
"""CursorPagination for the last page has next_cursor=None and has_more=False."""
|
||||||
|
pagination = CursorPagination(
|
||||||
|
next_cursor=None,
|
||||||
|
items_per_page=20,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
assert pagination.next_cursor is None
|
||||||
|
assert pagination.has_more is False
|
||||||
|
|
||||||
|
def test_prev_cursor_defaults_to_none(self):
|
||||||
|
"""prev_cursor defaults to None."""
|
||||||
|
pagination = CursorPagination(
|
||||||
|
next_cursor=None, items_per_page=10, has_more=False
|
||||||
|
)
|
||||||
|
assert pagination.prev_cursor is None
|
||||||
|
|
||||||
|
def test_prev_cursor_can_be_set(self):
|
||||||
|
"""prev_cursor can be explicitly set."""
|
||||||
|
pagination = CursorPagination(
|
||||||
|
next_cursor="next123",
|
||||||
|
prev_cursor="prev456",
|
||||||
|
items_per_page=10,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
assert pagination.prev_cursor == "prev456"
|
||||||
|
|
||||||
|
def test_serialization(self):
|
||||||
|
"""CursorPagination serializes correctly."""
|
||||||
|
pagination = CursorPagination(
|
||||||
|
next_cursor="abc123",
|
||||||
|
prev_cursor="xyz789",
|
||||||
|
items_per_page=20,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
data = pagination.model_dump()
|
||||||
|
assert data["next_cursor"] == "abc123"
|
||||||
|
assert data["prev_cursor"] == "xyz789"
|
||||||
|
assert data["items_per_page"] == 20
|
||||||
|
assert data["has_more"] is True
|
||||||
|
|
||||||
|
|
||||||
class TestPaginatedResponse:
|
class TestPaginatedResponse:
|
||||||
"""Tests for PaginatedResponse schema."""
|
"""Tests for PaginatedResponse schema."""
|
||||||
@@ -214,6 +287,7 @@ class TestPaginatedResponse:
|
|||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(response.pagination, OffsetPagination)
|
||||||
assert len(response.data) == 2
|
assert len(response.data) == 2
|
||||||
assert response.pagination.total_count == 30
|
assert response.pagination.total_count == 30
|
||||||
assert response.status == ResponseStatus.SUCCESS
|
assert response.status == ResponseStatus.SUCCESS
|
||||||
@@ -247,6 +321,7 @@ class TestPaginatedResponse:
|
|||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert isinstance(response.pagination, OffsetPagination)
|
||||||
assert response.data == []
|
assert response.data == []
|
||||||
assert response.pagination.total_count == 0
|
assert response.pagination.total_count == 0
|
||||||
|
|
||||||
@@ -290,6 +365,36 @@ class TestPaginatedResponse:
|
|||||||
assert data["data"] == ["item1", "item2"]
|
assert data["data"] == ["item1", "item2"]
|
||||||
assert data["pagination"]["page"] == 5
|
assert data["pagination"]["page"] == 5
|
||||||
|
|
||||||
|
def test_pagination_field_accepts_offset_pagination(self):
|
||||||
|
"""PaginatedResponse.pagination accepts OffsetPagination."""
|
||||||
|
response = PaginatedResponse(
|
||||||
|
data=[1, 2],
|
||||||
|
pagination=OffsetPagination(
|
||||||
|
total_count=2, items_per_page=10, page=1, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert isinstance(response.pagination, OffsetPagination)
|
||||||
|
|
||||||
|
def test_pagination_field_accepts_cursor_pagination(self):
|
||||||
|
"""PaginatedResponse.pagination accepts CursorPagination."""
|
||||||
|
response = PaginatedResponse(
|
||||||
|
data=[1, 2],
|
||||||
|
pagination=CursorPagination(
|
||||||
|
next_cursor=None, items_per_page=10, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert isinstance(response.pagination, CursorPagination)
|
||||||
|
|
||||||
|
def test_pagination_alias_accepted(self):
|
||||||
|
"""Constructing PaginatedResponse with Pagination (alias) still works."""
|
||||||
|
response = PaginatedResponse(
|
||||||
|
data=[],
|
||||||
|
pagination=Pagination(
|
||||||
|
total_count=0, items_per_page=10, page=1, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert isinstance(response.pagination, OffsetPagination)
|
||||||
|
|
||||||
|
|
||||||
class TestFromAttributes:
|
class TestFromAttributes:
|
||||||
"""Tests for from_attributes config (ORM mode)."""
|
"""Tests for from_attributes config (ORM mode)."""
|
||||||
|
|||||||
485
uv.lock
generated
485
uv.lock
generated
@@ -215,6 +215,15 @@ toml = [
|
|||||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deepmerge"
|
||||||
|
version = "2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "execnet"
|
name = "execnet"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -242,7 +251,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "0.10.0"
|
version = "1.1.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -262,18 +271,6 @@ all = [
|
|||||||
cli = [
|
cli = [
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
]
|
]
|
||||||
dev = [
|
|
||||||
{ name = "coverage" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "prometheus-client" },
|
|
||||||
{ name = "pytest" },
|
|
||||||
{ name = "pytest-anyio" },
|
|
||||||
{ name = "pytest-cov" },
|
|
||||||
{ name = "pytest-xdist" },
|
|
||||||
{ name = "ruff" },
|
|
||||||
{ name = "ty" },
|
|
||||||
{ name = "typer" },
|
|
||||||
]
|
|
||||||
metrics = [
|
metrics = [
|
||||||
{ name = "prometheus-client" },
|
{ name = "prometheus-client" },
|
||||||
]
|
]
|
||||||
@@ -282,7 +279,26 @@ pytest = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-xdist" },
|
{ name = "pytest-xdist" },
|
||||||
]
|
]
|
||||||
test = [
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "fastapi-toolsets", extra = ["all"] },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "mkdocstrings-python" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-anyio" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-xdist" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
{ name = "ty" },
|
||||||
|
{ name = "zensical" },
|
||||||
|
]
|
||||||
|
docs = [
|
||||||
|
{ name = "mkdocstrings-python" },
|
||||||
|
{ name = "zensical" },
|
||||||
|
]
|
||||||
|
tests = [
|
||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -294,24 +310,56 @@ test = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||||
{ name = "coverage", marker = "extra == 'test'", specifier = ">=7.0.0" },
|
|
||||||
{ name = "fastapi", specifier = ">=0.100.0" },
|
{ name = "fastapi", specifier = ">=0.100.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["all", "test"], marker = "extra == 'dev'" },
|
|
||||||
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" },
|
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" },
|
||||||
{ name = "fastapi-toolsets", extras = ["pytest"], marker = "extra == 'test'" },
|
|
||||||
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
||||||
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
||||||
{ name = "pytest-anyio", marker = "extra == 'test'", specifier = ">=0.0.0" },
|
|
||||||
{ name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.0.0" },
|
|
||||||
{ name = "pytest-xdist", marker = "extra == 'pytest'", specifier = ">=3.0.0" },
|
{ name = "pytest-xdist", marker = "extra == 'pytest'", specifier = ">=3.0.0" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a0" },
|
|
||||||
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["cli", "metrics", "pytest", "all", "test", "dev"]
|
provides-extras = ["cli", "metrics", "pytest", "all"]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
|
{ name = "fastapi-toolsets", extras = ["all"] },
|
||||||
|
{ name = "httpx", specifier = ">=0.25.0" },
|
||||||
|
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||||
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
|
{ name = "pytest-anyio", specifier = ">=0.0.0" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=4.0.0" },
|
||||||
|
{ name = "pytest-xdist", specifier = ">=3.0.0" },
|
||||||
|
{ name = "ruff", specifier = ">=0.1.0" },
|
||||||
|
{ name = "ty", specifier = ">=0.0.1a0" },
|
||||||
|
{ name = "zensical", specifier = ">=0.0.23" },
|
||||||
|
]
|
||||||
|
docs = [
|
||||||
|
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||||
|
{ name = "zensical", specifier = ">=0.0.23" },
|
||||||
|
]
|
||||||
|
tests = [
|
||||||
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
|
{ name = "httpx", specifier = ">=0.25.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
|
{ name = "pytest-anyio", specifier = ">=0.0.0" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=4.0.0" },
|
||||||
|
{ name = "pytest-xdist", specifier = ">=3.0.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2022-05-02T15:47:16.11Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
@@ -365,6 +413,38 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "griffe"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "griffecli" },
|
||||||
|
{ name = "griffelib" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "griffecli"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama" },
|
||||||
|
{ name = "griffelib" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "griffelib"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -420,6 +500,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja2"
|
||||||
|
version = "3.1.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown"
|
||||||
|
version = "3.10.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
@@ -432,6 +533,80 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mdurl"
|
name = "mdurl"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -441,6 +616,98 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2021-02-05T18:55:30.623Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mkdocs"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ 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, upload-time = "2024-08-30T12:24:06.899Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mkdocs-autorefs"
|
||||||
|
version = "1.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown" },
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
{ name = "mkdocs" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2023-11-20T17:51:09.981Z" }
|
||||||
|
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, upload-time = "2023-11-20T17:51:08.587Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mkdocstrings"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "markdown" },
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
{ name = "mkdocs" },
|
||||||
|
{ name = "mkdocs-autorefs" },
|
||||||
|
{ name = "pymdown-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/46/62/0dfc5719514115bf1781f44b1d7f2a0923fcc01e9c5d7990e48a05c9ae5d/mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434", size = 100946, upload-time = "2026-02-07T14:31:40.973Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/41/1cf02e3df279d2dd846a1bf235a928254eba9006dd22b4a14caa71aed0f7/mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046", size = 35523, upload-time = "2026-02-07T14:31:39.27Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mkdocstrings-python"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "griffe" },
|
||||||
|
{ name = "mkdocs-autorefs" },
|
||||||
|
{ name = "mkdocstrings" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/25/84/78243847ad9d5c21d30a2842720425b17e880d99dfe824dee11d6b2149b4/mkdocstrings_python-2.0.2.tar.gz", hash = "sha256:4a32ccfc4b8d29639864698e81cfeb04137bce76bb9f3c251040f55d4b6e1ad8", size = 199124, upload-time = "2026-02-09T15:12:01.543Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/31/7ee938abbde2322e553a2cb5f604cdd1e4728e08bba39c7ee6fae9af840b/mkdocstrings_python-2.0.2-py3-none-any.whl", hash = "sha256:31241c0f43d85a69306d704d5725786015510ea3f3c4bdfdb5a5731d83cdc2b0", size = 104900, upload-time = "2026-02-09T15:12:00.166Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.0"
|
version = "26.0"
|
||||||
@@ -450,6 +717,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.9.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -589,6 +874,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pymdown-extensions"
|
||||||
|
version = "10.21"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.2"
|
version = "9.0.2"
|
||||||
@@ -645,6 +943,85 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml-env-tag"
|
||||||
|
version = "1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rich"
|
name = "rich"
|
||||||
version = "14.3.2"
|
version = "14.3.2"
|
||||||
@@ -692,6 +1069,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.46"
|
version = "2.0.46"
|
||||||
@@ -872,3 +1258,58 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchdog"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zensical"
|
||||||
|
version = "0.0.23"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "deepmerge" },
|
||||||
|
{ name = "markdown" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "pymdown-extensions" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/ab/a65452b4e769552fd5a78c4996d6cf322630d896ddfd55c5433d96485e8b/zensical-0.0.23.tar.gz", hash = "sha256:5c4fc3aaf075df99d8cf41b9f2566e4d588180d9a89493014d3607dfe50ac4bc", size = 3822451, upload-time = "2026-02-11T21:24:38.373Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/86/035aa02bd36d26a03a1885bc22a73d4fe61ba0e21d0033cc42baf13d24f6/zensical-0.0.23-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35d6d3eb803fe73a67187a1a25443408bd02a8dd50e151f4a4bafd40de3f0928", size = 12242966, upload-time = "2026-02-11T21:24:05.894Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/68/335dfbb7efc972964f0610736a0ad243dd8a5dcc2ec76b9ddb84c847a4a4/zensical-0.0.23-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:5973267460a190f348f24d445ff0c01e8ed334fd075947687b305e68257f6b18", size = 12125173, upload-time = "2026-02-11T21:24:08.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/9c/d567da04fbeb077df5cf06a94f947af829ebef0ff5ca7d0ba4910a6cbdf6/zensical-0.0.23-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:953adf1f0b346a6c65fc6e05e6cc1c38a6440fec29c50c76fb29700cc1927006", size = 12489636, upload-time = "2026-02-11T21:24:10.91Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/6e/481a3ecf8a7b63a35c67f5be1ea548185d55bb1dacead54f76a9550197b2/zensical-0.0.23-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49c1cbd6131dafa056be828e081759184f9b8dd24b99bf38d1e77c8c31b0c720", size = 12421313, upload-time = "2026-02-11T21:24:13.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/aa/a95481547f708432636f5f8155917c90d877c244c62124a084f7448b60b2/zensical-0.0.23-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b7fe22c5d33b2b91899c5df7631ad4ce9cccfabac2560cc92ba73eafe2d297", size = 12761031, upload-time = "2026-02-11T21:24:17.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/9f/ce1c5af9afd11fe3521a90441aba48c484f98730c6d833d69ee4387ae2e9/zensical-0.0.23-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a3679d6bf6374f503afb74d9f6061da5de83c25922f618042b63a30b16f0389", size = 12527415, upload-time = "2026-02-11T21:24:19.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/b8/13a5d4d99f3b77e7bf4e791ef991a611ca2f108ed7eddf20858544ab0a91/zensical-0.0.23-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54d981e21a19c3dcec6e7fa77c4421db47389dfdff20d29fea70df8e1be4062e", size = 12665352, upload-time = "2026-02-11T21:24:22.703Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/84/3d0a187ed941826ca26b19a661c41685d8017b2a019afa0d353eb2ebbdba/zensical-0.0.23-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:afde7865cc3c79c99f6df4a911d638fb2c3b472a1b81367d47163f8e3c36f910", size = 12689042, upload-time = "2026-02-11T21:24:26.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/65/12466408f428f2cf7140b32d484753db0891debae3c956f4c076b51eeb17/zensical-0.0.23-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c484674d7b0a3e6d39db83914db932249bccdef2efaf8a5669671c66c16f584d", size = 12834779, upload-time = "2026-02-11T21:24:28.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/ab/0771ac6ffb30e4f04c20374e3beca9e71c3f81112219cdbd86cdc0e3d337/zensical-0.0.23-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:927d12fe2851f355fb3206809e04641d6651bdd2ff4afe9c205721aa3a32aa82", size = 12797057, upload-time = "2026-02-11T21:24:31.383Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/ce/fbd45c00a1cba15508ea3c29b121b4be010254eb65c1512bf11f4478496c/zensical-0.0.23-cp310-abi3-win32.whl", hash = "sha256:ffb79db4244324e9cc063d16adff25a40b145153e5e76d75e0012ba3c05af25d", size = 11837823, upload-time = "2026-02-11T21:24:33.869Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/82/0aebaa8e7d2e6314a85d9b7ff3f7fc74837a94086b56a9d5d8f2240e9b9c/zensical-0.0.23-cp310-abi3-win_amd64.whl", hash = "sha256:a8cfe240dca75231e8e525985366d010d09ee73aec0937930e88f7230694ce01", size = 12036837, upload-time = "2026-02-11T21:24:36.163Z" },
|
||||||
|
]
|
||||||
|
|||||||
318
zensical.toml
Normal file
318
zensical.toml
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# The configuration produced by default is meant to highlight the features
|
||||||
|
# that Zensical provides and to serve as a starting point for your own
|
||||||
|
# projects.
|
||||||
|
#
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
[project]
|
||||||
|
|
||||||
|
# The site_name is shown in the page header and the browser window title
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/setup/basics/#site_name
|
||||||
|
site_name = "FastAPI Toolsets"
|
||||||
|
|
||||||
|
# The site_description is included in the HTML head and should contain a
|
||||||
|
# meaningful description of the site content for use by search engines.
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/setup/basics/#site_description
|
||||||
|
site_description = "Production-ready utilities for FastAPI applications."
|
||||||
|
|
||||||
|
# The site_author attribute. This is used in the HTML head element.
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/setup/basics/#site_author
|
||||||
|
site_author = "d3vyce"
|
||||||
|
|
||||||
|
# The site_url is the canonical URL for your site. When building online
|
||||||
|
# documentation you should set this.
|
||||||
|
# Read more: https://zensical.org/docs/setup/basics/#site_url
|
||||||
|
site_url = "https://fastapi-toolsets.d3vyce.fr"
|
||||||
|
|
||||||
|
# The copyright notice appears in the page footer and can contain an HTML
|
||||||
|
# fragment.
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/setup/basics/#copyright
|
||||||
|
copyright = """
|
||||||
|
Copyright © 2026 d3vyce
|
||||||
|
"""
|
||||||
|
|
||||||
|
repo_url = "https://github.com/d3vyce/fastapi-toolsets"
|
||||||
|
|
||||||
|
# Zensical supports both implicit navigation and explicitly defined navigation.
|
||||||
|
# If you decide not to define a navigation here then Zensical will simply
|
||||||
|
# derive the navigation structure from the directory structure of your
|
||||||
|
# "docs_dir". The definition below demonstrates how a navigation structure
|
||||||
|
# can be defined using TOML syntax.
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/setup/navigation/
|
||||||
|
# nav = [
|
||||||
|
# { "Get started" = "index.md" },
|
||||||
|
# { "Markdown in 5min" = "markdown.md" },
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# With the "extra_css" option you can add your own CSS styling to customize
|
||||||
|
# your Zensical project according to your needs. You can add any number of
|
||||||
|
# CSS files.
|
||||||
|
#
|
||||||
|
# The path provided should be relative to the "docs_dir".
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/customization/#additional-css
|
||||||
|
#
|
||||||
|
#extra_css = ["stylesheets/extra.css"]
|
||||||
|
|
||||||
|
# With the `extra_javascript` option you can add your own JavaScript to your
|
||||||
|
# project to customize the behavior according to your needs.
|
||||||
|
#
|
||||||
|
# The path provided should be relative to the "docs_dir".
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/customization/#additional-javascript
|
||||||
|
#extra_javascript = ["javascripts/extra.js"]
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Section for configuring theme options
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
[project.theme]
|
||||||
|
|
||||||
|
# change this to "classic" to use the traditional Material for MkDocs look.
|
||||||
|
#variant = "classic"
|
||||||
|
|
||||||
|
# Zensical allows you to override specific blocks, partials, or whole
|
||||||
|
# templates as well as to define your own templates. To do this, uncomment
|
||||||
|
# the custom_dir setting below and set it to a directory in which you
|
||||||
|
# keep your template overrides.
|
||||||
|
#
|
||||||
|
# Read more:
|
||||||
|
# - https://zensical.org/docs/customization/#extending-the-theme
|
||||||
|
#
|
||||||
|
custom_dir = "docs/overrides"
|
||||||
|
|
||||||
|
# With the "favicon" option you can set your own image to use as the icon
|
||||||
|
# browsers will use in the browser title bar or tab bar. The path provided
|
||||||
|
# must be relative to the "docs_dir".
|
||||||
|
#
|
||||||
|
# Read more:
|
||||||
|
# - https://zensical.org/docs/setup/logo-and-icons/#favicon
|
||||||
|
# - https://developer.mozilla.org/en-US/docs/Glossary/Favicon
|
||||||
|
#
|
||||||
|
#favicon = "images/favicon.png"
|
||||||
|
|
||||||
|
# Zensical supports more than 60 different languages. This means that the
|
||||||
|
# labels and tooltips that Zensical's templates produce are translated.
|
||||||
|
# The "language" option allows you to set the language used. This language
|
||||||
|
# is also indicated in the HTML head element to help with accessibility
|
||||||
|
# and guide search engines and translation tools.
|
||||||
|
#
|
||||||
|
# The default language is "en" (English). It is possible to create
|
||||||
|
# sites with multiple languages and configure a language selector. See
|
||||||
|
# the documentation for details.
|
||||||
|
#
|
||||||
|
# Read more:
|
||||||
|
# - https://zensical.org/docs/setup/language/
|
||||||
|
#
|
||||||
|
language = "en"
|
||||||
|
|
||||||
|
# Zensical provides a number of feature toggles that change the behavior
|
||||||
|
# of the documentation site.
|
||||||
|
features = [
|
||||||
|
# Zensical includes an announcement bar. This feature allows users to
|
||||||
|
# dismiss it when they have read the announcement.
|
||||||
|
# https://zensical.org/docs/setup/header/#announcement-bar
|
||||||
|
"announce.dismiss",
|
||||||
|
|
||||||
|
# If you have a repository configured and turn on this feature, Zensical
|
||||||
|
# will generate an edit button for the page. This works for common
|
||||||
|
# repository hosting services.
|
||||||
|
# https://zensical.org/docs/setup/repository/#content-actions
|
||||||
|
#"content.action.edit",
|
||||||
|
|
||||||
|
# If you have a repository configured and turn on this feature, Zensical
|
||||||
|
# will generate a button that allows the user to view the Markdown
|
||||||
|
# code for the current page.
|
||||||
|
# https://zensical.org/docs/setup/repository/#content-actions
|
||||||
|
"content.action.view",
|
||||||
|
|
||||||
|
# Code annotations allow you to add an icon with a tooltip to your
|
||||||
|
# code blocks to provide explanations at crucial points.
|
||||||
|
# https://zensical.org/docs/authoring/code-blocks/#code-annotations
|
||||||
|
"content.code.annotate",
|
||||||
|
|
||||||
|
# This feature turns on a button in code blocks that allow users to
|
||||||
|
# copy the content to their clipboard without first selecting it.
|
||||||
|
# https://zensical.org/docs/authoring/code-blocks/#code-copy-button
|
||||||
|
"content.code.copy",
|
||||||
|
|
||||||
|
# Code blocks can include a button to allow for the selection of line
|
||||||
|
# ranges by the user.
|
||||||
|
# https://zensical.org/docs/authoring/code-blocks/#code-selection-button
|
||||||
|
"content.code.select",
|
||||||
|
|
||||||
|
# Zensical can render footnotes as inline tooltips, so the user can read
|
||||||
|
# the footnote without leaving the context of the document.
|
||||||
|
# https://zensical.org/docs/authoring/footnotes/#footnote-tooltips
|
||||||
|
"content.footnote.tooltips",
|
||||||
|
|
||||||
|
# If you have many content tabs that have the same titles (e.g., "Python",
|
||||||
|
# "JavaScript", "Cobol"), this feature causes all of them to switch to
|
||||||
|
# at the same time when the user chooses their language in one.
|
||||||
|
# https://zensical.org/docs/authoring/content-tabs/#linked-content-tabs
|
||||||
|
"content.tabs.link",
|
||||||
|
|
||||||
|
# With this feature enabled users can add tooltips to links that will be
|
||||||
|
# displayed when the mouse pointer hovers the link.
|
||||||
|
# https://zensical.org/docs/authoring/tooltips/#improved-tooltips
|
||||||
|
"content.tooltips",
|
||||||
|
|
||||||
|
# With this feature enabled, Zensical will automatically hide parts
|
||||||
|
# of the header when the user scrolls past a certain point.
|
||||||
|
# https://zensical.org/docs/setup/header/#automatic-hiding
|
||||||
|
# "header.autohide",
|
||||||
|
|
||||||
|
# Turn on this feature to expand all collapsible sections in the
|
||||||
|
# navigation sidebar by default.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-expansion
|
||||||
|
# "navigation.expand",
|
||||||
|
|
||||||
|
# This feature turns on navigation elements in the footer that allow the
|
||||||
|
# user to navigate to a next or previous page.
|
||||||
|
# https://zensical.org/docs/setup/footer/#navigation
|
||||||
|
"navigation.footer",
|
||||||
|
|
||||||
|
# When section index pages are enabled, documents can be directly attached
|
||||||
|
# to sections, which is particularly useful for providing overview pages.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#section-index-pages
|
||||||
|
"navigation.indexes",
|
||||||
|
|
||||||
|
# When instant navigation is enabled, clicks on all internal links will be
|
||||||
|
# intercepted and dispatched via XHR without fully reloading the page.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#instant-navigation
|
||||||
|
"navigation.instant",
|
||||||
|
|
||||||
|
# With instant prefetching, your site will start to fetch a page once the
|
||||||
|
# user hovers over a link. This will reduce the perceived loading time
|
||||||
|
# for the user.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#instant-prefetching
|
||||||
|
"navigation.instant.prefetch",
|
||||||
|
|
||||||
|
# In order to provide a better user experience on slow connections when
|
||||||
|
# using instant navigation, a progress indicator can be enabled.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#progress-indicator
|
||||||
|
#"navigation.instant.progress",
|
||||||
|
|
||||||
|
# When navigation paths are activated, a breadcrumb navigation is rendered
|
||||||
|
# above the title of each page
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-path
|
||||||
|
"navigation.path",
|
||||||
|
|
||||||
|
# When pruning is enabled, only the visible navigation items are included
|
||||||
|
# in the rendered HTML, reducing the size of the built site by 33% or more.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-pruning
|
||||||
|
#"navigation.prune",
|
||||||
|
|
||||||
|
# When sections are enabled, top-level sections are rendered as groups in
|
||||||
|
# the sidebar for viewports above 1220px, but remain as-is on mobile.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-sections
|
||||||
|
"navigation.sections",
|
||||||
|
|
||||||
|
# When tabs are enabled, top-level sections are rendered in a menu layer
|
||||||
|
# below the header for viewports above 1220px, but remain as-is on mobile.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-tabs
|
||||||
|
"navigation.tabs",
|
||||||
|
|
||||||
|
# When sticky tabs are enabled, navigation tabs will lock below the header
|
||||||
|
# and always remain visible when scrolling down.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#sticky-navigation-tabs
|
||||||
|
#"navigation.tabs.sticky",
|
||||||
|
|
||||||
|
# A back-to-top button can be shown when the user, after scrolling down,
|
||||||
|
# starts to scroll up again.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#back-to-top-button
|
||||||
|
"navigation.top",
|
||||||
|
|
||||||
|
# When anchor tracking is enabled, the URL in the address bar is
|
||||||
|
# automatically updated with the active anchor as highlighted in the table
|
||||||
|
# of contents.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#anchor-tracking
|
||||||
|
"navigation.tracking",
|
||||||
|
|
||||||
|
# When search highlighting is enabled and a user clicks on a search result,
|
||||||
|
# Zensical will highlight all occurrences after following the link.
|
||||||
|
# https://zensical.org/docs/setup/search/#search-highlighting
|
||||||
|
"search.highlight",
|
||||||
|
|
||||||
|
# When anchor following for the table of contents is enabled, the sidebar
|
||||||
|
# is automatically scrolled so that the active anchor is always visible.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#anchor-following
|
||||||
|
# "toc.follow",
|
||||||
|
|
||||||
|
# When navigation integration for the table of contents is enabled, it is
|
||||||
|
# always rendered as part of the navigation sidebar on the left.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-integration
|
||||||
|
#"toc.integrate",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# In the "palette" subsection you can configure options for the color scheme.
|
||||||
|
# You can configure different color # schemes, e.g., to turn on dark mode,
|
||||||
|
# that the user can switch between. Each color scheme can be further
|
||||||
|
# customized.
|
||||||
|
#
|
||||||
|
# Read more:
|
||||||
|
# - https://zensical.org/docs/setup/colors/
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
[[project.theme.palette]]
|
||||||
|
scheme = "default"
|
||||||
|
toggle.icon = "lucide/sun"
|
||||||
|
toggle.name = "Switch to dark mode"
|
||||||
|
|
||||||
|
[[project.theme.palette]]
|
||||||
|
scheme = "slate"
|
||||||
|
toggle.icon = "lucide/moon"
|
||||||
|
toggle.name = "Switch to light mode"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# In the "font" subsection you can configure the fonts used. By default, fonts
|
||||||
|
# are loaded from Google Fonts, giving you a wide range of choices from a set
|
||||||
|
# of suitably licensed fonts. There are options for a normal text font and for
|
||||||
|
# a monospaced font used in code blocks.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
[project.theme.font]
|
||||||
|
text = "Inter"
|
||||||
|
code = "Jetbrains Mono"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# You can configure your own logo to be shown in the header using the "logo"
|
||||||
|
# option in the "icons" subsection. The logo can be a path to a file in your
|
||||||
|
# "docs_dir" or it can be a path to an icon.
|
||||||
|
#
|
||||||
|
# Likewise, you can customize the logo used for the repository section of the
|
||||||
|
# header. Zensical derives the default logo for this from the repository URL.
|
||||||
|
# See below...
|
||||||
|
#
|
||||||
|
# There are other icons you can customize. See the documentation for details.
|
||||||
|
#
|
||||||
|
# Read more:
|
||||||
|
# - https://zensical.org/docs/setup/logo-and-icons
|
||||||
|
# - https://zensical.org/docs/authoring/icons-emojis/#search
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
[project.theme.icon]
|
||||||
|
#logo = "lucide/smile"
|
||||||
|
repo = "fontawesome/brands/github"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# The "extra" section contains miscellaneous settings.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#[[project.extra.social]]
|
||||||
|
#icon = "fontawesome/brands/github"
|
||||||
|
#link = "https://github.com/user/repo"
|
||||||
|
|
||||||
|
|
||||||
|
[project.plugins.mkdocstrings.handlers.python]
|
||||||
|
inventories = ["https://docs.python.org/3/objects.inv"]
|
||||||
|
paths = ["src"]
|
||||||
|
|
||||||
|
[project.plugins.mkdocstrings.handlers.python.options]
|
||||||
|
docstring_style = "google"
|
||||||
|
inherited_members = true
|
||||||
|
show_source = false
|
||||||
|
show_root_heading = true
|
||||||
Reference in New Issue
Block a user