Compare commits

..
54 Commits
Author SHA1 Message Date
github-actions[bot] eca29a5bdb Deployed 88ecfa8 to v5.1 with Zensical 0.0.51 and mike 2.2.0+zensical-0.1.0 2026-07-28 12:24:25 +00:00
github-actions[bot] c6d31cdac9 Deployed c15aee2 to v5.0 with Zensical 0.0.50 and mike 2.2.0+zensical-0.1.0 2026-07-11 13:17:37 +00:00
github-actions[bot] c9285b0fb8 Deployed 06d49d5 to v5.0 with Zensical 0.0.43 and mike 2.2.0+zensical-0.1.0 2026-07-04 09:08:25 +00:00
github-actions[bot] 8c60711c56 Deployed 025f190 to v5.0 with Zensical 0.0.43 and mike 2.2.0+zensical-0.1.0 2026-06-25 19:18:13 +00:00
github-actions[bot] ad920321e8 Removed v4.0 with mike 2.2.0+zensical-0.1.0 2026-06-25 19:18:10 +00:00
github-actions[bot] 95c5fb7828 Deployed 2641881 to v4.1 with Zensical 0.0.43 and mike 2.2.0+zensical-0.1.0 2026-06-09 16:51:07 +00:00
github-actions[bot] e449df76c2 Deployed 43445e9 to v4.1 with Zensical 0.0.43 and mike 2.2.0+zensical-0.1.0 2026-06-08 16:44:06 +00:00
github-actions[bot] 0c48b83fdd Deployed b0c35bf to v4.1 with Zensical 0.0.43 and mike 2.2.0+zensical-0.1.0 2026-06-03 20:06:25 +00:00
github-actions[bot] 9a13ebdd22 Deployed f80dae2 to v4.1 with Zensical 0.0.41 and mike 2.2.0+zensical-0.1.0 2026-05-21 16:33:08 +00:00
github-actions[bot] 1042121c1b Deployed 189e9e1 to v4.0 with Zensical 0.0.40 and mike 2.2.0+zensical-0.1.0 2026-05-08 11:18:22 +00:00
github-actions[bot] c541fb4f85 Removed v3.0 with mike 2.2.0+zensical-0.1.0 2026-05-08 11:18:19 +00:00
github-actions[bot] 84602c0fcf Deployed 2115036 to v3.1 with Zensical 0.0.33 and mike 2.2.0+zensical-0.1.0 2026-04-16 19:40:33 +00:00
github-actions[bot] 8f42f8da40 Deployed 94e7d79 to v3.1 with Zensical 0.0.32 and mike 2.2.0+zensical-0.1.0 2026-04-12 16:54:43 +00:00
github-actions[bot] b8da326810 Deployed 6b127d9 to v3.0 with Zensical 0.0.31 and mike 2.2.0+zensical-0.1.0 2026-04-04 23:15:54 +00:00
d3vyce 07289c015a Deployed 9b74f16 to v2.4 with Zensical 0.0.31 and mike 2.2.0+zensical-0.1.0 2026-04-03 04:53:26 -05:00
d3vyce c09eb4a4ac Deployed 9b74f16 to v1.3 with Zensical 0.0.31 and mike 2.2.0+zensical-0.1.0 2026-04-03 04:53:16 -05:00
d3vyce 01ca5496e0 Removed v2.4 with mike 2.2.0+zensical-0.1.0 2026-04-03 04:53:00 -05:00
d3vyce c59a43dea6 Removed v1.3 with mike 2.2.0+zensical-0.1.0 2026-04-03 04:52:59 -05:00
d3vyce 012d89dc09 Deployed a647da4 to v3.0 with Zensical 0.0.31 and mike 2.2.0+zensical-0.1.0 2026-04-03 04:37:39 -05:00
d3vyce fc9a1279ac Deployed a647da4 to v2.4 with Zensical 0.0.31 and mike 2.2.0+zensical-0.1.0 2026-04-03 04:37:32 -05:00
d3vyce e4edd7c612 Deployed a647da4 to v1.3 with Zensical 0.0.31 and mike 2.2.0+zensical-0.1.0 2026-04-03 04:37:25 -05:00
d3vyce 3b783e55bb Removed v3.0 with mike 2.2.0+zensical-0.1.0 2026-04-03 04:37:16 -05:00
d3vyce 62f7d5fd39 Removed v2 with mike 2.2.0+zensical-0.1.0 2026-04-03 04:37:16 -05:00
d3vyce 5837eecb8e Removed v1 with mike 2.2.0+zensical-0.1.0 2026-04-03 04:37:15 -05:00
github-actions[bot] 5f52925eaf Deployed bbe63ed to v3.0 with Zensical 0.0.31 and mike 2.2.0+zensical-0.1.0 2026-04-02 09:39:23 +00:00
github-actions[bot] ec2593d738 Removed v2.1 with mike 2.2.0+zensical-0.1.0 2026-04-02 09:39:20 +00:00
github-actions[bot] 9466862ea3 Removed v2.2 with mike 2.2.0+zensical-0.1.0 2026-04-02 09:39:19 +00:00
github-actions[bot] f1ac88e21f Removed v2.3 with mike 2.2.0+zensical-0.1.0 2026-04-02 09:39:19 +00:00
github-actions[bot] 41a3b63554 Removed v2.4 with mike 2.2.0+zensical-0.1.0 2026-04-02 09:39:18 +00:00
github-actions[bot] 91659b8237 Deployed bbe63ed to v2 with Zensical 0.0.31 and mike 2.2.0+zensical-0.1.0 2026-04-02 09:39:18 +00:00
d3vyceandGitHub 1d8415de08 Create CNAME 2026-03-29 13:59:20 +02:00
github-actions[bot] de46f8bd2d Removed 2.2 with mike 2.2.0+zensical-0.1.0 2026-03-29 06:48:17 -05:00
github-actions[bot] 23109102ca Removed 2.1 with mike 2.2.0+zensical-0.1.0 2026-03-29 06:48:16 -05:00
github-actions[bot] b9b7c0d5cc Removed 2.0 with mike 2.2.0+zensical-0.1.0 2026-03-29 06:48:13 -05:00
github-actions[bot] beb35d4d3c Removed 1.3 with mike 2.2.0+zensical-0.1.0 2026-03-29 06:48:10 -05:00
github-actions[bot] 99c132508d Removed 1.2 with mike 2.2.0+zensical-0.1.0 2026-03-29 06:48:07 -05:00
github-actions[bot] b1056369b1 Removed 1.1 with mike 2.2.0+zensical-0.1.0 2026-03-29 06:48:06 -05:00
github-actions[bot] 9051aade4b Removed 1.0 with mike 2.2.0+zensical-0.1.0 2026-03-29 06:48:03 -05:00
github-actions[bot] 659918bf5e Set default version to stable with mike 2.2.0+zensical-0.1.0 2026-03-29 06:47:34 -05:00
github-actions[bot] 1d22c64697 Deployed f5afbbe to v2.4 with Zensical 0.0.30 and mike 2.2.0+zensical-0.1.0 2026-03-29 06:47:33 -05:00
github-actions[bot] 793ba6f45f Deployed f5afbbe to v2.3 with Zensical 0.0.30 and mike 2.2.0+zensical-0.1.0 2026-03-29 06:47:31 -05:00
github-actions[bot] f61659e39b Deployed f5afbbe to v2.2 with Zensical 0.0.30 and mike 2.2.0+zensical-0.1.0 2026-03-29 06:47:28 -05:00
github-actions[bot] a1043448ed Deployed f5afbbe to v2.1 with Zensical 0.0.30 and mike 2.2.0+zensical-0.1.0 2026-03-29 06:47:25 -05:00
github-actions[bot] 57ccbe6523 Deployed f5afbbe to v2 with Zensical 0.0.30 and mike 2.2.0+zensical-0.1.0 2026-03-29 06:47:23 -05:00
github-actions[bot] baf58c6b3a Deployed f5afbbe to v1 with Zensical 0.0.30 and mike 2.2.0+zensical-0.1.0 2026-03-29 06:47:20 -05:00
d3vyce 9462d4406e Set default version to latest with mike 2.1.4 2026-03-11 13:58:30 -05:00
d3vyce aa1734e054 Deployed 0cc21d2 to 2.2 with MkDocs 1.0.0 and mike 2.1.4 2026-03-11 13:58:28 -05:00
d3vyce e386f5d0c9 Deployed 0cc21d2 to 2.1 with MkDocs 1.0.0 and mike 2.1.4 2026-03-11 13:58:24 -05:00
d3vyce 2223d42d15 Deployed dde5183 to 2.2 with MkDocs 1.0.0 and mike 2.1.4 2026-03-11 13:56:55 -05:00
d3vyce 9279947778 Deployed c4c7604 to 2.0 with MkDocs 1.0.0 and mike 2.1.4 2026-03-11 13:56:50 -05:00
d3vyce d863ddbbe2 Deployed 56d365d to 1.3 with MkDocs 1.0.0 and mike 2.1.4 2026-03-11 13:56:46 -05:00
d3vyce e473812460 Deployed 117675d to 1.2 with MkDocs 1.0.0 and mike 2.1.4 2026-03-11 13:56:41 -05:00
d3vyce b87be8aeea Deployed 7ec4078 to 1.1 with MkDocs 1.0.0 and mike 2.1.4 2026-03-11 13:56:37 -05:00
d3vyce d4e83fdc79 Deployed 3167893 to 1.0 with MkDocs 1.0.0 and mike 2.1.4 2026-03-11 13:56:32 -05:00
288 changed files with 402389 additions and 8177 deletions
-14
View File
@@ -1,14 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix:
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix:
-29
View File
@@ -1,29 +0,0 @@
name: Build Package
on:
release:
types: [published]
jobs:
build-package:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Set up Python
run: uv python install 3.14
- name: Install dependencies
run: uv sync
- name: Build
run: uv build
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
-108
View File
@@ -1,108 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint (Ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Set up Python
run: uv python install 3.13
- name: Install dependencies
run: uv sync --extra dev
- name: Run Ruff linter
run: uv run ruff check .
- name: Run Ruff formatter check
run: uv run ruff format --check .
typecheck:
name: Type Check (ty)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Set up Python
run: uv python install 3.13
- name: Install dependencies
run: uv sync --extra dev
- name: Run ty
run: uv run ty check
test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --extra dev
- name: Run tests with coverage
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/test_db
run: |
uv run pytest --cov --cov-report=xml --cov-report=term-missing --junitxml=junit.xml -o junit_family=legacy
- name: Upload coverage to Codecov
if: matrix.python-version == '3.14'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: coverage
files: ./coverage.xml
fail_ci_if_error: false
- name: Upload test results to Codecov
if: matrix.python-version == '3.14'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
-207
View File
@@ -1,207 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
-1
View File
@@ -1 +0,0 @@
3.14
+1
View File
@@ -0,0 +1 @@
fastapi-toolsets.d3vyce.fr
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 d3vyce
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-41
View File
@@ -1,41 +0,0 @@
# FastAPI Toolsets
FastAPI Toolsets provides production-ready utilities for FastAPI applications built with async SQLAlchemy and PostgreSQL. It includes generic CRUD operations, a fixture system with dependency resolution, a Django-like CLI, standardized API responses, and structured exception handling with automatic OpenAPI documentation.
[![CI](https://github.com/d3vyce/fastapi-toolsets/actions/workflows/ci.yml/badge.svg)](https://github.com/d3vyce/fastapi-toolsets/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/d3vyce/fastapi-toolsets/graph/badge.svg)](https://codecov.io/gh/d3vyce/fastapi-toolsets)
[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
```bash
uv add fastapi-toolsets
```
## Features
- **CRUD**: Generic async CRUD operations with `CrudFactory`
- **Fixtures**: Fixture system with dependency management, context support and pytest integration
- **CLI**: Django-like command-line interface with fixture management and custom commands support
- **Standardized API Responses**: Consistent response format across your API
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
## License
MIT License - see [LICENSE](LICENSE) for details.
## Contributing
Contributions are welcome! Please feel free to submit issues and pull requests.
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Redirecting</title>
<noscript>
<meta http-equiv="refresh" content="1; url=stable/" />
</noscript>
<script>
window.location.replace(
"stable/" + window.location.search + window.location.hash
);
</script>
</head>
<body>
Redirecting to <a href="stable/">stable/</a>...
</body>
</html>
-83
View File
@@ -1,83 +0,0 @@
[project]
name = "fastapi-toolsets"
version = "0.7.0"
description = "Reusable tools for FastAPI: async CRUD, fixtures, CLI, and standardized responses for SQLAlchemy + PostgreSQL"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.11"
authors = [
{ name = "d3vyce", email = "contact@d3vyce.fr" }
]
keywords = ["fastapi", "sqlalchemy", "postgresql"]
classifiers = [
"Development Status :: 4 - Beta",
"Framework :: AsyncIO",
"Framework :: FastAPI",
"Framework :: Pydantic",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development",
"Typing :: Typed",
]
dependencies = [
"fastapi>=0.100.0",
"sqlalchemy[asyncio]>=2.0",
"asyncpg>=0.29.0",
"pydantic>=2.0",
"typer>=0.9.0",
"httpx>=0.25.0",
]
[project.urls]
Homepage = "https://github.com/d3vyce/fastapi-toolsets"
Documentation = "https://fastapi-toolsets.d3vyce.fr/"
Repository = "https://github.com/d3vyce/fastapi-toolsets"
Issues = "https://github.com/d3vyce/fastapi-toolsets/issues"
[project.optional-dependencies]
test = [
"pytest>=8.0.0",
"pytest-anyio>=0.0.0",
"coverage>=7.0.0",
"pytest-cov>=4.0.0",
]
dev = [
"fastapi-toolsets[test]",
"ruff>=0.1.0",
"ty>=0.0.1a0",
]
[project.scripts]
manager = "fastapi_toolsets.cli.app:cli"
[build-system]
requires = ["uv_build>=0.10,<0.11.0"]
build-backend = "uv_build"
[tool.pytest.ini_options]
testpaths = ["tests"]
filterwarnings = [
"ignore::DeprecationWarning",
]
[tool.coverage.run]
source = ["src/fastapi_toolsets"]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
-24
View File
@@ -1,24 +0,0 @@
"""FastAPI utilities package.
Provides CRUD operations, fixtures, CLI, and standardized API responses
for FastAPI with async SQLAlchemy and PostgreSQL.
Example usage:
from fastapi import FastAPI, Depends
from fastapi_toolsets.exceptions import init_exceptions_handlers
from fastapi_toolsets.crud import CrudFactory
from fastapi_toolsets.db import create_db_dependency
from fastapi_toolsets.schemas import Response
app = FastAPI()
init_exceptions_handlers(app)
UserCrud = CrudFactory(User)
@app.get("/users/{user_id}", response_model=Response[dict])
async def get_user(user_id: int, session = Depends(get_db)):
user = await UserCrud.get(session, [User.id == user_id])
return Response(data={"user": user.username}, message="Success")
"""
__version__ = "0.7.0"
-5
View File
@@ -1,5 +0,0 @@
"""CLI for FastAPI projects."""
from .utils import async_command
__all__ = ["async_command"]
-32
View File
@@ -1,32 +0,0 @@
"""Main CLI application."""
import typer
from ..logger import configure_logging
from .config import get_custom_cli
from .pyproject import load_pyproject
# Use custom CLI if configured, otherwise create default one
_custom_cli = get_custom_cli()
if _custom_cli is not None:
cli = _custom_cli
else:
cli = typer.Typer(
name="manager",
help="CLI utilities for FastAPI projects.",
no_args_is_help=True,
)
_config = load_pyproject()
if _config.get("fixtures") and _config.get("db_context"):
from .commands.fixtures import fixture_cli
cli.add_typer(fixture_cli, name="fixtures")
@cli.callback()
def main(ctx: typer.Context) -> None:
"""FastAPI utilities CLI."""
configure_logging()
ctx.ensure_object(dict)
@@ -1 +0,0 @@
"""Built-in CLI commands."""
@@ -1,100 +0,0 @@
"""Fixture management commands."""
from typing import Annotated
import typer
from rich.console import Console
from rich.table import Table
from ...fixtures import Context, LoadStrategy, load_fixtures_by_context
from ..config import get_db_context, get_fixtures_registry
from ..utils import async_command
fixture_cli = typer.Typer(
name="fixtures",
help="Manage database fixtures.",
no_args_is_help=True,
)
console = Console()
@fixture_cli.command("list")
def list_fixtures(
ctx: typer.Context,
context: Annotated[
Context | None,
typer.Option(
"--context",
"-c",
help="Filter by context.",
),
] = None,
) -> None:
"""List all registered fixtures."""
registry = get_fixtures_registry()
fixtures = registry.get_by_context(context.value) if context else registry.get_all()
if not fixtures:
print("No fixtures found.")
return
table = Table("Name", "Contexts", "Dependencies")
for fixture in fixtures:
contexts = ", ".join(fixture.contexts)
deps = ", ".join(fixture.depends_on) if fixture.depends_on else "-"
table.add_row(fixture.name, contexts, deps)
console.print(table)
print(f"\nTotal: {len(fixtures)} fixture(s)")
@fixture_cli.command("load")
@async_command
async def load(
ctx: typer.Context,
contexts: Annotated[
list[Context] | None,
typer.Argument(help="Contexts to load."),
] = None,
strategy: Annotated[
LoadStrategy,
typer.Option("--strategy", "-s", help="Load strategy."),
] = LoadStrategy.MERGE,
dry_run: Annotated[
bool,
typer.Option(
"--dry-run", "-n", help="Show what would be loaded without loading."
),
] = False,
) -> None:
"""Load fixtures into the database."""
registry = get_fixtures_registry()
db_context = get_db_context()
context_list = [c.value for c in contexts] if contexts else [Context.BASE]
ordered = registry.resolve_context_dependencies(*context_list)
if not ordered:
print("No fixtures to load for the specified context(s).")
return
print(f"\nFixtures to load ({strategy.value} strategy):")
for name in ordered:
fixture = registry.get(name)
instances = list(fixture.func())
model_name = type(instances[0]).__name__ if instances else "?"
print(f" - {name}: {len(instances)} {model_name}(s)")
if dry_run:
print("\n[Dry run - no changes made]")
return
async with db_context() as session:
result = await load_fixtures_by_context(
session, registry, *context_list, strategy=strategy
)
total = sum(len(items) for items in result.values())
print(f"\nLoaded {total} record(s) successfully.")
-107
View File
@@ -1,107 +0,0 @@
"""CLI configuration and dynamic imports."""
import importlib
import sys
import typer
from .pyproject import find_pyproject, load_pyproject
def _ensure_project_in_path():
"""Add project root to sys.path if not installed in editable mode."""
pyproject = find_pyproject()
if pyproject:
project_root = str(pyproject.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
def import_from_string(import_path: str):
"""Import an object from a string path like 'module.submodule:attribute'.
Raises:
typer.BadParameter: If the import path is invalid or import fails.
"""
if ":" not in import_path:
raise typer.BadParameter(
f"Invalid import path '{import_path}'. Expected format: 'module:attribute'"
)
module_path, attr_name = import_path.rsplit(":", 1)
_ensure_project_in_path()
try:
module = importlib.import_module(module_path)
except ImportError as e:
raise typer.BadParameter(f"Cannot import module '{module_path}': {e}")
if not hasattr(module, attr_name):
raise typer.BadParameter(
f"Module '{module_path}' has no attribute '{attr_name}'"
)
return getattr(module, attr_name)
def get_config_value(key: str, required: bool = False):
"""Get a configuration value from pyproject.toml.
Args:
key: The configuration key in [tool.fastapi-toolsets].
required: If True, raises an error when the key is missing.
Returns:
The configuration value, or None if not found and not required.
Raises:
typer.BadParameter: If required=True and the key is missing.
"""
config = load_pyproject()
value = config.get(key)
if required and value is None:
raise typer.BadParameter(
f"No '{key}' configured. "
f"Add '{key}' to [tool.fastapi-toolsets] in pyproject.toml."
)
return value
def get_fixtures_registry():
"""Import and return the fixtures registry from config."""
from ..fixtures import FixtureRegistry
import_path = get_config_value("fixtures", required=True)
registry = import_from_string(import_path)
if not isinstance(registry, FixtureRegistry):
raise typer.BadParameter(
f"'fixtures' must be a FixtureRegistry instance, got {type(registry).__name__}"
)
return registry
def get_db_context():
"""Import and return the db_context function from config."""
import_path = get_config_value("db_context", required=True)
return import_from_string(import_path)
def get_custom_cli() -> typer.Typer | None:
"""Import and return the custom CLI Typer instance from config."""
import_path = get_config_value("custom_cli")
if not import_path:
return None
custom = import_from_string(import_path)
if not isinstance(custom, typer.Typer):
raise typer.BadParameter(
f"'custom_cli' must be a Typer instance, got {type(custom).__name__}"
)
return custom
-43
View File
@@ -1,43 +0,0 @@
"""Pyproject.toml discovery and loading."""
import tomllib
from pathlib import Path
TOOL_NAME = "fastapi-toolsets"
def find_pyproject(start_path: Path | None = None) -> Path | None:
"""Find pyproject.toml by walking up the directory tree.
Similar to how pytest, black, and ruff discover their config files.
"""
path = (start_path or Path.cwd()).resolve()
for directory in [path, *path.parents]:
pyproject = directory / "pyproject.toml"
if pyproject.is_file():
return pyproject
return None
def load_pyproject(path: Path | None = None) -> dict:
"""Load tool configuration from pyproject.toml.
Args:
path: Explicit path to pyproject.toml. If None, searches up from cwd.
Returns:
The [tool.fastapi-toolsets] section as a dict, or empty dict if not found.
"""
pyproject_path = path or find_pyproject()
if not pyproject_path:
return {}
try:
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
return data.get("tool", {}).get(TOOL_NAME, {})
except (OSError, tomllib.TOMLDecodeError):
return {}
-27
View File
@@ -1,27 +0,0 @@
"""CLI utility functions."""
import asyncio
import functools
from collections.abc import Callable, Coroutine
from typing import Any, ParamSpec, TypeVar
P = ParamSpec("P")
T = TypeVar("T")
def async_command(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
"""Decorator to run an async function as a sync CLI command.
Example:
@fixture_cli.command("load")
@async_command
async def load(ctx: typer.Context) -> None:
async with get_db_context() as session:
await load_fixtures(session, registry)
"""
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
return asyncio.run(func(*args, **kwargs))
return wrapper
-15
View File
@@ -1,15 +0,0 @@
"""Generic async CRUD operations for SQLAlchemy models."""
from ..exceptions import NoSearchableFieldsError
from .factory import CrudFactory
from .search import (
SearchConfig,
get_searchable_fields,
)
__all__ = [
"CrudFactory",
"get_searchable_fields",
"NoSearchableFieldsError",
"SearchConfig",
]
-631
View File
@@ -1,631 +0,0 @@
"""Generic async CRUD operations for SQLAlchemy models."""
from collections.abc import Sequence
from typing import Any, ClassVar, Generic, Literal, Self, TypeVar, cast, overload
from pydantic import BaseModel
from sqlalchemy import and_, func, select
from sqlalchemy import delete as sql_delete
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import NoResultFound
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.sql.roles import WhereHavingRole
from ..db import get_transaction
from ..exceptions import NotFoundError
from ..schemas import PaginatedResponse, Pagination, Response
from .search import SearchConfig, SearchFieldType, build_search_filters
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
JoinType = list[tuple[type[DeclarativeBase], Any]]
class AsyncCrud(Generic[ModelType]):
"""Generic async CRUD operations for SQLAlchemy models.
Subclass this and set the `model` class variable, or use `CrudFactory`.
"""
model: ClassVar[type[DeclarativeBase]]
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
@overload
@classmethod
async def create( # pragma: no cover
cls: type[Self],
session: AsyncSession,
obj: BaseModel,
*,
as_response: Literal[True],
) -> Response[ModelType]: ...
@overload
@classmethod
async def create( # pragma: no cover
cls: type[Self],
session: AsyncSession,
obj: BaseModel,
*,
as_response: Literal[False] = ...,
) -> ModelType: ...
@classmethod
async def create(
cls: type[Self],
session: AsyncSession,
obj: BaseModel,
*,
as_response: bool = False,
) -> ModelType | Response[ModelType]:
"""Create a new record in the database.
Args:
session: DB async session
obj: Pydantic model with data to create
as_response: If True, wrap result in Response object
Returns:
Created model instance or Response wrapping it
"""
async with get_transaction(session):
db_model = cls.model(**obj.model_dump())
session.add(db_model)
await session.refresh(db_model)
result = cast(ModelType, db_model)
if as_response:
return Response(data=result)
return result
@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[Any] | None = None,
as_response: Literal[True],
) -> Response[ModelType]: ...
@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[Any] | None = None,
as_response: Literal[False] = ...,
) -> ModelType: ...
@classmethod
async def get(
cls: type[Self],
session: AsyncSession,
filters: list[Any],
*,
joins: JoinType | None = None,
outer_join: bool = False,
with_for_update: bool = False,
load_options: list[Any] | None = None,
as_response: bool = False,
) -> ModelType | Response[ModelType]:
"""Get exactly one record. Raises NotFoundError if not found.
Args:
session: DB async session
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
with_for_update: Lock the row for update
load_options: SQLAlchemy loader options (e.g., selectinload)
as_response: If True, wrap result in Response object
Returns:
Model instance or Response wrapping it
Raises:
NotFoundError: If no record found
MultipleResultsFound: If more than one record found
"""
q = select(cls.model)
if joins:
for model, condition in joins:
q = (
q.outerjoin(model, condition)
if outer_join
else q.join(model, condition)
)
q = q.where(and_(*filters))
if load_options:
q = q.options(*load_options)
if with_for_update:
q = q.with_for_update()
result = await session.execute(q)
item = result.unique().scalar_one_or_none()
if not item:
raise NotFoundError()
result = cast(ModelType, item)
if as_response:
return Response(data=result)
return result
@classmethod
async def first(
cls: type[Self],
session: AsyncSession,
filters: list[Any] | None = None,
*,
joins: JoinType | None = None,
outer_join: bool = False,
load_options: list[Any] | None = None,
) -> ModelType | None:
"""Get the first matching record, or None.
Args:
session: DB async session
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
Returns:
Model instance or None
"""
q = select(cls.model)
if joins:
for model, condition in joins:
q = (
q.outerjoin(model, condition)
if outer_join
else q.join(model, condition)
)
if filters:
q = q.where(and_(*filters))
if load_options:
q = q.options(*load_options)
result = await session.execute(q)
return cast(ModelType | None, result.unique().scalars().first())
@classmethod
async def get_multi(
cls: type[Self],
session: AsyncSession,
*,
filters: list[Any] | None = None,
joins: JoinType | None = None,
outer_join: bool = False,
load_options: list[Any] | None = None,
order_by: Any | None = None,
limit: int | None = None,
offset: int | None = None,
) -> Sequence[ModelType]:
"""Get multiple records from the database.
Args:
session: DB async session
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
order_by: Column or list of columns to order by
limit: Max number of rows to return
offset: Rows to skip
Returns:
List of model instances
"""
q = select(cls.model)
if joins:
for model, condition in joins:
q = (
q.outerjoin(model, condition)
if outer_join
else q.join(model, condition)
)
if filters:
q = q.where(and_(*filters))
if load_options:
q = q.options(*load_options)
if order_by is not None:
q = q.order_by(order_by)
if offset is not None:
q = q.offset(offset)
if limit is not None:
q = q.limit(limit)
result = await session.execute(q)
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,
as_response: Literal[True],
) -> Response[ModelType]: ...
@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,
as_response: Literal[False] = ...,
) -> ModelType: ...
@classmethod
async def update(
cls: type[Self],
session: AsyncSession,
obj: BaseModel,
filters: list[Any],
*,
exclude_unset: bool = True,
exclude_none: bool = False,
as_response: bool = False,
) -> ModelType | Response[ModelType]:
"""Update a record in the database.
Args:
session: DB async session
obj: Pydantic model with update data
filters: List of SQLAlchemy filter conditions
exclude_unset: Exclude fields not explicitly set in the schema
exclude_none: Exclude fields with None value
as_response: If True, wrap result in Response object
Returns:
Updated model instance or Response wrapping it
Raises:
NotFoundError: If no record found
"""
async with get_transaction(session):
db_model = await cls.get(session=session, filters=filters)
values = obj.model_dump(
exclude_unset=exclude_unset, exclude_none=exclude_none
)
for key, value in values.items():
setattr(db_model, key, value)
await session.refresh(db_model)
if as_response:
return Response(data=db_model)
return db_model
@classmethod
async def upsert(
cls: type[Self],
session: AsyncSession,
obj: BaseModel,
index_elements: list[str],
*,
set_: BaseModel | None = None,
where: WhereHavingRole | None = None,
) -> ModelType | None:
"""Create or update a record (PostgreSQL only).
Uses INSERT ... ON CONFLICT for atomic upsert.
Args:
session: DB async session
obj: Pydantic model with data
index_elements: Columns for ON CONFLICT (unique constraint)
set_: Pydantic model for ON CONFLICT DO UPDATE SET
where: WHERE clause for ON CONFLICT DO UPDATE
Returns:
Model instance
"""
async with get_transaction(session):
values = obj.model_dump(exclude_unset=True)
q = insert(cls.model).values(**values)
if set_:
q = q.on_conflict_do_update(
index_elements=index_elements,
set_=set_.model_dump(exclude_unset=True),
where=where,
)
else:
q = q.on_conflict_do_nothing(index_elements=index_elements)
q = q.returning(cls.model)
result = await session.execute(q)
try:
db_model = result.unique().scalar_one()
except NoResultFound:
db_model = await cls.first(
session=session,
filters=[getattr(cls.model, k) == v for k, v in values.items()],
)
return cast(ModelType | None, db_model)
@overload
@classmethod
async def delete( # pragma: no cover
cls: type[Self],
session: AsyncSession,
filters: list[Any],
*,
as_response: Literal[True],
) -> Response[None]: ...
@overload
@classmethod
async def delete( # pragma: no cover
cls: type[Self],
session: AsyncSession,
filters: list[Any],
*,
as_response: Literal[False] = ...,
) -> bool: ...
@classmethod
async def delete(
cls: type[Self],
session: AsyncSession,
filters: list[Any],
*,
as_response: bool = False,
) -> bool | Response[None]:
"""Delete records from the database.
Args:
session: DB async session
filters: List of SQLAlchemy filter conditions
as_response: If True, wrap result in Response object
Returns:
True if deletion was executed, or Response wrapping it
"""
async with get_transaction(session):
q = sql_delete(cls.model).where(and_(*filters))
await session.execute(q)
if as_response:
return Response(data=None)
return True
@classmethod
async def count(
cls: type[Self],
session: AsyncSession,
filters: list[Any] | None = None,
*,
joins: JoinType | None = None,
outer_join: bool = False,
) -> int:
"""Count records matching the filters.
Args:
session: DB async session
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
Returns:
Number of matching records
"""
q = select(func.count()).select_from(cls.model)
if joins:
for model, condition in joins:
q = (
q.outerjoin(model, condition)
if outer_join
else q.join(model, condition)
)
if filters:
q = q.where(and_(*filters))
result = await session.execute(q)
return result.scalar_one()
@classmethod
async def exists(
cls: type[Self],
session: AsyncSession,
filters: list[Any],
*,
joins: JoinType | None = None,
outer_join: bool = False,
) -> bool:
"""Check if a record exists.
Args:
session: DB async session
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
Returns:
True if at least one record matches
"""
q = select(cls.model)
if joins:
for model, condition in joins:
q = (
q.outerjoin(model, condition)
if outer_join
else q.join(model, condition)
)
q = q.where(and_(*filters)).exists().select()
result = await session.execute(q)
return bool(result.scalar())
@classmethod
async def paginate(
cls: type[Self],
session: AsyncSession,
*,
filters: list[Any] | None = None,
joins: JoinType | None = None,
outer_join: bool = False,
load_options: list[Any] | 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,
) -> PaginatedResponse[ModelType]:
"""Get paginated results with metadata.
Args:
session: DB async session
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
order_by: Column or list of columns to order by
page: Page number (1-indexed)
items_per_page: Number of items per page
search: Search query string or SearchConfig object
search_fields: Fields to search in (overrides class default)
Returns:
Dict with 'data' and 'pagination' keys
"""
filters = list(filters) if filters else []
offset = (page - 1) * items_per_page
search_joins: list[Any] = []
# 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 with joins
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 search)
for join_rel in search_joins:
q = q.outerjoin(join_rel)
if filters:
q = q.where(and_(*filters))
if load_options:
q = q.options(*load_options)
if order_by is not None:
q = q.order_by(order_by)
q = q.offset(offset).limit(items_per_page)
result = await session.execute(q)
items = cast(list[ModelType], result.unique().scalars().all())
# Count query (with same joins and filters)
pk_col = cls.model.__mapper__.primary_key[0]
count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
count_q = count_q.select_from(cls.model)
# Apply explicit joins to count query
if joins:
for model, condition in joins:
count_q = (
count_q.outerjoin(model, condition)
if outer_join
else count_q.join(model, condition)
)
# Apply search joins to count query
for join_rel in search_joins:
count_q = count_q.outerjoin(join_rel)
if filters:
count_q = count_q.where(and_(*filters))
count_result = await session.execute(count_q)
total_count = count_result.scalar_one()
return PaginatedResponse(
data=items,
pagination=Pagination(
total_count=total_count,
items_per_page=items_per_page,
page=page,
has_more=page * items_per_page < total_count,
),
)
def CrudFactory(
model: type[ModelType],
*,
searchable_fields: Sequence[SearchFieldType] | None = None,
) -> type[AsyncCrud[ModelType]]:
"""Create a CRUD class for a specific model.
Args:
model: SQLAlchemy model class
searchable_fields: Optional list of searchable fields
Returns:
AsyncCrud subclass bound to the model
Example:
from fastapi_toolsets.crud import CrudFactory
from myapp.models import User, Post
UserCrud = CrudFactory(User)
PostCrud = CrudFactory(Post)
# With searchable fields:
UserCrud = CrudFactory(
User,
searchable_fields=[User.username, User.email, (User.role, Role.name)]
)
# Usage
user = await UserCrud.get(session, [User.id == 1])
posts = await PostCrud.get_multi(session, filters=[Post.user_id == user.id])
# With search
result = await UserCrud.paginate(session, search="john")
# With joins (inner join by default):
users = await UserCrud.get_multi(
session,
joins=[(Post, Post.user_id == User.id)],
filters=[Post.published == True],
)
# With outer join:
users = await UserCrud.get_multi(
session,
joins=[(Post, Post.user_id == User.id)],
outer_join=True,
)
"""
cls = type(
f"Async{model.__name__}Crud",
(AsyncCrud,),
{
"model": model,
"searchable_fields": searchable_fields,
},
)
return cast(type[AsyncCrud[ModelType]], cls)
-146
View File
@@ -1,146 +0,0 @@
"""Search utilities for AsyncCrud."""
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
from sqlalchemy import String, or_
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.attributes import InstrumentedAttribute
from ..exceptions import NoSearchableFieldsError
if TYPE_CHECKING:
from sqlalchemy.sql.elements import ColumnElement
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
@dataclass
class SearchConfig:
"""Advanced search configuration.
Attributes:
query: The search string
fields: Fields to search (columns or tuples for relationships)
case_sensitive: Case-sensitive search (default: False)
match_mode: "any" (OR) or "all" (AND) to combine fields
"""
query: str
fields: Sequence[SearchFieldType] | None = None
case_sensitive: bool = False
match_mode: Literal["any", "all"] = "any"
def get_searchable_fields(
model: type[DeclarativeBase],
*,
include_relationships: bool = True,
max_depth: int = 1,
) -> list[SearchFieldType]:
"""Auto-detect String fields on a model and its relationships.
Args:
model: SQLAlchemy model class
include_relationships: Include fields from many-to-one/one-to-one relationships
max_depth: Max depth for relationship traversal (default: 1)
Returns:
List of columns and tuples (relationship, column)
"""
fields: list[SearchFieldType] = []
mapper = model.__mapper__
# Direct String columns
for col in mapper.columns:
if isinstance(col.type, String):
fields.append(getattr(model, col.key))
# Relationships (one-to-one, many-to-one only)
if include_relationships and max_depth > 0:
for rel_name, rel_prop in mapper.relationships.items():
if rel_prop.uselist: # Skip collections (one-to-many, many-to-many)
continue
rel_attr = getattr(model, rel_name)
related_model = rel_prop.mapper.class_
for col in related_model.__mapper__.columns:
if isinstance(col.type, String):
fields.append((rel_attr, getattr(related_model, col.key)))
return fields
def build_search_filters(
model: type[DeclarativeBase],
search: str | SearchConfig,
search_fields: Sequence[SearchFieldType] | None = None,
default_fields: Sequence[SearchFieldType] | None = None,
) -> tuple[list["ColumnElement[bool]"], list[InstrumentedAttribute[Any]]]:
"""Build SQLAlchemy filter conditions for search.
Args:
model: SQLAlchemy model class
search: Search string or SearchConfig
search_fields: Fields specified per-call (takes priority)
default_fields: Default fields (from ClassVar)
Returns:
Tuple of (filter_conditions, joins_needed)
"""
# Normalize input
if isinstance(search, str):
config = SearchConfig(query=search, fields=search_fields)
else:
config = search
if search_fields is not None:
config = SearchConfig(
query=config.query,
fields=search_fields,
case_sensitive=config.case_sensitive,
match_mode=config.match_mode,
)
if not config.query or not config.query.strip():
return [], []
# Determine which fields to search
fields = config.fields or default_fields or get_searchable_fields(model)
if not fields:
raise NoSearchableFieldsError(model)
query = config.query.strip()
filters: list[ColumnElement[bool]] = []
joins: list[InstrumentedAttribute[Any]] = []
added_joins: set[str] = set()
for field in fields:
if isinstance(field, tuple):
# Relationship: (User.role, Role.name) or deeper
for rel in field[:-1]:
rel_key = str(rel)
if rel_key not in added_joins:
joins.append(rel)
added_joins.add(rel_key)
column = field[-1]
else:
column = field
# Build the filter (cast to String for non-text columns)
column_as_string = column.cast(String)
if config.case_sensitive:
filters.append(column_as_string.like(f"%{query}%"))
else:
filters.append(column_as_string.ilike(f"%{query}%"))
if not filters:
return [], []
# Combine based on match_mode
if config.match_mode == "any":
return [or_(*filters)], joins
else:
return filters, joins
-175
View File
@@ -1,175 +0,0 @@
"""Database utilities: sessions, transactions, and locks."""
from collections.abc import AsyncGenerator, Callable
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from enum import Enum
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
__all__ = [
"LockMode",
"create_db_context",
"create_db_dependency",
"lock_tables",
"get_transaction",
]
def create_db_dependency(
session_maker: async_sessionmaker[AsyncSession],
) -> Callable[[], AsyncGenerator[AsyncSession, None]]:
"""Create a FastAPI dependency for database sessions.
Creates a dependency function that yields a session and auto-commits
if a transaction is active when the request completes.
Args:
session_maker: Async session factory from create_session_factory()
Returns:
An async generator function usable with FastAPI's Depends()
Example:
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from fastapi_toolsets.db import create_db_dependency
engine = create_async_engine("postgresql+asyncpg://...")
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
get_db = create_db_dependency(SessionLocal)
@app.get("/users")
async def list_users(session: AsyncSession = Depends(get_db)):
...
"""
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with session_maker() as session:
yield session
if session.in_transaction():
await session.commit()
return get_db
def create_db_context(
session_maker: async_sessionmaker[AsyncSession],
) -> Callable[[], AbstractAsyncContextManager[AsyncSession]]:
"""Create a context manager for database sessions.
Creates a context manager for use outside of FastAPI request handlers,
such as in background tasks, CLI commands, or tests.
Args:
session_maker: Async session factory from create_session_factory()
Returns:
An async context manager function
Example:
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from fastapi_toolsets.db import create_db_context
engine = create_async_engine("postgresql+asyncpg://...")
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
get_db_context = create_db_context(SessionLocal)
async def background_task():
async with get_db_context() as session:
user = await UserCrud.get(session, [User.id == 1])
...
"""
get_db = create_db_dependency(session_maker)
return asynccontextmanager(get_db)
@asynccontextmanager
async def get_transaction(
session: AsyncSession,
) -> AsyncGenerator[AsyncSession, None]:
"""Get a transaction context, handling nested transactions.
If already in a transaction, creates a savepoint (nested transaction).
Otherwise, starts a new transaction.
Args:
session: AsyncSession instance
Yields:
The session within the transaction context
Example:
async with get_transaction(session):
session.add(model)
# Auto-commits on exit, rolls back on exception
"""
if session.in_transaction():
async with session.begin_nested():
yield session
else:
async with session.begin():
yield session
class LockMode(str, Enum):
"""PostgreSQL table lock modes.
See: https://www.postgresql.org/docs/current/explicit-locking.html
"""
ACCESS_SHARE = "ACCESS SHARE"
ROW_SHARE = "ROW SHARE"
ROW_EXCLUSIVE = "ROW EXCLUSIVE"
SHARE_UPDATE_EXCLUSIVE = "SHARE UPDATE EXCLUSIVE"
SHARE = "SHARE"
SHARE_ROW_EXCLUSIVE = "SHARE ROW EXCLUSIVE"
EXCLUSIVE = "EXCLUSIVE"
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
@asynccontextmanager
async def lock_tables(
session: AsyncSession,
tables: list[type[DeclarativeBase]],
*,
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
timeout: str = "5s",
) -> AsyncGenerator[AsyncSession, None]:
"""Lock PostgreSQL tables for the duration of a transaction.
Acquires table-level locks that are held until the transaction ends.
Useful for preventing concurrent modifications during critical operations.
Args:
session: AsyncSession instance
tables: List of SQLAlchemy model classes to lock
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE)
timeout: Lock timeout (default: "5s")
Yields:
The session with locked tables
Raises:
SQLAlchemyError: If lock cannot be acquired within timeout
Example:
from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(session, [User, Account]):
# Tables are locked with SHARE UPDATE EXCLUSIVE mode
user = await UserCrud.get(session, [User.id == 1])
user.balance += 100
# With custom lock mode
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
# Exclusive lock - no other transactions can access
await process_order(session, order_id)
"""
table_names = ",".join(table.__tablename__ for table in tables)
async with get_transaction(session):
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
yield session
@@ -1,5 +0,0 @@
"""FastAPI dependency factories for database objects."""
from .factory import BodyDependency, PathDependency
__all__ = ["BodyDependency", "PathDependency"]
@@ -1,139 +0,0 @@
"""Dependency factories for FastAPI routes."""
import inspect
from collections.abc import AsyncGenerator, Callable
from typing import Any, TypeVar, cast
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase
from ..crud import CrudFactory
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]]
def PathDependency(
model: type[ModelType],
field: Any,
*,
session_dep: SessionDependency,
param_name: str | None = None,
) -> ModelType:
"""Create a dependency that fetches a DB object from a path parameter.
Args:
model: SQLAlchemy model class
field: Model field to filter by (e.g., User.id)
session_dep: Session dependency function (e.g., get_db)
param_name: Path parameter name (defaults to model_field, e.g., user_id)
Returns:
A Depends() instance that resolves to the model instance
Raises:
NotFoundError: If no matching record is found
Example:
UserDep = PathDependency(User, User.id, session_dep=get_db)
@router.get("/user/{id}")
async def get(
user: User = UserDep,
): ...
"""
crud = CrudFactory(model)
name = (
param_name
if param_name is not None
else "{}_{}".format(model.__name__.lower(), field.key)
)
python_type = field.type.python_type
async def dependency(
session: AsyncSession = Depends(session_dep), **kwargs: Any
) -> ModelType:
value = kwargs[name]
return await crud.get(session, filters=[field == value])
setattr(
dependency,
"__signature__",
inspect.Signature(
parameters=[
inspect.Parameter(
name, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
),
inspect.Parameter(
"session",
inspect.Parameter.KEYWORD_ONLY,
annotation=AsyncSession,
default=Depends(session_dep),
),
]
),
)
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
def BodyDependency(
model: type[ModelType],
field: Any,
*,
session_dep: SessionDependency,
body_field: str,
) -> ModelType:
"""Create a dependency that fetches a DB object from a body field.
Args:
model: SQLAlchemy model class
field: Model field to filter by (e.g., User.id)
session_dep: Session dependency function (e.g., get_db)
body_field: Name of the field in the request body
Returns:
A Depends() instance that resolves to the model instance
Raises:
NotFoundError: If no matching record is found
Example:
UserDep = BodyDependency(
User, User.ctfd_id, session_dep=get_db, body_field="user_id"
)
@router.post("/assign")
async def assign(
user: User = UserDep,
): ...
"""
crud = CrudFactory(model)
python_type = field.type.python_type
async def dependency(
session: AsyncSession = Depends(session_dep), **kwargs: Any
) -> ModelType:
value = kwargs[body_field]
return await crud.get(session, filters=[field == value])
setattr(
dependency,
"__signature__",
inspect.Signature(
parameters=[
inspect.Parameter(
body_field, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
),
inspect.Parameter(
"session",
inspect.Parameter.KEYWORD_ONLY,
annotation=AsyncSession,
default=Depends(session_dep),
),
]
),
)
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
@@ -1,23 +0,0 @@
from .exceptions import (
ApiError,
ApiException,
ConflictError,
ForbiddenError,
NoSearchableFieldsError,
NotFoundError,
UnauthorizedError,
generate_error_responses,
)
from .handler import init_exceptions_handlers
__all__ = [
"ApiError",
"ApiException",
"ConflictError",
"ForbiddenError",
"generate_error_responses",
"init_exceptions_handlers",
"NoSearchableFieldsError",
"NotFoundError",
"UnauthorizedError",
]
@@ -1,185 +0,0 @@
"""Custom exceptions with standardized API error responses."""
from typing import Any, ClassVar
from ..schemas import ApiError, ErrorResponse, ResponseStatus
class ApiException(Exception):
"""Base exception for API errors with structured response.
Subclass this to create custom API exceptions with consistent error format.
The exception handler will use api_error to generate the response.
Example:
class CustomError(ApiException):
api_error = ApiError(
code=400,
msg="Bad Request",
desc="The request was invalid.",
err_code="CUSTOM-400",
)
"""
api_error: ClassVar[ApiError]
def __init__(self, detail: str | None = None):
"""Initialize the exception.
Args:
detail: Optional override for the error message
"""
super().__init__(detail or self.api_error.msg)
class UnauthorizedError(ApiException):
"""HTTP 401 - User is not authenticated."""
api_error = ApiError(
code=401,
msg="Unauthorized",
desc="Authentication credentials were missing or invalid.",
err_code="AUTH-401",
)
class ForbiddenError(ApiException):
"""HTTP 403 - User lacks required permissions."""
api_error = ApiError(
code=403,
msg="Forbidden",
desc="You do not have permission to access this resource.",
err_code="AUTH-403",
)
class NotFoundError(ApiException):
"""HTTP 404 - Resource not found."""
api_error = ApiError(
code=404,
msg="Not Found",
desc="The requested resource was not found.",
err_code="RES-404",
)
class ConflictError(ApiException):
"""HTTP 409 - Resource conflict."""
api_error = ApiError(
code=409,
msg="Conflict",
desc="The request conflicts with the current state of the resource.",
err_code="RES-409",
)
class InsufficientRolesError(ForbiddenError):
"""User does not have the required roles."""
api_error = ApiError(
code=403,
msg="Insufficient Roles",
desc="You do not have the required roles to access this resource.",
err_code="RBAC-403",
)
def __init__(self, required_roles: list[str], user_roles: set[str] | None = None):
self.required_roles = required_roles
self.user_roles = user_roles
desc = f"Required roles: {', '.join(required_roles)}"
if user_roles is not None:
desc += f". User has: {', '.join(user_roles) if user_roles else 'no roles'}"
super().__init__(desc)
class UserNotFoundError(NotFoundError):
"""User was not found."""
api_error = ApiError(
code=404,
msg="User Not Found",
desc="The requested user was not found.",
err_code="USER-404",
)
class RoleNotFoundError(NotFoundError):
"""Role was not found."""
api_error = ApiError(
code=404,
msg="Role Not Found",
desc="The requested role was not found.",
err_code="ROLE-404",
)
class NoSearchableFieldsError(ApiException):
"""Raised when search is requested but no searchable fields are available."""
api_error = ApiError(
code=400,
msg="No Searchable Fields",
desc="No searchable fields configured for this resource.",
err_code="SEARCH-400",
)
def __init__(self, model: type) -> None:
self.model = model
detail = (
f"No searchable fields found for model '{model.__name__}'. "
"Provide 'search_fields' parameter or set 'searchable_fields' on the CRUD class."
)
super().__init__(detail)
def generate_error_responses(
*errors: type[ApiException],
) -> dict[int | str, dict[str, Any]]:
"""Generate OpenAPI response documentation for exceptions.
Use this to document possible error responses for an endpoint.
Args:
*errors: Exception classes that inherit from ApiException
Returns:
Dict suitable for FastAPI's responses parameter
Example:
from fastapi_toolsets.exceptions import generate_error_responses, UnauthorizedError, ForbiddenError
@app.get(
"/admin",
responses=generate_error_responses(UnauthorizedError, ForbiddenError)
)
async def admin_endpoint():
...
"""
responses: dict[int | str, dict[str, Any]] = {}
for error in errors:
api_error = error.api_error
responses[api_error.code] = {
"model": ErrorResponse,
"description": api_error.msg,
"content": {
"application/json": {
"example": {
"data": None,
"status": ResponseStatus.FAIL.value,
"message": api_error.msg,
"description": api_error.desc,
"error_code": api_error.err_code,
}
}
},
}
return responses
-169
View File
@@ -1,169 +0,0 @@
"""Exception handlers for FastAPI applications."""
from typing import Any
from fastapi import FastAPI, Request, Response, status
from fastapi.exceptions import RequestValidationError, ResponseValidationError
from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse
from ..schemas import ResponseStatus
from .exceptions import ApiException
def init_exceptions_handlers(app: FastAPI) -> FastAPI:
_register_exception_handlers(app)
app.openapi = lambda: _custom_openapi(app) # type: ignore[method-assign]
return app
def _register_exception_handlers(app: FastAPI) -> None:
"""Register all exception handlers on a FastAPI application.
Args:
app: FastAPI application instance
Example:
from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers
app = FastAPI()
init_exceptions_handlers(app)
"""
@app.exception_handler(ApiException)
async def api_exception_handler(request: Request, exc: ApiException) -> Response:
"""Handle custom API exceptions with structured response."""
api_error = exc.api_error
return JSONResponse(
status_code=api_error.code,
content={
"data": None,
"status": ResponseStatus.FAIL.value,
"message": api_error.msg,
"description": api_error.desc,
"error_code": api_error.err_code,
},
)
@app.exception_handler(RequestValidationError)
async def request_validation_handler(
request: Request, exc: RequestValidationError
) -> Response:
"""Handle Pydantic request validation errors (422)."""
return _format_validation_error(exc)
@app.exception_handler(ResponseValidationError)
async def response_validation_handler(
request: Request, exc: ResponseValidationError
) -> Response:
"""Handle Pydantic response validation errors (422)."""
return _format_validation_error(exc)
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception) -> Response:
"""Handle all unhandled exceptions with a generic 500 response."""
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"data": None,
"status": ResponseStatus.FAIL.value,
"message": "Internal Server Error",
"description": "An unexpected error occurred. Please try again later.",
"error_code": "SERVER-500",
},
)
def _format_validation_error(
exc: RequestValidationError | ResponseValidationError,
) -> JSONResponse:
"""Format validation errors into a structured response."""
errors = exc.errors()
formatted_errors = []
for error in errors:
field_path = ".".join(
str(loc)
for loc in error["loc"]
if loc not in ("body", "query", "path", "header", "cookie")
)
formatted_errors.append(
{
"field": field_path or "root",
"message": error.get("msg", ""),
"type": error.get("type", ""),
}
)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"data": {"errors": formatted_errors},
"status": ResponseStatus.FAIL.value,
"message": "Validation Error",
"description": f"{len(formatted_errors)} validation error(s) detected",
"error_code": "VAL-422",
},
)
def _custom_openapi(app: FastAPI) -> dict[str, Any]:
"""Generate custom OpenAPI schema with standardized error format.
Replaces default 422 validation error responses with the custom format.
Args:
app: FastAPI application instance
Returns:
OpenAPI schema dict
Example:
from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers
app = FastAPI()
init_exceptions_handlers(app) # Automatically sets custom OpenAPI
"""
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
openapi_version=app.openapi_version,
description=app.description,
routes=app.routes,
)
for path_data in openapi_schema.get("paths", {}).values():
for operation in path_data.values():
if isinstance(operation, dict) and "responses" in operation:
if "422" in operation["responses"]:
operation["responses"]["422"] = {
"description": "Validation Error",
"content": {
"application/json": {
"example": {
"data": {
"errors": [
{
"field": "field_name",
"message": "value is not valid",
"type": "value_error",
}
]
},
"status": ResponseStatus.FAIL.value,
"message": "Validation Error",
"description": "1 validation error(s) detected",
"error_code": "VAL-422",
}
}
},
}
app.openapi_schema = openapi_schema
return app.openapi_schema
-13
View File
@@ -1,13 +0,0 @@
from .enum import LoadStrategy
from .registry import Context, FixtureRegistry
from .utils import get_obj_by_attr, load_fixtures, load_fixtures_by_context
__all__ = [
"Context",
"FixtureRegistry",
"LoadStrategy",
"get_obj_by_attr",
"load_fixtures",
"load_fixtures_by_context",
"register_fixtures",
]
-30
View File
@@ -1,30 +0,0 @@
from enum import Enum
class LoadStrategy(str, Enum):
"""Strategy for loading fixtures into the database."""
INSERT = "insert"
"""Insert new records. Fails if record already exists."""
MERGE = "merge"
"""Insert or update based on primary key (SQLAlchemy merge)."""
SKIP_EXISTING = "skip_existing"
"""Insert only if record doesn't exist (based on primary key)."""
class Context(str, Enum):
"""Predefined fixture contexts."""
BASE = "base"
"""Base fixtures loaded in all environments."""
PRODUCTION = "production"
"""Production-only fixtures."""
DEVELOPMENT = "development"
"""Development fixtures."""
TESTING = "testing"
"""Test fixtures."""
-213
View File
@@ -1,213 +0,0 @@
"""Fixture system with dependency management and context support."""
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from typing import Any, cast
from sqlalchemy.orm import DeclarativeBase
from ..logger import get_logger
from .enum import Context
logger = get_logger()
@dataclass
class Fixture:
"""A fixture definition with metadata."""
name: str
func: Callable[[], Sequence[DeclarativeBase]]
depends_on: list[str] = field(default_factory=list)
contexts: list[str] = field(default_factory=lambda: [Context.BASE])
class FixtureRegistry:
"""Registry for managing fixtures with dependencies.
Example:
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"])
def users():
return [
User(id=1, username="admin", role_id=1),
]
@fixtures.register(depends_on=["users"], contexts=[Context.TESTING])
def test_data():
return [
Post(id=1, title="Test", user_id=1),
]
"""
def __init__(
self,
contexts: list[str | Context] | None = None,
) -> None:
self._fixtures: dict[str, Fixture] = {}
self._default_contexts: list[str] | None = (
[c.value if isinstance(c, Context) else c for c in contexts]
if contexts
else None
)
def register(
self,
func: Callable[[], Sequence[DeclarativeBase]] | None = None,
*,
name: str | None = None,
depends_on: list[str] | None = None,
contexts: list[str | Context] | None = None,
) -> Callable[..., Any]:
"""Register a fixture function.
Can be used as a decorator with or without arguments.
Args:
func: Fixture function returning list of model instances
name: Fixture name (defaults to function name)
depends_on: List of fixture names this depends on
contexts: List of contexts this fixture belongs to
Example:
@fixtures.register
def roles():
return [Role(id=1, name="admin")]
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
def test_users():
return [User(id=1, username="test", role_id=1)]
"""
def decorator(
fn: Callable[[], Sequence[DeclarativeBase]],
) -> Callable[[], Sequence[DeclarativeBase]]:
fixture_name = name or cast(Any, fn).__name__
if contexts is not None:
fixture_contexts = [
c.value if isinstance(c, Context) else c for c in contexts
]
elif self._default_contexts is not None:
fixture_contexts = self._default_contexts
else:
fixture_contexts = [Context.BASE.value]
self._fixtures[fixture_name] = Fixture(
name=fixture_name,
func=fn,
depends_on=depends_on or [],
contexts=fixture_contexts,
)
return fn
if func is not None:
return decorator(func)
return decorator
def include_registry(self, registry: "FixtureRegistry") -> None:
"""Include another `FixtureRegistry` in the same current `FixtureRegistry`.
Args:
registry: The `FixtureRegistry` to include
Raises:
ValueError: If a fixture name already exists in the current registry
Example:
registry = FixtureRegistry()
dev_registry = FixtureRegistry()
@dev_registry.register
def dev_data():
return [...]
registry.include_registry(registry=dev_registry)
"""
for name, fixture in registry._fixtures.items():
if name in self._fixtures:
raise ValueError(
f"Fixture '{name}' already exists in the current registry"
)
self._fixtures[name] = fixture
def get(self, name: str) -> Fixture:
"""Get a fixture by name."""
if name not in self._fixtures:
raise KeyError(f"Fixture '{name}' not found")
return self._fixtures[name]
def get_all(self) -> list[Fixture]:
"""Get all registered fixtures."""
return list(self._fixtures.values())
def get_by_context(self, *contexts: str | Context) -> list[Fixture]:
"""Get fixtures for specific contexts."""
context_values = {c.value if isinstance(c, Context) else c for c in contexts}
return [f for f in self._fixtures.values() if set(f.contexts) & context_values]
def resolve_dependencies(self, *names: str) -> list[str]:
"""Resolve fixture dependencies in topological order.
Args:
*names: Fixture names to resolve
Returns:
List of fixture names in load order (dependencies first)
Raises:
KeyError: If a fixture is not found
ValueError: If circular dependency detected
"""
resolved: list[str] = []
seen: set[str] = set()
visiting: set[str] = set()
def visit(name: str) -> None:
if name in resolved:
return
if name in visiting:
raise ValueError(f"Circular dependency detected: {name}")
visiting.add(name)
fixture = self.get(name)
for dep in fixture.depends_on:
visit(dep)
visiting.remove(name)
resolved.append(name)
seen.add(name)
for name in names:
visit(name)
return resolved
def resolve_context_dependencies(self, *contexts: str | Context) -> list[str]:
"""Resolve all fixtures for contexts with dependencies.
Args:
*contexts: Contexts to load
Returns:
List of fixture names in load order
"""
context_fixtures = self.get_by_context(*contexts)
names = [f.name for f in context_fixtures]
all_deps: set[str] = set()
for name in names:
deps = self.resolve_dependencies(name)
all_deps.update(deps)
return self.resolve_dependencies(*all_deps)
-149
View File
@@ -1,149 +0,0 @@
from collections.abc import Callable, Sequence
from typing import Any, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase
from ..db import get_transaction
from ..logger import get_logger
from .enum import LoadStrategy
from .registry import Context, FixtureRegistry
logger = get_logger()
T = TypeVar("T", bound=DeclarativeBase)
def get_obj_by_attr(
fixtures: Callable[[], Sequence[T]], attr_name: str, value: Any
) -> T:
"""Get a SQLAlchemy model instance by matching an attribute value.
Args:
fixtures: A fixture function registered via ``@registry.register``
that returns a sequence of SQLAlchemy model instances.
attr_name: Name of the attribute to match against.
value: Value to match.
Returns:
The first model instance where the attribute matches the given value.
Raises:
StopIteration: If no matching object is found.
"""
return next(obj for obj in fixtures() if getattr(obj, attr_name) == value)
async def load_fixtures(
session: AsyncSession,
registry: FixtureRegistry,
*names: str,
strategy: LoadStrategy = LoadStrategy.MERGE,
) -> dict[str, list[DeclarativeBase]]:
"""Load specific fixtures by name with dependencies.
Args:
session: Database session
registry: Fixture registry
*names: Fixture names to load (dependencies auto-resolved)
strategy: How to handle existing records
Returns:
Dict mapping fixture names to loaded instances
Example:
# Loads 'roles' first (dependency), then 'users'
result = await load_fixtures(session, fixtures, "users")
print(result["users"]) # [User(...), ...]
"""
ordered = registry.resolve_dependencies(*names)
return await _load_ordered(session, registry, ordered, strategy)
async def load_fixtures_by_context(
session: AsyncSession,
registry: FixtureRegistry,
*contexts: str | Context,
strategy: LoadStrategy = LoadStrategy.MERGE,
) -> dict[str, list[DeclarativeBase]]:
"""Load all fixtures for specific contexts.
Args:
session: Database session
registry: Fixture registry
*contexts: Contexts to load (e.g., Context.BASE, Context.TESTING)
strategy: How to handle existing records
Returns:
Dict mapping fixture names to loaded instances
Example:
# Load base + testing fixtures
await load_fixtures_by_context(
session, fixtures,
Context.BASE, Context.TESTING
)
"""
ordered = registry.resolve_context_dependencies(*contexts)
return await _load_ordered(session, registry, ordered, strategy)
async def _load_ordered(
session: AsyncSession,
registry: FixtureRegistry,
ordered_names: list[str],
strategy: LoadStrategy,
) -> dict[str, list[DeclarativeBase]]:
"""Load fixtures in order."""
results: dict[str, list[DeclarativeBase]] = {}
for name in ordered_names:
fixture = registry.get(name)
instances = list(fixture.func())
if not instances:
results[name] = []
continue
model_name = type(instances[0]).__name__
loaded: list[DeclarativeBase] = []
async with get_transaction(session):
for instance in instances:
if strategy == LoadStrategy.INSERT:
session.add(instance)
loaded.append(instance)
elif strategy == LoadStrategy.MERGE:
merged = await session.merge(instance)
loaded.append(merged)
elif strategy == LoadStrategy.SKIP_EXISTING:
pk = _get_primary_key(instance)
if pk is not None:
existing = await session.get(type(instance), pk)
if existing is None:
session.add(instance)
loaded.append(instance)
else:
session.add(instance)
loaded.append(instance)
results[name] = loaded
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
return results
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
"""Get the primary key value of a model instance."""
mapper = instance.__class__.__mapper__
pk_cols = mapper.primary_key
if len(pk_cols) == 1:
return getattr(instance, pk_cols[0].name, None)
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
if all(v is not None for v in pk_values):
return pk_values
return None
-81
View File
@@ -1,81 +0,0 @@
"""Logging configuration for FastAPI applications and CLI tools."""
import logging
import sys
from typing import Literal
__all__ = ["LogLevel", "configure_logging", "get_logger"]
DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
UVICORN_LOGGERS = ("uvicorn", "uvicorn.access", "uvicorn.error")
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
def configure_logging(
level: LogLevel | int = "INFO",
fmt: str = DEFAULT_FORMAT,
logger_name: str | None = None,
) -> logging.Logger:
"""Configure logging with a stdout handler and consistent format.
Sets up a :class:`~logging.StreamHandler` writing to stdout with the
given format and level. Also configures the uvicorn loggers so that
FastAPI access logs use the same format.
Calling this function multiple times is safe -- existing handlers are
replaced rather than duplicated.
Args:
level: Log level (e.g. ``"DEBUG"``, ``"INFO"``, or ``logging.DEBUG``).
fmt: Log format string. Defaults to
``"%(asctime)s - %(name)s - %(levelname)s - %(message)s"``.
logger_name: Logger name to configure. ``None`` (the default)
configures the root logger so all loggers inherit the settings.
Returns:
The configured Logger instance.
"""
formatter = logging.Formatter(fmt)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
logger = logging.getLogger(logger_name)
logger.handlers.clear()
logger.addHandler(handler)
logger.setLevel(level)
for name in UVICORN_LOGGERS:
uv_logger = logging.getLogger(name)
uv_logger.handlers.clear()
uv_logger.addHandler(handler)
uv_logger.setLevel(level)
return logger
_SENTINEL = object()
def get_logger(name: str | None = _SENTINEL) -> logging.Logger: # type: ignore[assignment]
"""Return a logger with the given *name*.
A thin convenience wrapper around :func:`logging.getLogger` that keeps
logging imports consistent across the codebase.
When called without arguments, the caller's ``__name__`` is used
automatically, so ``get_logger()`` in a module is equivalent to
``logging.getLogger(__name__)``. Pass ``None`` explicitly to get the
root logger.
Args:
name: Logger name. Defaults to the caller's ``__name__``.
Pass ``None`` to get the root logger.
Returns:
A Logger instance.
"""
if name is _SENTINEL:
name = sys._getframe(1).f_globals.get("__name__")
return logging.getLogger(name)
-8
View File
@@ -1,8 +0,0 @@
from .plugin import register_fixtures
from .utils import create_async_client, create_db_session
__all__ = [
"create_async_client",
"create_db_session",
"register_fixtures",
]
-204
View File
@@ -1,204 +0,0 @@
"""Pytest plugin for using FixtureRegistry fixtures in tests.
This module provides utilities to automatically generate pytest fixtures
from your FixtureRegistry, with proper dependency resolution.
Example:
# conftest.py
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.fixtures import fixtures # Your FixtureRegistry
from app.models import Base
from fastapi_toolsets.pytest_plugin import register_fixtures
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/test_db"
@pytest.fixture
async def engine():
engine = create_async_engine(DATABASE_URL)
yield engine
await engine.dispose()
@pytest.fixture
async def db_session(engine):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
session = session_factory()
try:
yield session
finally:
await session.close()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
# Automatically generate pytest fixtures from registry
# Creates: fixture_roles, fixture_users, fixture_posts, etc.
register_fixtures(fixtures, globals())
Usage in tests:
# test_users.py
async def test_user_count(db_session, fixture_users):
# fixture_users automatically loads fixture_roles first (if dependency)
# and returns the list of User models
assert len(fixture_users) > 0
async def test_user_role(db_session, fixture_users):
user = fixture_users[0]
assert user.role_id is not None
"""
from collections.abc import Callable, Sequence
from typing import Any
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase
from ..db import get_transaction
from ..fixtures import FixtureRegistry, LoadStrategy
def register_fixtures(
registry: FixtureRegistry,
namespace: dict[str, Any],
*,
prefix: str = "fixture_",
session_fixture: str = "db_session",
strategy: LoadStrategy = LoadStrategy.MERGE,
) -> list[str]:
"""Register pytest fixtures from a FixtureRegistry.
Automatically creates pytest fixtures for each fixture in the registry.
Dependencies are resolved via pytest fixture dependencies.
Args:
registry: The FixtureRegistry containing fixtures
namespace: The module's globals() dict to add fixtures to
prefix: Prefix for generated fixture names (default: "fixture_")
session_fixture: Name of the db session fixture (default: "db_session")
strategy: Loading strategy for fixtures (default: MERGE)
Returns:
List of created fixture names
Example:
# conftest.py
from app.fixtures import fixtures
from fastapi_toolsets.pytest_plugin import register_fixtures
register_fixtures(fixtures, globals())
# Creates fixtures like:
# - fixture_roles
# - fixture_users (depends on fixture_roles if users depends on roles)
# - fixture_posts (depends on fixture_users if posts depends on users)
"""
created_fixtures: list[str] = []
for fixture in registry.get_all():
fixture_name = f"{prefix}{fixture.name}"
# Build list of pytest fixture dependencies
pytest_deps = [session_fixture]
for dep in fixture.depends_on:
pytest_deps.append(f"{prefix}{dep}")
# Create the fixture function
fixture_func = _create_fixture_function(
registry=registry,
fixture_name=fixture.name,
dependencies=pytest_deps,
strategy=strategy,
)
# Apply pytest.fixture decorator
decorated = pytest.fixture(fixture_func)
# Add to namespace
namespace[fixture_name] = decorated
created_fixtures.append(fixture_name)
return created_fixtures
def _create_fixture_function(
registry: FixtureRegistry,
fixture_name: str,
dependencies: list[str],
strategy: LoadStrategy,
) -> Callable[..., Any]:
"""Create a fixture function with the correct signature.
The function signature must include all dependencies as parameters
for pytest to resolve them correctly.
"""
# Get the fixture definition
fixture_def = registry.get(fixture_name)
# Build the function dynamically with correct parameters
# We need the session as first param, then all dependencies
async def fixture_func(**kwargs: Any) -> Sequence[DeclarativeBase]:
# Get session from kwargs (first dependency)
session: AsyncSession = kwargs[dependencies[0]]
# Load the fixture data
instances = list(fixture_def.func())
if not instances:
return []
loaded: list[DeclarativeBase] = []
async with get_transaction(session):
for instance in instances:
if strategy == LoadStrategy.INSERT:
session.add(instance)
loaded.append(instance)
elif strategy == LoadStrategy.MERGE:
merged = await session.merge(instance)
loaded.append(merged)
elif strategy == LoadStrategy.SKIP_EXISTING:
pk = _get_primary_key(instance)
if pk is not None:
existing = await session.get(type(instance), pk)
if existing is None:
session.add(instance)
loaded.append(instance)
else:
loaded.append(existing)
else:
session.add(instance)
loaded.append(instance)
return loaded
# Update function signature to include dependencies
# This is needed for pytest to inject the right fixtures
params = ", ".join(dependencies)
code = f"async def {fixture_name}_fixture({params}):\n return await _impl({', '.join(f'{d}={d}' for d in dependencies)})"
local_ns: dict[str, Any] = {"_impl": fixture_func}
exec(code, local_ns) # noqa: S102
created_func = local_ns[f"{fixture_name}_fixture"]
created_func.__doc__ = f"Load {fixture_name} fixture data."
return created_func
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
"""Get the primary key value of a model instance."""
mapper = instance.__class__.__mapper__
pk_cols = mapper.primary_key
if len(pk_cols) == 1:
return getattr(instance, pk_cols[0].name, None)
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
if all(v is not None for v in pk_values):
return pk_values
return None
-110
View File
@@ -1,110 +0,0 @@
"""Pytest helper utilities for FastAPI testing."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from ..db import create_db_context
@asynccontextmanager
async def create_async_client(
app: Any,
base_url: str = "http://test",
) -> AsyncGenerator[AsyncClient, None]:
"""Create an async httpx client for testing FastAPI applications.
Args:
app: FastAPI application instance.
base_url: Base URL for requests. Defaults to "http://test".
Yields:
An AsyncClient configured for the app.
Example:
```python
from fastapi import FastAPI
from fastapi_toolsets.pytest import create_async_client
app = FastAPI()
@pytest.fixture
async def client():
async with create_async_client(app) as c:
yield c
async def test_endpoint(client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
```
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url=base_url) as client:
yield client
@asynccontextmanager
async def create_db_session(
database_url: str,
base: type[DeclarativeBase],
*,
echo: bool = False,
expire_on_commit: bool = False,
drop_tables: bool = True,
) -> AsyncGenerator[AsyncSession, None]:
"""Create a database session for testing.
Creates tables before yielding the session and optionally drops them after.
Each call creates a fresh engine and session for test isolation.
Args:
database_url: Database connection URL (e.g., "postgresql+asyncpg://...").
base: SQLAlchemy DeclarativeBase class containing model metadata.
echo: Enable SQLAlchemy query logging. Defaults to False.
expire_on_commit: Expire objects after commit. Defaults to False.
drop_tables: Drop tables after test. Defaults to True.
Yields:
An AsyncSession ready for database operations.
Example:
```python
from fastapi_toolsets.pytest import create_db_session
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, Base) as session:
yield session
async def test_create_user(db_session: AsyncSession):
user = User(name="test")
db_session.add(user)
await db_session.commit()
```
"""
engine = create_async_engine(database_url, echo=echo)
try:
# Create tables
async with engine.begin() as conn:
await conn.run_sync(base.metadata.create_all)
# Create session using existing db context utility
session_maker = async_sessionmaker(engine, expire_on_commit=expire_on_commit)
get_session = create_db_context(session_maker)
async with get_session() as session:
yield session
if drop_tables:
async with engine.begin() as conn:
await conn.run_sync(base.metadata.drop_all)
finally:
await engine.dispose()
-116
View File
@@ -1,116 +0,0 @@
"""Base Pydantic schemas for API responses."""
from enum import Enum
from typing import ClassVar, Generic, TypeVar
from pydantic import BaseModel, ConfigDict
__all__ = [
"ApiError",
"ErrorResponse",
"Pagination",
"PaginatedResponse",
"Response",
"ResponseStatus",
]
DataT = TypeVar("DataT")
class PydanticBase(BaseModel):
"""Base class for all Pydantic models with common configuration."""
model_config: ClassVar[ConfigDict] = ConfigDict(
from_attributes=True,
validate_assignment=True,
)
class ResponseStatus(str, Enum):
"""Standard API response status."""
SUCCESS = "SUCCESS"
FAIL = "FAIL"
class ApiError(PydanticBase):
"""Structured API error definition.
Used to define standard error responses with consistent format.
Attributes:
code: HTTP status code
msg: Short error message
desc: Detailed error description
err_code: Application-specific error code (e.g., "AUTH-401")
"""
code: int
msg: str
desc: str
err_code: str
class BaseResponse(PydanticBase):
"""Base response structure for all API responses.
Attributes:
status: SUCCESS or FAIL
message: Human-readable message
error_code: Error code if status is FAIL, None otherwise
"""
status: ResponseStatus = ResponseStatus.SUCCESS
message: str = "Success"
error_code: str | None = None
class Response(BaseResponse, Generic[DataT]):
"""Generic API response with data payload.
Example:
Response[UserRead](data=user, message="User retrieved")
"""
data: DataT | None = None
class ErrorResponse(BaseResponse):
"""Error response with additional description field.
Used for error responses that need more context.
"""
status: ResponseStatus = ResponseStatus.FAIL
description: str | None = None
data: None = None
class Pagination(PydanticBase):
"""Pagination metadata for list responses.
Attributes:
total_count: Total number of items across all pages
items_per_page: Number of items per page
page: Current page number (1-indexed)
has_more: Whether there are more pages
"""
total_count: int
items_per_page: int
page: int
has_more: bool
class PaginatedResponse(BaseResponse, Generic[DataT]):
"""Paginated API response for list endpoints.
Example:
PaginatedResponse[UserRead](
data=users,
pagination=Pagination(total_count=100, items_per_page=10, page=1, has_more=True)
)
"""
data: list[DataT]
pagination: Pagination
Symlink
+1
View File
@@ -0,0 +1 @@
v5.1
-1
View File
@@ -1 +0,0 @@
"""Tests for fastapi-utils package."""
-202
View File
@@ -1,202 +0,0 @@
"""Shared pytest fixtures for fastapi-utils tests."""
import os
import uuid
import pytest
from pydantic import BaseModel
from sqlalchemy import ForeignKey, String, Uuid
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from fastapi_toolsets.crud import CrudFactory
# PostgreSQL connection URL from environment or default for local development
DATABASE_URL = os.getenv("DATABASE_URL") or os.getenv(
"TEST_DATABASE_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5432/fastapi_toolsets_test",
)
# =============================================================================
# Test Models
# =============================================================================
class Base(DeclarativeBase):
"""Base class for test models."""
pass
class Role(Base):
"""Test role model."""
__tablename__ = "roles"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(50), unique=True)
users: Mapped[list["User"]] = relationship(back_populates="role")
class User(Base):
"""Test user model."""
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(50), unique=True)
email: Mapped[str] = mapped_column(String(100), unique=True)
is_active: Mapped[bool] = mapped_column(default=True)
role_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("roles.id"), nullable=True
)
role: Mapped[Role | None] = relationship(back_populates="users")
class Post(Base):
"""Test post model."""
__tablename__ = "posts"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column(String(1000), default="")
is_published: Mapped[bool] = mapped_column(default=False)
author_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
# =============================================================================
# Test Schemas
# =============================================================================
class RoleCreate(BaseModel):
"""Schema for creating a role."""
id: uuid.UUID | None = None
name: str
class RoleUpdate(BaseModel):
"""Schema for updating a role."""
name: str | None = None
class UserCreate(BaseModel):
"""Schema for creating a user."""
id: uuid.UUID | None = None
username: str
email: str
is_active: bool = True
role_id: uuid.UUID | None = None
class UserUpdate(BaseModel):
"""Schema for updating a user."""
username: str | None = None
email: str | None = None
is_active: bool | None = None
role_id: uuid.UUID | None = None
class PostCreate(BaseModel):
"""Schema for creating a post."""
id: uuid.UUID | None = None
title: str
content: str = ""
is_published: bool = False
author_id: uuid.UUID
class PostUpdate(BaseModel):
"""Schema for updating a post."""
title: str | None = None
content: str | None = None
is_published: bool | None = None
# =============================================================================
# CRUD Classes
# =============================================================================
RoleCrud = CrudFactory(Role)
UserCrud = CrudFactory(User)
PostCrud = CrudFactory(Post)
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def anyio_backend():
"""Use asyncio for async tests."""
return "asyncio"
@pytest.fixture(scope="function")
async def engine():
"""Create a PostgreSQL test database engine."""
engine = create_async_engine(DATABASE_URL, echo=False)
yield engine
await engine.dispose()
@pytest.fixture(scope="function")
async def db_session(engine):
"""Create a test database session with tables.
Creates all tables before the test and drops them after.
Each test gets a clean database state.
"""
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Create session
session_factory = async_sessionmaker(engine, expire_on_commit=False)
session = session_factory()
try:
yield session
finally:
await session.close()
# Drop tables after test
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
def sample_role_data() -> RoleCreate:
"""Sample role creation data."""
return RoleCreate(name="admin")
@pytest.fixture
def sample_user_data() -> UserCreate:
"""Sample user creation data."""
return UserCreate(
username="testuser",
email="test@example.com",
is_active=True,
)
@pytest.fixture
def sample_post_data() -> PostCreate:
"""Sample post creation data."""
return PostCreate(
title="Test Post",
content="Test content",
is_published=True,
author_id=uuid.uuid4(),
)
-543
View File
@@ -1,543 +0,0 @@
"""Tests for fastapi_toolsets.cli module."""
import sys
import pytest
from typer.testing import CliRunner
from fastapi_toolsets.cli.config import (
get_config_value,
get_custom_cli,
get_db_context,
get_fixtures_registry,
import_from_string,
)
from fastapi_toolsets.cli.pyproject import find_pyproject, load_pyproject
from fastapi_toolsets.cli.utils import async_command
from fastapi_toolsets.fixtures import FixtureRegistry
runner = CliRunner()
class TestPyproject:
"""Tests for pyproject.toml discovery and loading."""
def test_find_pyproject_in_current_dir(self, tmp_path, monkeypatch):
"""Finds pyproject.toml in current directory."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("[project]\nname = 'test'\n")
monkeypatch.chdir(tmp_path)
result = find_pyproject()
assert result == pyproject
def test_find_pyproject_in_parent_dir(self, tmp_path, monkeypatch):
"""Finds pyproject.toml in parent directory."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("[project]\nname = 'test'\n")
subdir = tmp_path / "src" / "app"
subdir.mkdir(parents=True)
monkeypatch.chdir(subdir)
result = find_pyproject()
assert result == pyproject
def test_find_pyproject_not_found(self, tmp_path, monkeypatch):
"""Returns None when no pyproject.toml exists."""
monkeypatch.chdir(tmp_path)
result = find_pyproject()
assert result is None
def test_load_pyproject_returns_tool_config(self, tmp_path, monkeypatch):
"""load_pyproject returns the [tool.fastapi-toolsets] section."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
'[tool.fastapi-toolsets]\nfixtures = "app.fixtures:registry"\n'
)
monkeypatch.chdir(tmp_path)
result = load_pyproject()
assert result == {"fixtures": "app.fixtures:registry"}
def test_load_pyproject_empty_when_no_file(self, tmp_path, monkeypatch):
"""Returns empty dict when no pyproject.toml exists."""
monkeypatch.chdir(tmp_path)
result = load_pyproject()
assert result == {}
def test_load_pyproject_empty_when_no_tool_section(self, tmp_path, monkeypatch):
"""Returns empty dict when no [tool.fastapi-toolsets] section."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("[project]\nname = 'test'\n")
monkeypatch.chdir(tmp_path)
result = load_pyproject()
assert result == {}
def test_load_pyproject_invalid_toml(self, tmp_path, monkeypatch):
"""Returns empty dict when pyproject.toml is invalid."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("invalid toml {{{")
monkeypatch.chdir(tmp_path)
result = load_pyproject()
assert result == {}
class TestImportFromString:
"""Tests for import_from_string function."""
def test_import_valid_path(self):
"""Import valid module:attribute path."""
result = import_from_string("fastapi_toolsets.fixtures:FixtureRegistry")
assert result is FixtureRegistry
def test_import_without_colon_raises_error(self):
"""Import path without colon raises error."""
with pytest.raises(Exception) as exc_info:
import_from_string("fastapi_toolsets.fixtures.FixtureRegistry")
assert "Expected format: 'module:attribute'" in str(exc_info.value)
def test_import_nonexistent_module_raises_error(self):
"""Import nonexistent module raises error."""
with pytest.raises(Exception) as exc_info:
import_from_string("nonexistent.module:something")
assert "Cannot import module" in str(exc_info.value)
def test_import_nonexistent_attribute_raises_error(self):
"""Import nonexistent attribute raises error."""
with pytest.raises(Exception) as exc_info:
import_from_string("fastapi_toolsets.fixtures:NonexistentClass")
assert "has no attribute" in str(exc_info.value)
class TestGetConfigValue:
"""Tests for get_config_value function."""
def test_get_existing_value(self, tmp_path, monkeypatch):
"""Returns value when key exists."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.fastapi-toolsets]\nfixtures = "app:registry"\n')
monkeypatch.chdir(tmp_path)
result = get_config_value("fixtures")
assert result == "app:registry"
def test_get_missing_value_returns_none(self, tmp_path, monkeypatch):
"""Returns None when key is missing and not required."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("[tool.fastapi-toolsets]\n")
monkeypatch.chdir(tmp_path)
result = get_config_value("fixtures")
assert result is None
def test_get_missing_value_required_raises_error(self, tmp_path, monkeypatch):
"""Raises error when key is missing and required."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("[tool.fastapi-toolsets]\n")
monkeypatch.chdir(tmp_path)
with pytest.raises(Exception) as exc_info:
get_config_value("fixtures", required=True)
assert "No 'fixtures' configured" in str(exc_info.value)
class TestGetFixturesRegistry:
"""Tests for get_fixtures_registry function."""
def test_raises_when_not_configured(self, tmp_path, monkeypatch):
"""Raises error when fixtures not configured."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("[tool.fastapi-toolsets]\n")
monkeypatch.chdir(tmp_path)
with pytest.raises(Exception) as exc_info:
get_fixtures_registry()
assert "No 'fixtures' configured" in str(exc_info.value)
def test_raises_when_not_registry_instance(self, tmp_path, monkeypatch):
"""Raises error when imported object is not a FixtureRegistry."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
'[tool.fastapi-toolsets]\nfixtures = "my_fixtures:registry"\n'
)
fixtures_file = tmp_path / "my_fixtures.py"
fixtures_file.write_text("registry = 'not a registry'\n")
monkeypatch.chdir(tmp_path)
if str(tmp_path) not in sys.path:
sys.path.insert(0, str(tmp_path))
try:
with pytest.raises(Exception) as exc_info:
get_fixtures_registry()
assert "must be a FixtureRegistry instance" in str(exc_info.value)
finally:
if str(tmp_path) in sys.path:
sys.path.remove(str(tmp_path))
if "my_fixtures" in sys.modules:
del sys.modules["my_fixtures"]
class TestGetDbContext:
"""Tests for get_db_context function."""
def test_raises_when_not_configured(self, tmp_path, monkeypatch):
"""Raises error when db_context not configured."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("[tool.fastapi-toolsets]\n")
monkeypatch.chdir(tmp_path)
with pytest.raises(Exception) as exc_info:
get_db_context()
assert "No 'db_context' configured" in str(exc_info.value)
class TestGetCustomCli:
"""Tests for get_custom_cli function."""
def test_returns_none_when_not_configured(self, tmp_path, monkeypatch):
"""Returns None when custom_cli not configured."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("[tool.fastapi-toolsets]\n")
monkeypatch.chdir(tmp_path)
result = get_custom_cli()
assert result is None
def test_raises_when_not_typer_instance(self, tmp_path, monkeypatch):
"""Raises error when imported object is not a Typer instance."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.fastapi-toolsets]\ncustom_cli = "my_cli:cli"\n')
cli_file = tmp_path / "my_cli.py"
cli_file.write_text("cli = 'not a typer'\n")
monkeypatch.chdir(tmp_path)
if str(tmp_path) not in sys.path:
sys.path.insert(0, str(tmp_path))
try:
with pytest.raises(Exception) as exc_info:
get_custom_cli()
assert "must be a Typer instance" in str(exc_info.value)
finally:
if str(tmp_path) in sys.path:
sys.path.remove(str(tmp_path))
if "my_cli" in sys.modules:
del sys.modules["my_cli"]
class TestCliApp:
"""Tests for CLI application."""
def test_cli_help(self, tmp_path, monkeypatch):
"""CLI shows help without fixtures."""
monkeypatch.chdir(tmp_path)
# Need to reload the module to pick up new cwd
import importlib
from fastapi_toolsets.cli import app
importlib.reload(app)
result = runner.invoke(app.cli, ["--help"])
assert result.exit_code == 0
assert "CLI utilities for FastAPI projects" in result.output
class TestFixturesCli:
"""Tests for fixtures CLI commands."""
@pytest.fixture
def cli_env(self, tmp_path, monkeypatch):
"""Set up CLI environment with fixtures config."""
# Create pyproject.toml
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"[tool.fastapi-toolsets]\n"
'fixtures = "fixtures:registry"\n'
'db_context = "db:get_session"\n'
)
# Create fixtures module
fixtures_file = tmp_path / "fixtures.py"
fixtures_file.write_text(
"from fastapi_toolsets.fixtures import FixtureRegistry, Context\n"
"\n"
"registry = FixtureRegistry()\n"
"\n"
"@registry.register(contexts=[Context.BASE])\n"
"def roles():\n"
' return [{"id": 1, "name": "admin"}, {"id": 2, "name": "user"}]\n'
"\n"
'@registry.register(depends_on=["roles"], contexts=[Context.TESTING])\n'
"def users():\n"
' return [{"id": 1, "name": "alice", "role_id": 1}]\n'
)
# Create db module
db_file = tmp_path / "db.py"
db_file.write_text(
"from contextlib import asynccontextmanager\n"
"\n"
"@asynccontextmanager\n"
"async def get_session():\n"
" yield None\n"
)
monkeypatch.chdir(tmp_path)
# Add tmp_path to sys.path for imports
if str(tmp_path) not in sys.path:
sys.path.insert(0, str(tmp_path))
# Reload the CLI module to pick up new config
import importlib
from fastapi_toolsets.cli import app
importlib.reload(app)
yield tmp_path, app.cli
# Cleanup
if str(tmp_path) in sys.path:
sys.path.remove(str(tmp_path))
def test_fixtures_list(self, cli_env):
"""fixtures list shows registered fixtures."""
tmp_path, cli = cli_env
result = runner.invoke(cli, ["fixtures", "list"])
assert result.exit_code == 0
assert "roles" in result.output
assert "users" in result.output
assert "Total: 2 fixture(s)" in result.output
def test_fixtures_list_with_context(self, cli_env):
"""fixtures list --context filters by context."""
tmp_path, cli = cli_env
result = runner.invoke(cli, ["fixtures", "list", "--context", "base"])
assert result.exit_code == 0
assert "roles" in result.output
assert "users" not in result.output
assert "Total: 1 fixture(s)" in result.output
def test_fixtures_load_dry_run(self, cli_env):
"""fixtures load --dry-run shows what would be loaded."""
tmp_path, cli = cli_env
result = runner.invoke(cli, ["fixtures", "load", "base", "--dry-run"])
assert result.exit_code == 0
assert "Fixtures to load" in result.output
assert "roles" in result.output
assert "[Dry run - no changes made]" in result.output
def test_fixtures_load_invalid_strategy(self, cli_env):
"""fixtures load with invalid strategy shows error."""
tmp_path, cli = cli_env
result = runner.invoke(
cli, ["fixtures", "load", "base", "--strategy", "invalid"]
)
assert result.exit_code != 0
class TestCliWithoutFixturesConfig:
"""Tests for CLI when fixtures is not configured."""
def test_no_fixtures_command(self, tmp_path, monkeypatch):
"""fixtures command is not available when not configured."""
# Create pyproject.toml without fixtures
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[project]\nname = "test"\n')
monkeypatch.chdir(tmp_path)
# Reload the CLI module
import importlib
from fastapi_toolsets.cli import app
importlib.reload(app)
result = runner.invoke(app.cli, ["--help"])
assert result.exit_code == 0
assert "fixtures" not in result.output
class TestCustomCliConfig:
"""Tests for custom CLI configuration."""
def test_cli_with_custom_cli(self, tmp_path, monkeypatch):
"""CLI uses custom Typer instance when configured."""
import typer
# Create pyproject.toml with custom_cli config
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.fastapi-toolsets]\ncustom_cli = "my_cli:cli"\n')
# Create custom CLI module with its own Typer and commands
cli_file = tmp_path / "my_cli.py"
cli_file.write_text(
"import typer\n"
"\n"
"cli = typer.Typer(name='my-app', help='My custom CLI')\n"
"\n"
"@cli.command()\n"
"def hello():\n"
' print("Hello from custom CLI!")\n'
)
monkeypatch.chdir(tmp_path)
# Add tmp_path to sys.path for imports
if str(tmp_path) not in sys.path:
sys.path.insert(0, str(tmp_path))
# Remove my_cli from sys.modules if it was previously loaded
if "my_cli" in sys.modules:
del sys.modules["my_cli"]
# Reload the CLI module to pick up new config
import importlib
from fastapi_toolsets.cli import app
importlib.reload(app)
try:
# Verify custom CLI is used
assert isinstance(app.cli, typer.Typer)
result = runner.invoke(app.cli, ["--help"])
assert result.exit_code == 0
assert "My custom CLI" in result.output
assert "hello" in result.output
result = runner.invoke(app.cli, ["hello"])
assert result.exit_code == 0
assert "Hello from custom CLI!" in result.output
finally:
if str(tmp_path) in sys.path:
sys.path.remove(str(tmp_path))
if "my_cli" in sys.modules:
del sys.modules["my_cli"]
def test_custom_cli_with_fixtures(self, tmp_path, monkeypatch):
"""Custom CLI gets fixtures command added when configured."""
# Create pyproject.toml with both custom_cli and fixtures
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"[tool.fastapi-toolsets]\n"
'custom_cli = "my_cli:cli"\n'
'fixtures = "fixtures:registry"\n'
'db_context = "db:get_session"\n'
)
# Create custom CLI module
cli_file = tmp_path / "my_cli.py"
cli_file.write_text(
"import typer\n"
"\n"
"cli = typer.Typer(name='my-app', help='My custom CLI')\n"
"\n"
"@cli.command()\n"
"def hello():\n"
' print("Hello!")\n'
)
# Create fixtures module
fixtures_file = tmp_path / "fixtures.py"
fixtures_file.write_text(
"from fastapi_toolsets.fixtures import FixtureRegistry\n"
"\n"
"registry = FixtureRegistry()\n"
)
# Create db module
db_file = tmp_path / "db.py"
db_file.write_text(
"from contextlib import asynccontextmanager\n"
"\n"
"@asynccontextmanager\n"
"async def get_session():\n"
" yield None\n"
)
monkeypatch.chdir(tmp_path)
if str(tmp_path) not in sys.path:
sys.path.insert(0, str(tmp_path))
for mod in ["my_cli", "fixtures", "db"]:
if mod in sys.modules:
del sys.modules[mod]
import importlib
from fastapi_toolsets.cli import app
importlib.reload(app)
try:
result = runner.invoke(app.cli, ["--help"])
assert result.exit_code == 0
# Should have both custom command and fixtures
assert "hello" in result.output
assert "fixtures" in result.output
finally:
if str(tmp_path) in sys.path:
sys.path.remove(str(tmp_path))
for mod in ["my_cli", "fixtures", "db"]:
if mod in sys.modules:
del sys.modules[mod]
class TestAsyncCommand:
"""Tests for async_command decorator."""
def test_async_command_runs_coroutine(self):
"""async_command runs async function synchronously."""
@async_command
async def async_func(value: int) -> int:
return value * 2
result = async_func(21)
assert result == 42
def test_async_command_preserves_signature(self):
"""async_command preserves function signature."""
@async_command
async def async_func(name: str, count: int = 1) -> str:
return f"{name} x {count}"
result = async_func("test", count=3)
assert result == "test x 3"
def test_async_command_preserves_docstring(self):
"""async_command preserves function docstring."""
@async_command
async def async_func() -> None:
"""This is a docstring."""
pass
assert async_func.__doc__ == """This is a docstring."""
def test_async_command_preserves_name(self):
"""async_command preserves function name."""
@async_command
async def my_async_function() -> None:
pass
assert my_async_function.__name__ == "my_async_function"
-814
View File
@@ -1,814 +0,0 @@
"""Tests for fastapi_toolsets.crud module."""
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_toolsets.crud import CrudFactory
from fastapi_toolsets.crud.factory import AsyncCrud
from fastapi_toolsets.exceptions import NotFoundError
from .conftest import (
Post,
PostCreate,
PostCrud,
Role,
RoleCreate,
RoleCrud,
RoleUpdate,
User,
UserCreate,
UserCrud,
UserUpdate,
)
class TestCrudFactory:
"""Tests for CrudFactory."""
def test_creates_crud_class(self):
"""CrudFactory creates a properly configured CRUD class."""
crud = CrudFactory(User)
assert issubclass(crud, AsyncCrud)
assert crud.model is User
def test_creates_unique_classes(self):
"""Each call creates a unique class."""
crud1 = CrudFactory(User)
crud2 = CrudFactory(User)
assert crud1 is not crud2
def test_class_name_includes_model(self):
"""Generated class name includes model name."""
crud = CrudFactory(User)
assert "User" in crud.__name__
class TestCrudCreate:
"""Tests for CRUD create operations."""
@pytest.mark.anyio
async def test_create_single_record(self, db_session: AsyncSession):
"""Create a single record."""
data = RoleCreate(name="admin")
role = await RoleCrud.create(db_session, data)
assert role.id is not None
assert role.name == "admin"
@pytest.mark.anyio
async def test_create_with_relationship(self, db_session: AsyncSession):
"""Create records with foreign key relationships."""
role = await RoleCrud.create(db_session, RoleCreate(name="user"))
user_data = UserCreate(
username="john",
email="john@example.com",
role_id=role.id,
)
user = await UserCrud.create(db_session, user_data)
assert user.role_id == role.id
@pytest.mark.anyio
async def test_create_with_defaults(self, db_session: AsyncSession):
"""Create uses model defaults."""
user_data = UserCreate(username="jane", email="jane@example.com")
user = await UserCrud.create(db_session, user_data)
assert user.is_active is True
class TestCrudGet:
"""Tests for CRUD get operations."""
@pytest.mark.anyio
async def test_get_existing_record(self, db_session: AsyncSession):
"""Get an existing record by filter."""
created = await RoleCrud.create(db_session, RoleCreate(name="admin"))
fetched = await RoleCrud.get(db_session, [Role.id == created.id])
assert fetched.id == created.id
assert fetched.name == "admin"
@pytest.mark.anyio
async def test_get_raises_not_found(self, db_session: AsyncSession):
"""Get raises NotFoundError for missing records."""
non_existent_id = uuid.uuid4()
with pytest.raises(NotFoundError):
await RoleCrud.get(db_session, [Role.id == non_existent_id])
@pytest.mark.anyio
async def test_get_with_multiple_filters(self, db_session: AsyncSession):
"""Get with multiple filter conditions."""
await UserCrud.create(
db_session,
UserCreate(username="active", email="active@test.com", is_active=True),
)
await UserCrud.create(
db_session,
UserCreate(username="inactive", email="inactive@test.com", is_active=False),
)
user = await UserCrud.get(
db_session,
[User.username == "active", User.is_active == True], # noqa: E712
)
assert user.username == "active"
class TestCrudFirst:
"""Tests for CRUD first operations."""
@pytest.mark.anyio
async def test_first_returns_record(self, db_session: AsyncSession):
"""First returns the first matching record."""
await RoleCrud.create(db_session, RoleCreate(name="admin"))
role = await RoleCrud.first(db_session, [Role.name == "admin"])
assert role is not None
assert role.name == "admin"
@pytest.mark.anyio
async def test_first_returns_none_when_not_found(self, db_session: AsyncSession):
"""First returns None for missing records."""
role = await RoleCrud.first(db_session, [Role.name == "nonexistent"])
assert role is None
@pytest.mark.anyio
async def test_first_without_filters(self, db_session: AsyncSession):
"""First without filters returns any record."""
await RoleCrud.create(db_session, RoleCreate(name="role1"))
await RoleCrud.create(db_session, RoleCreate(name="role2"))
role = await RoleCrud.first(db_session)
assert role is not None
class TestCrudGetMulti:
"""Tests for CRUD get_multi operations."""
@pytest.mark.anyio
async def test_get_multi_returns_all(self, db_session: AsyncSession):
"""Get multiple records."""
await RoleCrud.create(db_session, RoleCreate(name="admin"))
await RoleCrud.create(db_session, RoleCreate(name="user"))
await RoleCrud.create(db_session, RoleCreate(name="guest"))
roles = await RoleCrud.get_multi(db_session)
assert len(roles) == 3
@pytest.mark.anyio
async def test_get_multi_with_filters(self, db_session: AsyncSession):
"""Get multiple with filter."""
await UserCrud.create(
db_session,
UserCreate(username="active1", email="a1@test.com", is_active=True),
)
await UserCrud.create(
db_session,
UserCreate(username="active2", email="a2@test.com", is_active=True),
)
await UserCrud.create(
db_session,
UserCreate(username="inactive", email="i@test.com", is_active=False),
)
active_users = await UserCrud.get_multi(
db_session,
filters=[User.is_active == True], # noqa: E712
)
assert len(active_users) == 2
@pytest.mark.anyio
async def test_get_multi_with_limit(self, db_session: AsyncSession):
"""Get multiple with limit."""
for i in range(5):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i}"))
roles = await RoleCrud.get_multi(db_session, limit=3)
assert len(roles) == 3
@pytest.mark.anyio
async def test_get_multi_with_offset(self, db_session: AsyncSession):
"""Get multiple with offset."""
for i in range(5):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i}"))
roles = await RoleCrud.get_multi(db_session, offset=2)
assert len(roles) == 3
@pytest.mark.anyio
async def test_get_multi_with_order_by(self, db_session: AsyncSession):
"""Get multiple with ordering."""
await RoleCrud.create(db_session, RoleCreate(name="charlie"))
await RoleCrud.create(db_session, RoleCreate(name="alpha"))
await RoleCrud.create(db_session, RoleCreate(name="bravo"))
roles = await RoleCrud.get_multi(db_session, order_by=Role.name)
names = [r.name for r in roles]
assert names == ["alpha", "bravo", "charlie"]
class TestCrudUpdate:
"""Tests for CRUD update operations."""
@pytest.mark.anyio
async def test_update_record(self, db_session: AsyncSession):
"""Update an existing record."""
role = await RoleCrud.create(db_session, RoleCreate(name="old_name"))
updated = await RoleCrud.update(
db_session,
RoleUpdate(name="new_name"),
[Role.id == role.id],
)
assert updated.name == "new_name"
assert updated.id == role.id
@pytest.mark.anyio
async def test_update_raises_not_found(self, db_session: AsyncSession):
"""Update raises NotFoundError for missing records."""
non_existent_id = uuid.uuid4()
with pytest.raises(NotFoundError):
await RoleCrud.update(
db_session,
RoleUpdate(name="new"),
[Role.id == non_existent_id],
)
@pytest.mark.anyio
async def test_update_excludes_unset(self, db_session: AsyncSession):
"""Update excludes unset fields by default."""
user = await UserCrud.create(
db_session,
UserCreate(username="john", email="john@test.com", is_active=True),
)
updated = await UserCrud.update(
db_session,
UserUpdate(username="johnny"),
[User.id == user.id],
)
assert updated.username == "johnny"
assert updated.email == "john@test.com"
assert updated.is_active is True
class TestCrudDelete:
"""Tests for CRUD delete operations."""
@pytest.mark.anyio
async def test_delete_record(self, db_session: AsyncSession):
"""Delete an existing record."""
role = await RoleCrud.create(db_session, RoleCreate(name="to_delete"))
result = await RoleCrud.delete(db_session, [Role.id == role.id])
assert result is True
assert await RoleCrud.first(db_session, [Role.id == role.id]) is None
@pytest.mark.anyio
async def test_delete_multiple_records(self, db_session: AsyncSession):
"""Delete multiple records with filter."""
await UserCrud.create(
db_session,
UserCreate(username="u1", email="u1@test.com", is_active=False),
)
await UserCrud.create(
db_session,
UserCreate(username="u2", email="u2@test.com", is_active=False),
)
await UserCrud.create(
db_session,
UserCreate(username="u3", email="u3@test.com", is_active=True),
)
await UserCrud.delete(db_session, [User.is_active == False]) # noqa: E712
remaining = await UserCrud.get_multi(db_session)
assert len(remaining) == 1
assert remaining[0].username == "u3"
class TestCrudExists:
"""Tests for CRUD exists operations."""
@pytest.mark.anyio
async def test_exists_returns_true(self, db_session: AsyncSession):
"""Exists returns True for existing records."""
await RoleCrud.create(db_session, RoleCreate(name="admin"))
assert await RoleCrud.exists(db_session, [Role.name == "admin"]) is True
@pytest.mark.anyio
async def test_exists_returns_false(self, db_session: AsyncSession):
"""Exists returns False for missing records."""
assert await RoleCrud.exists(db_session, [Role.name == "nonexistent"]) is False
class TestCrudCount:
"""Tests for CRUD count operations."""
@pytest.mark.anyio
async def test_count_all(self, db_session: AsyncSession):
"""Count all records."""
await RoleCrud.create(db_session, RoleCreate(name="role1"))
await RoleCrud.create(db_session, RoleCreate(name="role2"))
await RoleCrud.create(db_session, RoleCreate(name="role3"))
count = await RoleCrud.count(db_session)
assert count == 3
@pytest.mark.anyio
async def test_count_with_filter(self, db_session: AsyncSession):
"""Count records with filter."""
await UserCrud.create(
db_session,
UserCreate(username="a1", email="a1@test.com", is_active=True),
)
await UserCrud.create(
db_session,
UserCreate(username="a2", email="a2@test.com", is_active=True),
)
await UserCrud.create(
db_session,
UserCreate(username="i1", email="i1@test.com", is_active=False),
)
active_count = await UserCrud.count(
db_session,
filters=[User.is_active == True], # noqa: E712
)
assert active_count == 2
class TestCrudUpsert:
"""Tests for CRUD upsert operations (PostgreSQL-specific)."""
@pytest.mark.anyio
async def test_upsert_insert_new_record(self, db_session: AsyncSession):
"""Upsert inserts a new record when it doesn't exist."""
role_id = uuid.uuid4()
data = RoleCreate(id=role_id, name="upsert_new")
role = await RoleCrud.upsert(
db_session,
data,
index_elements=["id"],
)
assert role is not None
assert role.name == "upsert_new"
@pytest.mark.anyio
async def test_upsert_update_existing_record(self, db_session: AsyncSession):
"""Upsert updates an existing record."""
role_id = uuid.uuid4()
# First insert
data = RoleCreate(id=role_id, name="original_name")
await RoleCrud.upsert(db_session, data, index_elements=["id"])
# Upsert with update
updated_data = RoleCreate(id=role_id, name="updated_name")
role = await RoleCrud.upsert(
db_session,
updated_data,
index_elements=["id"],
set_=RoleUpdate(name="updated_name"),
)
assert role is not None
assert role.name == "updated_name"
# Verify only one record exists
count = await RoleCrud.count(db_session, [Role.id == role_id])
assert count == 1
@pytest.mark.anyio
async def test_upsert_do_nothing_on_conflict(self, db_session: AsyncSession):
"""Upsert does nothing on conflict when set_ is not provided."""
role_id = uuid.uuid4()
# First insert
data = RoleCreate(id=role_id, name="do_nothing_original")
await RoleCrud.upsert(db_session, data, index_elements=["id"])
# Upsert without set_ (do nothing)
conflict_data = RoleCreate(id=role_id, name="do_nothing_conflict")
await RoleCrud.upsert(db_session, conflict_data, index_elements=["id"])
# Original value should be preserved
role = await RoleCrud.first(db_session, [Role.id == role_id])
assert role is not None
assert role.name == "do_nothing_original"
@pytest.mark.anyio
async def test_upsert_with_unique_constraint(self, db_session: AsyncSession):
"""Upsert works with unique constraint columns."""
# Insert first role
data1 = RoleCreate(name="unique_role")
await RoleCrud.upsert(db_session, data1, index_elements=["name"])
# Upsert with same name - should update (or do nothing)
data2 = RoleCreate(name="unique_role")
role = await RoleCrud.upsert(db_session, data2, index_elements=["name"])
assert role is not None
assert role.name == "unique_role"
# Should still be only one record
count = await RoleCrud.count(db_session, [Role.name == "unique_role"])
assert count == 1
class TestCrudPaginate:
"""Tests for CRUD pagination."""
@pytest.mark.anyio
async def test_paginate_first_page(self, db_session: AsyncSession):
"""Paginate returns first page."""
for i in range(25):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
result = await RoleCrud.paginate(db_session, page=1, items_per_page=10)
assert len(result.data) == 10
assert result.pagination.total_count == 25
assert result.pagination.page == 1
assert result.pagination.items_per_page == 10
assert result.pagination.has_more is True
@pytest.mark.anyio
async def test_paginate_last_page(self, db_session: AsyncSession):
"""Paginate returns last page with has_more=False."""
for i in range(25):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
result = await RoleCrud.paginate(db_session, page=3, items_per_page=10)
assert len(result.data) == 5
assert result.pagination.has_more is False
@pytest.mark.anyio
async def test_paginate_with_filters(self, db_session: AsyncSession):
"""Paginate with filter conditions."""
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 UserCrud.paginate(
db_session,
filters=[User.is_active == True], # noqa: E712
page=1,
items_per_page=10,
)
assert result.pagination.total_count == 5
@pytest.mark.anyio
async def test_paginate_with_ordering(self, db_session: AsyncSession):
"""Paginate with custom ordering."""
await RoleCrud.create(db_session, RoleCreate(name="charlie"))
await RoleCrud.create(db_session, RoleCreate(name="alpha"))
await RoleCrud.create(db_session, RoleCreate(name="bravo"))
result = await RoleCrud.paginate(
db_session,
order_by=Role.name,
page=1,
items_per_page=10,
)
names = [r.name for r in result.data]
assert names == ["alpha", "bravo", "charlie"]
class TestCrudJoins:
"""Tests for CRUD operations with joins."""
@pytest.mark.anyio
async def test_get_with_join(self, db_session: AsyncSession):
"""Get with inner join filters correctly."""
# Create user with posts
user = await UserCrud.create(
db_session,
UserCreate(username="author", email="author@test.com"),
)
await PostCrud.create(
db_session,
PostCreate(title="Post 1", author_id=user.id, is_published=True),
)
# Get user with join on published posts
fetched = await UserCrud.get(
db_session,
filters=[User.id == user.id, Post.is_published == True], # noqa: E712
joins=[(Post, Post.author_id == User.id)],
)
assert fetched.id == user.id
@pytest.mark.anyio
async def test_first_with_join(self, db_session: AsyncSession):
"""First with join returns matching record."""
user = await UserCrud.create(
db_session,
UserCreate(username="writer", email="writer@test.com"),
)
await PostCrud.create(
db_session,
PostCreate(title="Draft", author_id=user.id, is_published=False),
)
# Find user with unpublished posts
result = await UserCrud.first(
db_session,
filters=[Post.is_published == False], # noqa: E712
joins=[(Post, Post.author_id == User.id)],
)
assert result is not None
assert result.id == user.id
@pytest.mark.anyio
async def test_first_with_outer_join(self, db_session: AsyncSession):
"""First with outer join includes records without related data."""
# User without posts
user = await UserCrud.create(
db_session,
UserCreate(username="no_posts", email="no_posts@test.com"),
)
# With outer join, user should be found even without posts
result = await UserCrud.first(
db_session,
filters=[User.id == user.id],
joins=[(Post, Post.author_id == User.id)],
outer_join=True,
)
assert result is not None
assert result.id == user.id
@pytest.mark.anyio
async def test_get_multi_with_inner_join(self, db_session: AsyncSession):
"""Get multiple with inner join only returns matching records."""
# User with published post
user1 = await UserCrud.create(
db_session,
UserCreate(username="publisher", email="pub@test.com"),
)
await PostCrud.create(
db_session,
PostCreate(title="Published", author_id=user1.id, is_published=True),
)
# User without posts
await UserCrud.create(
db_session,
UserCreate(username="lurker", email="lurk@test.com"),
)
# Inner join should only return user with published post
users = await UserCrud.get_multi(
db_session,
joins=[(Post, Post.author_id == User.id)],
filters=[Post.is_published == True], # noqa: E712
)
assert len(users) == 1
assert users[0].username == "publisher"
@pytest.mark.anyio
async def test_get_multi_with_outer_join(self, db_session: AsyncSession):
"""Get multiple with outer join includes all records."""
# User with post
user1 = await UserCrud.create(
db_session,
UserCreate(username="has_post", email="has@test.com"),
)
await PostCrud.create(
db_session,
PostCreate(title="My Post", author_id=user1.id),
)
# User without posts
await UserCrud.create(
db_session,
UserCreate(username="no_post", email="no@test.com"),
)
# Outer join should return both users
users = await UserCrud.get_multi(
db_session,
joins=[(Post, Post.author_id == User.id)],
outer_join=True,
)
assert len(users) == 2
@pytest.mark.anyio
async def test_count_with_join(self, db_session: AsyncSession):
"""Count with join counts correctly."""
# Create users with different post statuses
user1 = await UserCrud.create(
db_session,
UserCreate(username="active_author", email="active@test.com"),
)
await PostCrud.create(
db_session,
PostCreate(title="Published 1", author_id=user1.id, is_published=True),
)
user2 = await UserCrud.create(
db_session,
UserCreate(username="draft_author", email="draft@test.com"),
)
await PostCrud.create(
db_session,
PostCreate(title="Draft 1", author_id=user2.id, is_published=False),
)
# Count users with published posts
count = await UserCrud.count(
db_session,
filters=[Post.is_published == True], # noqa: E712
joins=[(Post, Post.author_id == User.id)],
)
assert count == 1
@pytest.mark.anyio
async def test_exists_with_join(self, db_session: AsyncSession):
"""Exists with join checks correctly."""
user = await UserCrud.create(
db_session,
UserCreate(username="poster", email="poster@test.com"),
)
await PostCrud.create(
db_session,
PostCreate(title="Exists Post", author_id=user.id, is_published=True),
)
# Check if user with published post exists
exists = await UserCrud.exists(
db_session,
filters=[Post.is_published == True], # noqa: E712
joins=[(Post, Post.author_id == User.id)],
)
assert exists is True
# Check if user with specific title exists
exists = await UserCrud.exists(
db_session,
filters=[Post.title == "Nonexistent"],
joins=[(Post, Post.author_id == User.id)],
)
assert exists is False
@pytest.mark.anyio
async def test_paginate_with_join(self, db_session: AsyncSession):
"""Paginate with join works correctly."""
# Create users with posts
for i in range(5):
user = await UserCrud.create(
db_session,
UserCreate(username=f"author{i}", email=f"author{i}@test.com"),
)
await PostCrud.create(
db_session,
PostCreate(
title=f"Post {i}",
author_id=user.id,
is_published=i % 2 == 0,
),
)
# Paginate users with published posts
result = await UserCrud.paginate(
db_session,
joins=[(Post, Post.author_id == User.id)],
filters=[Post.is_published == True], # noqa: E712
page=1,
items_per_page=10,
)
assert result.pagination.total_count == 3
assert len(result.data) == 3
@pytest.mark.anyio
async def test_paginate_with_outer_join(self, db_session: AsyncSession):
"""Paginate with outer join includes all records."""
# User with post
user1 = await UserCrud.create(
db_session,
UserCreate(username="with_post", email="with@test.com"),
)
await PostCrud.create(
db_session,
PostCreate(title="A Post", author_id=user1.id),
)
# User without post
await UserCrud.create(
db_session,
UserCreate(username="without_post", email="without@test.com"),
)
# Paginate with outer join
result = await UserCrud.paginate(
db_session,
joins=[(Post, Post.author_id == User.id)],
outer_join=True,
page=1,
items_per_page=10,
)
assert result.pagination.total_count == 2
assert len(result.data) == 2
@pytest.mark.anyio
async def test_multiple_joins(self, db_session: AsyncSession):
"""Multiple joins can be applied."""
role = await RoleCrud.create(db_session, RoleCreate(name="author_role"))
user = await UserCrud.create(
db_session,
UserCreate(
username="multi_join",
email="multi@test.com",
role_id=role.id,
),
)
await PostCrud.create(
db_session,
PostCreate(title="Multi Join Post", author_id=user.id, is_published=True),
)
# Join both Role and Post
users = await UserCrud.get_multi(
db_session,
joins=[
(Role, Role.id == User.role_id),
(Post, Post.author_id == User.id),
],
filters=[Role.name == "author_role", Post.is_published == True], # noqa: E712
)
assert len(users) == 1
assert users[0].username == "multi_join"
class TestAsResponse:
"""Tests for as_response parameter."""
@pytest.mark.anyio
async def test_create_as_response(self, db_session: AsyncSession):
"""Create with as_response=True returns Response."""
from fastapi_toolsets.schemas import Response
data = RoleCreate(name="response_role")
result = await RoleCrud.create(db_session, data, as_response=True)
assert isinstance(result, Response)
assert result.data is not None
assert result.data.name == "response_role"
@pytest.mark.anyio
async def test_get_as_response(self, db_session: AsyncSession):
"""Get with as_response=True returns Response."""
from fastapi_toolsets.schemas import Response
created = await RoleCrud.create(db_session, RoleCreate(name="get_response"))
result = await RoleCrud.get(
db_session, [Role.id == created.id], as_response=True
)
assert isinstance(result, Response)
assert result.data is not None
assert result.data.id == created.id
@pytest.mark.anyio
async def test_update_as_response(self, db_session: AsyncSession):
"""Update with as_response=True returns Response."""
from fastapi_toolsets.schemas import Response
created = await RoleCrud.create(db_session, RoleCreate(name="old_name"))
result = await RoleCrud.update(
db_session,
RoleUpdate(name="new_name"),
[Role.id == created.id],
as_response=True,
)
assert isinstance(result, Response)
assert result.data is not None
assert result.data.name == "new_name"
@pytest.mark.anyio
async def test_delete_as_response(self, db_session: AsyncSession):
"""Delete with as_response=True returns Response."""
from fastapi_toolsets.schemas import Response
created = await RoleCrud.create(db_session, RoleCreate(name="to_delete"))
result = await RoleCrud.delete(
db_session, [Role.id == created.id], as_response=True
)
assert isinstance(result, Response)
assert result.data is None
-415
View File
@@ -1,415 +0,0 @@
"""Tests for CRUD search functionality."""
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_toolsets.crud import SearchConfig, get_searchable_fields
from .conftest import (
Role,
RoleCreate,
RoleCrud,
User,
UserCreate,
UserCrud,
)
class TestPaginateSearch:
"""Tests for paginate() with search."""
@pytest.mark.anyio
async def test_search_single_column(self, db_session: AsyncSession):
"""Search on a single direct column."""
await UserCrud.create(
db_session, UserCreate(username="john_doe", email="john@test.com")
)
await UserCrud.create(
db_session, UserCreate(username="jane_doe", email="jane@test.com")
)
await UserCrud.create(
db_session, UserCreate(username="bob_smith", email="bob@test.com")
)
result = await UserCrud.paginate(
db_session,
search="doe",
search_fields=[User.username],
)
assert result.pagination.total_count == 2
@pytest.mark.anyio
async def test_search_multiple_columns(self, db_session: AsyncSession):
"""Search across multiple columns (OR logic)."""
await UserCrud.create(
db_session, UserCreate(username="alice", email="alice@company.com")
)
await UserCrud.create(
db_session, UserCreate(username="company_bob", email="bob@other.com")
)
result = await UserCrud.paginate(
db_session,
search="company",
search_fields=[User.username, User.email],
)
assert result.pagination.total_count == 2
@pytest.mark.anyio
async def test_search_relationship_depth1(self, db_session: AsyncSession):
"""Search through a relationship (depth 1)."""
admin_role = await RoleCrud.create(db_session, RoleCreate(name="administrator"))
user_role = await RoleCrud.create(db_session, RoleCreate(name="basic_user"))
await UserCrud.create(
db_session,
UserCreate(username="admin1", email="a1@test.com", role_id=admin_role.id),
)
await UserCrud.create(
db_session,
UserCreate(username="admin2", email="a2@test.com", role_id=admin_role.id),
)
await UserCrud.create(
db_session,
UserCreate(username="user1", email="u1@test.com", role_id=user_role.id),
)
result = await UserCrud.paginate(
db_session,
search="admin",
search_fields=[(User.role, Role.name)],
)
assert result.pagination.total_count == 2
@pytest.mark.anyio
async def test_search_mixed_direct_and_relation(self, db_session: AsyncSession):
"""Search combining direct columns and relationships."""
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
await UserCrud.create(
db_session,
UserCreate(username="john", email="john@test.com", role_id=role.id),
)
# Search "admin" in username OR role.name
result = await UserCrud.paginate(
db_session,
search="admin",
search_fields=[User.username, (User.role, Role.name)],
)
assert result.pagination.total_count == 1
@pytest.mark.anyio
async def test_search_case_insensitive(self, db_session: AsyncSession):
"""Search is case-insensitive by default."""
await UserCrud.create(
db_session, UserCreate(username="JohnDoe", email="j@test.com")
)
result = await UserCrud.paginate(
db_session,
search="johndoe",
search_fields=[User.username],
)
assert result.pagination.total_count == 1
@pytest.mark.anyio
async def test_search_case_sensitive(self, db_session: AsyncSession):
"""Case-sensitive search with SearchConfig."""
await UserCrud.create(
db_session, UserCreate(username="JohnDoe", email="j@test.com")
)
# Should not find (case mismatch)
result = await UserCrud.paginate(
db_session,
search=SearchConfig(query="johndoe", case_sensitive=True),
search_fields=[User.username],
)
assert result.pagination.total_count == 0
# Should find (case match)
result = await UserCrud.paginate(
db_session,
search=SearchConfig(query="JohnDoe", case_sensitive=True),
search_fields=[User.username],
)
assert result.pagination.total_count == 1
@pytest.mark.anyio
async def test_search_empty_query(self, db_session: AsyncSession):
"""Empty search returns all results."""
await UserCrud.create(
db_session, UserCreate(username="user1", email="u1@test.com")
)
await UserCrud.create(
db_session, UserCreate(username="user2", email="u2@test.com")
)
result = await UserCrud.paginate(db_session, search="")
assert result.pagination.total_count == 2
result = await UserCrud.paginate(db_session, search=None)
assert result.pagination.total_count == 2
@pytest.mark.anyio
async def test_search_with_existing_filters(self, db_session: AsyncSession):
"""Search combines with existing filters (AND)."""
await UserCrud.create(
db_session,
UserCreate(username="active_john", email="aj@test.com", is_active=True),
)
await UserCrud.create(
db_session,
UserCreate(username="inactive_john", email="ij@test.com", is_active=False),
)
result = await UserCrud.paginate(
db_session,
filters=[User.is_active == True], # noqa: E712
search="john",
search_fields=[User.username],
)
assert result.pagination.total_count == 1
assert result.data[0].username == "active_john"
@pytest.mark.anyio
async def test_search_auto_detect_fields(self, db_session: AsyncSession):
"""Auto-detect searchable fields when not specified."""
await UserCrud.create(
db_session, UserCreate(username="findme", email="other@test.com")
)
result = await UserCrud.paginate(db_session, search="findme")
assert result.pagination.total_count == 1
@pytest.mark.anyio
async def test_search_no_results(self, db_session: AsyncSession):
"""Search with no matching results."""
await UserCrud.create(
db_session, UserCreate(username="john", email="j@test.com")
)
result = await UserCrud.paginate(
db_session,
search="nonexistent",
search_fields=[User.username],
)
assert result.pagination.total_count == 0
assert result.data == []
@pytest.mark.anyio
async def test_search_with_pagination(self, db_session: AsyncSession):
"""Search respects pagination parameters."""
for i in range(15):
await UserCrud.create(
db_session,
UserCreate(username=f"user_{i}", email=f"user{i}@test.com"),
)
result = await UserCrud.paginate(
db_session,
search="user_",
search_fields=[User.username],
page=1,
items_per_page=5,
)
assert result.pagination.total_count == 15
assert len(result.data) == 5
assert result.pagination.has_more is True
@pytest.mark.anyio
async def test_search_null_relationship(self, db_session: AsyncSession):
"""Users without relationship are included (outerjoin)."""
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
await UserCrud.create(
db_session,
UserCreate(username="with_role", email="wr@test.com", role_id=role.id),
)
await UserCrud.create(
db_session,
UserCreate(username="no_role", email="nr@test.com", role_id=None),
)
# Search in username, not in role
result = await UserCrud.paginate(
db_session,
search="role",
search_fields=[User.username],
)
assert result.pagination.total_count == 2
@pytest.mark.anyio
async def test_search_with_order_by(self, db_session: AsyncSession):
"""Search works with order_by parameter."""
await UserCrud.create(
db_session, UserCreate(username="charlie", email="c@test.com")
)
await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com")
)
await UserCrud.create(
db_session, UserCreate(username="bob", email="b@test.com")
)
result = await UserCrud.paginate(
db_session,
search="@test.com",
search_fields=[User.email],
order_by=User.username,
)
assert result.pagination.total_count == 3
usernames = [u.username for u in result.data]
assert usernames == ["alice", "bob", "charlie"]
@pytest.mark.anyio
async def test_search_non_string_column(self, db_session: AsyncSession):
"""Search on non-string columns (e.g., UUID) works via cast."""
user_id = uuid.UUID("12345678-1234-5678-1234-567812345678")
await UserCrud.create(
db_session, UserCreate(id=user_id, username="john", email="john@test.com")
)
await UserCrud.create(
db_session, UserCreate(username="jane", email="jane@test.com")
)
# Search by UUID (partial match)
result = await UserCrud.paginate(
db_session,
search="12345678",
search_fields=[User.id, User.username],
)
assert result.pagination.total_count == 1
assert result.data[0].id == user_id
class TestSearchConfig:
"""Tests for SearchConfig options."""
@pytest.mark.anyio
async def test_match_mode_all(self, db_session: AsyncSession):
"""match_mode='all' requires all fields to match (AND)."""
await UserCrud.create(
db_session,
UserCreate(username="john_test", email="john_test@company.com"),
)
await UserCrud.create(
db_session,
UserCreate(username="john_other", email="other@example.com"),
)
# 'john' must be in username AND email
result = await UserCrud.paginate(
db_session,
search=SearchConfig(query="john", match_mode="all"),
search_fields=[User.username, User.email],
)
assert result.pagination.total_count == 1
assert result.data[0].username == "john_test"
@pytest.mark.anyio
async def test_search_config_with_fields(self, db_session: AsyncSession):
"""SearchConfig can specify fields directly."""
await UserCrud.create(
db_session, UserCreate(username="test", email="findme@test.com")
)
result = await UserCrud.paginate(
db_session,
search=SearchConfig(query="findme", fields=[User.email]),
)
assert result.pagination.total_count == 1
class TestNoSearchableFieldsError:
"""Tests for NoSearchableFieldsError exception."""
def test_error_is_api_exception(self):
"""NoSearchableFieldsError inherits from ApiException."""
from fastapi_toolsets.exceptions import ApiException, NoSearchableFieldsError
assert issubclass(NoSearchableFieldsError, ApiException)
def test_error_has_api_error_fields(self):
"""NoSearchableFieldsError has proper api_error configuration."""
from fastapi_toolsets.exceptions import NoSearchableFieldsError
assert NoSearchableFieldsError.api_error.code == 400
assert NoSearchableFieldsError.api_error.err_code == "SEARCH-400"
def test_error_message_contains_model_name(self):
"""Error message includes the model name."""
from fastapi_toolsets.exceptions import NoSearchableFieldsError
error = NoSearchableFieldsError(User)
assert "User" in str(error)
assert error.model is User
def test_error_raised_when_no_fields(self):
"""Error is raised when search has no searchable fields."""
from sqlalchemy import Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from fastapi_toolsets.crud.search import build_search_filters
from fastapi_toolsets.exceptions import NoSearchableFieldsError
# Model with no String columns
class NoStringBase(DeclarativeBase):
pass
class NoStringModel(NoStringBase):
__tablename__ = "no_strings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
count: Mapped[int] = mapped_column(Integer, default=0)
with pytest.raises(NoSearchableFieldsError) as exc_info:
build_search_filters(NoStringModel, "test")
assert exc_info.value.model is NoStringModel
assert "NoStringModel" in str(exc_info.value)
class TestGetSearchableFields:
"""Tests for auto-detection of searchable fields."""
def test_detects_string_columns(self):
"""Detects String columns on the model."""
fields = get_searchable_fields(User, include_relationships=False)
# Should include username and email (String), not id or is_active
field_names = [str(f) for f in fields]
assert any("username" in f for f in field_names)
assert any("email" in f for f in field_names)
assert not any("id" in f and "role_id" not in f for f in field_names)
assert not any("is_active" in f for f in field_names)
def test_detects_relationship_fields(self):
"""Detects String fields on related models."""
fields = get_searchable_fields(User, include_relationships=True)
# Should include (User.role, Role.name)
has_role_name = any(isinstance(f, tuple) and len(f) == 2 for f in fields)
assert has_role_name
def test_skips_collection_relationships(self):
"""Skips one-to-many relationships."""
fields = get_searchable_fields(Role, include_relationships=True)
# Role.users is a collection, should not be included
field_strs = [str(f) for f in fields]
assert not any("users" in f for f in field_strs)
-243
View File
@@ -1,243 +0,0 @@
"""Tests for fastapi_toolsets.db module."""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from fastapi_toolsets.db import (
LockMode,
create_db_context,
create_db_dependency,
get_transaction,
lock_tables,
)
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User
class TestCreateDbDependency:
"""Tests for create_db_dependency."""
@pytest.mark.anyio
async def test_yields_session(self):
"""Dependency yields a valid session."""
engine = create_async_engine(DATABASE_URL, echo=False)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
get_db = create_db_dependency(session_factory)
async for session in get_db():
assert isinstance(session, AsyncSession)
break
await engine.dispose()
@pytest.mark.anyio
async def test_auto_commits_transaction(self):
"""Dependency auto-commits if transaction is active."""
engine = create_async_engine(DATABASE_URL, echo=False)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
get_db = create_db_dependency(session_factory)
async for session in get_db():
role = Role(name="test_role_dep")
session.add(role)
await session.flush()
async with session_factory() as verify_session:
result = await RoleCrud.first(
verify_session, [Role.name == "test_role_dep"]
)
assert result is not None
finally:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
class TestCreateDbContext:
"""Tests for create_db_context."""
@pytest.mark.anyio
async def test_context_manager_yields_session(self):
"""Context manager yields a valid session."""
engine = create_async_engine(DATABASE_URL, echo=False)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
get_db_context = create_db_context(session_factory)
async with get_db_context() as session:
assert isinstance(session, AsyncSession)
await engine.dispose()
@pytest.mark.anyio
async def test_context_manager_commits(self):
"""Context manager commits on exit."""
engine = create_async_engine(DATABASE_URL, echo=False)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
get_db_context = create_db_context(session_factory)
async with get_db_context() as session:
role = Role(name="context_role")
session.add(role)
await session.flush()
async with session_factory() as verify_session:
result = await RoleCrud.first(
verify_session, [Role.name == "context_role"]
)
assert result is not None
finally:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
class TestGetTransaction:
"""Tests for get_transaction context manager."""
@pytest.mark.anyio
async def test_starts_transaction(self, db_session: AsyncSession):
"""get_transaction starts a new transaction."""
async with get_transaction(db_session):
role = Role(name="tx_role")
db_session.add(role)
result = await RoleCrud.first(db_session, [Role.name == "tx_role"])
assert result is not None
@pytest.mark.anyio
async def test_nested_transaction_uses_savepoint(self, db_session: AsyncSession):
"""Nested transactions use savepoints."""
async with get_transaction(db_session):
role1 = Role(name="outer_role")
db_session.add(role1)
await db_session.flush()
async with get_transaction(db_session):
role2 = Role(name="inner_role")
db_session.add(role2)
results = await RoleCrud.get_multi(db_session)
names = {r.name for r in results}
assert "outer_role" in names
assert "inner_role" in names
@pytest.mark.anyio
async def test_rollback_on_exception(self, db_session: AsyncSession):
"""Transaction rolls back on exception."""
try:
async with get_transaction(db_session):
role = Role(name="rollback_role")
db_session.add(role)
await db_session.flush()
raise ValueError("Simulated error")
except ValueError:
pass
result = await RoleCrud.first(db_session, [Role.name == "rollback_role"])
assert result is None
@pytest.mark.anyio
async def test_nested_rollback_preserves_outer(self, db_session: AsyncSession):
"""Nested rollback preserves outer transaction."""
async with get_transaction(db_session):
role1 = Role(name="preserved_role")
db_session.add(role1)
await db_session.flush()
try:
async with get_transaction(db_session):
role2 = Role(name="rolled_back_role")
db_session.add(role2)
await db_session.flush()
raise ValueError("Inner error")
except ValueError:
pass
outer = await RoleCrud.first(db_session, [Role.name == "preserved_role"])
inner = await RoleCrud.first(db_session, [Role.name == "rolled_back_role"])
assert outer is not None
assert inner is None
class TestLockMode:
"""Tests for LockMode enum."""
def test_lock_modes_exist(self):
"""All expected lock modes are defined."""
assert LockMode.ACCESS_SHARE == "ACCESS SHARE"
assert LockMode.ROW_SHARE == "ROW SHARE"
assert LockMode.ROW_EXCLUSIVE == "ROW EXCLUSIVE"
assert LockMode.SHARE_UPDATE_EXCLUSIVE == "SHARE UPDATE EXCLUSIVE"
assert LockMode.SHARE == "SHARE"
assert LockMode.SHARE_ROW_EXCLUSIVE == "SHARE ROW EXCLUSIVE"
assert LockMode.EXCLUSIVE == "EXCLUSIVE"
assert LockMode.ACCESS_EXCLUSIVE == "ACCESS EXCLUSIVE"
def test_lock_mode_is_string(self):
"""Lock modes are string enums."""
assert isinstance(LockMode.EXCLUSIVE, str)
assert LockMode.EXCLUSIVE.value == "EXCLUSIVE"
class TestLockTables:
"""Tests for lock_tables context manager (PostgreSQL-specific)."""
@pytest.mark.anyio
async def test_lock_single_table(self, db_session: AsyncSession):
"""Lock a single table."""
async with lock_tables(db_session, [Role]):
# Inside the lock, we can still perform operations
role = Role(name="locked_role")
db_session.add(role)
await db_session.flush()
# After lock is released, verify the data was committed
result = await RoleCrud.first(db_session, [Role.name == "locked_role"])
assert result is not None
@pytest.mark.anyio
async def test_lock_multiple_tables(self, db_session: AsyncSession):
"""Lock multiple tables."""
async with lock_tables(db_session, [Role, User]):
role = Role(name="multi_lock_role")
db_session.add(role)
await db_session.flush()
result = await RoleCrud.first(db_session, [Role.name == "multi_lock_role"])
assert result is not None
@pytest.mark.anyio
async def test_lock_with_custom_mode(self, db_session: AsyncSession):
"""Lock with custom lock mode."""
async with lock_tables(db_session, [Role], mode=LockMode.EXCLUSIVE):
role = Role(name="exclusive_lock_role")
db_session.add(role)
await db_session.flush()
result = await RoleCrud.first(db_session, [Role.name == "exclusive_lock_role"])
assert result is not None
@pytest.mark.anyio
async def test_lock_rollback_on_exception(self, db_session: AsyncSession):
"""Lock context rolls back on exception."""
try:
async with lock_tables(db_session, [Role]):
role = Role(name="lock_rollback_role")
db_session.add(role)
await db_session.flush()
raise ValueError("Simulated error")
except ValueError:
pass
result = await RoleCrud.first(db_session, [Role.name == "lock_rollback_role"])
assert result is None
-186
View File
@@ -1,186 +0,0 @@
"""Tests for fastapi_toolsets.dependencies module."""
import inspect
import uuid
from collections.abc import AsyncGenerator
from typing import Any, cast
import pytest
from fastapi.params import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_toolsets.dependencies import BodyDependency, PathDependency
from .conftest import Role, RoleCreate, RoleCrud, User
async def mock_get_db() -> AsyncGenerator[AsyncSession, None]:
"""Mock session dependency for testing."""
yield None
class TestPathDependency:
"""Tests for PathDependency factory."""
def test_returns_depends_instance(self):
"""PathDependency returns a Depends instance."""
dep = PathDependency(Role, Role.id, session_dep=mock_get_db)
assert isinstance(dep, Depends)
def test_signature_has_default_param_name(self):
"""PathDependency uses model_field as default param name."""
dep = cast(Any, PathDependency(Role, Role.id, session_dep=mock_get_db))
func = dep.dependency
sig = inspect.signature(func)
params = list(sig.parameters.keys())
assert "role_id" in params
assert "session" in params
def test_signature_has_correct_type_annotation(self):
"""PathDependency uses field's python type for annotation."""
dep = cast(Any, PathDependency(Role, Role.id, session_dep=mock_get_db))
func = dep.dependency
sig = inspect.signature(func)
assert sig.parameters["role_id"].annotation == uuid.UUID
assert sig.parameters["session"].annotation == AsyncSession
def test_signature_session_has_depends_default(self):
"""PathDependency session param has Depends as default."""
dep = cast(Any, PathDependency(Role, Role.id, session_dep=mock_get_db))
func = dep.dependency
sig = inspect.signature(func)
assert isinstance(sig.parameters["session"].default, Depends)
def test_custom_param_name_in_signature(self):
"""PathDependency uses custom param_name in signature."""
dep = cast(
Any,
PathDependency(
Role, Role.id, session_dep=mock_get_db, param_name="role_uuid"
),
)
func = dep.dependency
sig = inspect.signature(func)
params = list(sig.parameters.keys())
assert "role_uuid" in params
assert "id" not in params
def test_string_field_type(self):
"""PathDependency handles string field types."""
dep = cast(Any, PathDependency(User, User.username, session_dep=mock_get_db))
func = dep.dependency
sig = inspect.signature(func)
assert sig.parameters["user_username"].annotation is str
@pytest.mark.anyio
async def test_dependency_fetches_object(self, db_session):
"""PathDependency inner function fetches object from database."""
role = await RoleCrud.create(db_session, RoleCreate(name="test_role"))
dep = cast(Any, PathDependency(Role, Role.id, session_dep=mock_get_db))
func = dep.dependency
result = await func(session=db_session, role_id=role.id)
assert result.id == role.id
assert result.name == "test_role"
class TestBodyDependency:
"""Tests for BodyDependency factory."""
def test_returns_depends_instance(self):
"""BodyDependency returns a Depends instance."""
dep = BodyDependency(
Role, Role.id, session_dep=mock_get_db, body_field="role_id"
)
assert isinstance(dep, Depends)
def test_signature_has_body_field_as_param(self):
"""BodyDependency uses body_field as param name."""
dep = cast(
Any,
BodyDependency(
Role, Role.id, session_dep=mock_get_db, body_field="role_id"
),
)
func = dep.dependency
sig = inspect.signature(func)
params = list(sig.parameters.keys())
assert "role_id" in params
assert "session" in params
def test_signature_has_correct_type_annotation(self):
"""BodyDependency uses field's python type for annotation."""
dep = cast(
Any,
BodyDependency(
Role, Role.id, session_dep=mock_get_db, body_field="role_id"
),
)
func = dep.dependency
sig = inspect.signature(func)
assert sig.parameters["role_id"].annotation == uuid.UUID
assert sig.parameters["session"].annotation == AsyncSession
def test_signature_session_has_depends_default(self):
"""BodyDependency session param has Depends as default."""
dep = cast(
Any,
BodyDependency(
Role, Role.id, session_dep=mock_get_db, body_field="role_id"
),
)
func = dep.dependency
sig = inspect.signature(func)
assert isinstance(sig.parameters["session"].default, Depends)
def test_different_body_field_name(self):
"""BodyDependency can use any body_field name."""
dep = cast(
Any,
BodyDependency(
User, User.id, session_dep=mock_get_db, body_field="user_uuid"
),
)
func = dep.dependency
sig = inspect.signature(func)
params = list(sig.parameters.keys())
assert "user_uuid" in params
assert "id" not in params
@pytest.mark.anyio
async def test_dependency_fetches_object(self, db_session):
"""BodyDependency inner function fetches object from database."""
role = await RoleCrud.create(db_session, RoleCreate(name="body_test_role"))
dep = cast(
Any,
BodyDependency(
Role, Role.id, session_dep=mock_get_db, body_field="role_id"
),
)
func = dep.dependency
result = await func(session=db_session, role_id=role.id)
assert result.id == role.id
assert result.name == "body_test_role"
-265
View File
@@ -1,265 +0,0 @@
"""Tests for fastapi_toolsets.exceptions module."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from fastapi_toolsets.exceptions import (
ApiException,
ConflictError,
ForbiddenError,
NotFoundError,
UnauthorizedError,
generate_error_responses,
init_exceptions_handlers,
)
from fastapi_toolsets.schemas import ApiError
class TestApiException:
"""Tests for ApiException base class."""
def test_subclass_with_api_error(self):
"""Subclasses can define api_error."""
class CustomError(ApiException):
api_error = ApiError(
code=418,
msg="I'm a teapot",
desc="The server is a teapot.",
err_code="TEA-418",
)
error = CustomError()
assert error.api_error.code == 418
assert error.api_error.msg == "I'm a teapot"
assert str(error) == "I'm a teapot"
def test_custom_detail_message(self):
"""Custom detail overrides default message."""
class CustomError(ApiException):
api_error = ApiError(
code=400,
msg="Bad Request",
desc="Request was bad.",
err_code="BAD-400",
)
error = CustomError("Custom message")
assert str(error) == "Custom message"
class TestBuiltInExceptions:
"""Tests for built-in exception classes."""
def test_unauthorized_error(self):
"""UnauthorizedError has correct attributes."""
error = UnauthorizedError()
assert error.api_error.code == 401
assert error.api_error.err_code == "AUTH-401"
def test_forbidden_error(self):
"""ForbiddenError has correct attributes."""
error = ForbiddenError()
assert error.api_error.code == 403
assert error.api_error.err_code == "AUTH-403"
def test_not_found_error(self):
"""NotFoundError has correct attributes."""
error = NotFoundError()
assert error.api_error.code == 404
assert error.api_error.err_code == "RES-404"
def test_conflict_error(self):
"""ConflictError has correct attributes."""
error = ConflictError()
assert error.api_error.code == 409
assert error.api_error.err_code == "RES-409"
class TestGenerateErrorResponses:
"""Tests for generate_error_responses function."""
def test_generates_single_response(self):
"""Generates response for single exception."""
responses = generate_error_responses(NotFoundError)
assert 404 in responses
assert responses[404]["description"] == "Not Found"
def test_generates_multiple_responses(self):
"""Generates responses for multiple exceptions."""
responses = generate_error_responses(
UnauthorizedError,
ForbiddenError,
NotFoundError,
)
assert 401 in responses
assert 403 in responses
assert 404 in responses
def test_response_has_example(self):
"""Generated response includes example."""
responses = generate_error_responses(NotFoundError)
example = responses[404]["content"]["application/json"]["example"]
assert example["status"] == "FAIL"
assert example["error_code"] == "RES-404"
assert example["message"] == "Not Found"
class TestInitExceptionsHandlers:
"""Tests for init_exceptions_handlers function."""
def test_returns_app(self):
"""Returns the FastAPI app."""
app = FastAPI()
result = init_exceptions_handlers(app)
assert result is app
def test_handles_api_exception(self):
"""Handles ApiException with structured response."""
app = FastAPI()
init_exceptions_handlers(app)
@app.get("/error")
async def raise_error():
raise NotFoundError()
client = TestClient(app)
response = client.get("/error")
assert response.status_code == 404
data = response.json()
assert data["status"] == "FAIL"
assert data["error_code"] == "RES-404"
assert data["message"] == "Not Found"
def test_handles_validation_error(self):
"""Handles validation errors with structured response."""
from pydantic import BaseModel
app = FastAPI()
init_exceptions_handlers(app)
class Item(BaseModel):
name: str
price: float
@app.post("/items")
async def create_item(item: Item):
return item
client = TestClient(app)
response = client.post("/items", json={"name": 123})
assert response.status_code == 422
data = response.json()
assert data["status"] == "FAIL"
assert data["error_code"] == "VAL-422"
assert "errors" in data["data"]
def test_handles_generic_exception(self):
"""Handles unhandled exceptions with 500 response."""
app = FastAPI()
init_exceptions_handlers(app)
@app.get("/crash")
async def crash():
raise RuntimeError("Something went wrong")
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/crash")
assert response.status_code == 500
data = response.json()
assert data["status"] == "FAIL"
assert data["error_code"] == "SERVER-500"
def test_custom_openapi_schema(self):
"""Customizes OpenAPI schema for 422 responses."""
app = FastAPI()
init_exceptions_handlers(app)
from pydantic import BaseModel
class Item(BaseModel):
name: str
@app.post("/items")
async def create_item(item: Item):
return item
openapi = app.openapi()
post_op = openapi["paths"]["/items"]["post"]
assert "422" in post_op["responses"]
resp_422 = post_op["responses"]["422"]
example = resp_422["content"]["application/json"]["example"]
assert example["error_code"] == "VAL-422"
class TestExceptionIntegration:
"""Integration tests for exception handling."""
@pytest.fixture
def app_with_routes(self):
"""Create app with test routes."""
app = FastAPI()
init_exceptions_handlers(app)
@app.get("/users/{user_id}")
async def get_user(user_id: int):
if user_id == 404:
raise NotFoundError()
if user_id == 401:
raise UnauthorizedError()
if user_id == 403:
raise ForbiddenError()
if user_id == 409:
raise ConflictError()
return {"id": user_id}
return app
def test_not_found_response(self, app_with_routes):
"""NotFoundError returns 404."""
client = TestClient(app_with_routes)
response = client.get("/users/404")
assert response.status_code == 404
assert response.json()["error_code"] == "RES-404"
def test_unauthorized_response(self, app_with_routes):
"""UnauthorizedError returns 401."""
client = TestClient(app_with_routes)
response = client.get("/users/401")
assert response.status_code == 401
assert response.json()["error_code"] == "AUTH-401"
def test_forbidden_response(self, app_with_routes):
"""ForbiddenError returns 403."""
client = TestClient(app_with_routes)
response = client.get("/users/403")
assert response.status_code == 403
assert response.json()["error_code"] == "AUTH-403"
def test_conflict_response(self, app_with_routes):
"""ConflictError returns 409."""
client = TestClient(app_with_routes)
response = client.get("/users/409")
assert response.status_code == 409
assert response.json()["error_code"] == "RES-409"
def test_success_response(self, app_with_routes):
"""Successful requests return normally."""
client = TestClient(app_with_routes)
response = client.get("/users/1")
assert response.status_code == 200
assert response.json() == {"id": 1}
-754
View File
@@ -1,754 +0,0 @@
"""Tests for fastapi_toolsets.fixtures module."""
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_toolsets.fixtures import (
Context,
FixtureRegistry,
LoadStrategy,
get_obj_by_attr,
load_fixtures,
load_fixtures_by_context,
)
from .conftest import Role, User
class TestContext:
"""Tests for Context enum."""
def test_base_context(self):
"""BASE context has correct value."""
assert Context.BASE.value == "base"
def test_production_context(self):
"""PRODUCTION context has correct value."""
assert Context.PRODUCTION.value == "production"
def test_development_context(self):
"""DEVELOPMENT context has correct value."""
assert Context.DEVELOPMENT.value == "development"
def test_testing_context(self):
"""TESTING context has correct value."""
assert Context.TESTING.value == "testing"
class TestLoadStrategy:
"""Tests for LoadStrategy enum."""
def test_insert_strategy(self):
"""INSERT strategy has correct value."""
assert LoadStrategy.INSERT.value == "insert"
def test_merge_strategy(self):
"""MERGE strategy has correct value."""
assert LoadStrategy.MERGE.value == "merge"
def test_skip_existing_strategy(self):
"""SKIP_EXISTING strategy has correct value."""
assert LoadStrategy.SKIP_EXISTING.value == "skip_existing"
class TestFixtureRegistry:
"""Tests for FixtureRegistry class."""
def test_register_with_decorator(self):
"""Register fixture with decorator."""
registry = FixtureRegistry()
role_id = uuid.uuid4()
@registry.register
def roles():
return [Role(id=role_id, name="admin")]
assert "roles" in [f.name for f in registry.get_all()]
def test_register_with_custom_name(self):
"""Register fixture with custom name."""
registry = FixtureRegistry()
role_id = uuid.uuid4()
@registry.register(name="custom_roles")
def roles():
return [Role(id=role_id, name="admin")]
fixture = registry.get("custom_roles")
assert fixture.name == "custom_roles"
def test_register_with_dependencies(self):
"""Register fixture with dependencies."""
registry = FixtureRegistry()
role_id = uuid.uuid4()
user_id = uuid.uuid4()
@registry.register
def roles():
return [Role(id=role_id, name="admin")]
@registry.register(depends_on=["roles"])
def users():
return [
User(
id=user_id,
username="admin",
email="admin@test.com",
role_id=role_id,
)
]
fixture = registry.get("users")
assert fixture.depends_on == ["roles"]
def test_register_with_contexts(self):
"""Register fixture with contexts."""
registry = FixtureRegistry()
role_id = uuid.uuid4()
@registry.register(contexts=[Context.TESTING])
def test_data():
return [Role(id=role_id, name="test")]
fixture = registry.get("test_data")
assert Context.TESTING.value in fixture.contexts
def test_get_raises_key_error(self):
"""Get raises KeyError for missing fixture."""
registry = FixtureRegistry()
with pytest.raises(KeyError, match="not found"):
registry.get("nonexistent")
def test_get_all(self):
"""Get all registered fixtures."""
registry = FixtureRegistry()
@registry.register
def fixture1():
return []
@registry.register
def fixture2():
return []
fixtures = registry.get_all()
names = {f.name for f in fixtures}
assert names == {"fixture1", "fixture2"}
def test_get_by_context(self):
"""Get fixtures by context."""
registry = FixtureRegistry()
@registry.register(contexts=[Context.BASE])
def base_data():
return []
@registry.register(contexts=[Context.TESTING])
def test_data():
return []
@registry.register(contexts=[Context.PRODUCTION])
def prod_data():
return []
testing_fixtures = registry.get_by_context(Context.TESTING)
names = {f.name for f in testing_fixtures}
assert names == {"test_data"}
class TestIncludeRegistry:
"""Tests for FixtureRegistry.include_registry method."""
def test_include_empty_registry(self):
"""Include an empty registry does nothing."""
main_registry = FixtureRegistry()
other_registry = FixtureRegistry()
@main_registry.register
def roles():
return []
main_registry.include_registry(other_registry)
assert len(main_registry.get_all()) == 1
def test_include_registry_adds_fixtures(self):
"""Include registry adds all fixtures from the other registry."""
main_registry = FixtureRegistry()
other_registry = FixtureRegistry()
@main_registry.register
def roles():
return []
@other_registry.register
def users():
return []
@other_registry.register
def posts():
return []
main_registry.include_registry(other_registry)
names = {f.name for f in main_registry.get_all()}
assert names == {"roles", "users", "posts"}
def test_include_registry_preserves_dependencies(self):
"""Include registry preserves fixture dependencies."""
main_registry = FixtureRegistry()
other_registry = FixtureRegistry()
@main_registry.register
def roles():
return []
@other_registry.register(depends_on=["roles"])
def users():
return []
main_registry.include_registry(other_registry)
fixture = main_registry.get("users")
assert fixture.depends_on == ["roles"]
def test_include_registry_preserves_contexts(self):
"""Include registry preserves fixture contexts."""
main_registry = FixtureRegistry()
other_registry = FixtureRegistry()
@other_registry.register(contexts=[Context.TESTING, Context.DEVELOPMENT])
def test_data():
return []
main_registry.include_registry(other_registry)
fixture = main_registry.get("test_data")
assert Context.TESTING.value in fixture.contexts
assert Context.DEVELOPMENT.value in fixture.contexts
def test_include_registry_raises_on_duplicate(self):
"""Include registry raises ValueError on duplicate fixture names."""
main_registry = FixtureRegistry()
other_registry = FixtureRegistry()
@main_registry.register(name="roles")
def roles_main():
return []
@other_registry.register(name="roles")
def roles_other():
return []
with pytest.raises(ValueError, match="already exists"):
main_registry.include_registry(other_registry)
def test_include_multiple_registries(self):
"""Include multiple registries sequentially."""
main_registry = FixtureRegistry()
dev_registry = FixtureRegistry()
test_registry = FixtureRegistry()
@main_registry.register
def base():
return []
@dev_registry.register
def dev_data():
return []
@test_registry.register
def test_data():
return []
main_registry.include_registry(dev_registry)
main_registry.include_registry(test_registry)
names = {f.name for f in main_registry.get_all()}
assert names == {"base", "dev_data", "test_data"}
class TestDefaultContexts:
"""Tests for FixtureRegistry default contexts."""
def test_default_contexts_applied_to_fixtures(self):
"""Default contexts are applied when no contexts specified."""
registry = FixtureRegistry(contexts=[Context.TESTING])
@registry.register
def test_data():
return []
fixture = registry.get("test_data")
assert fixture.contexts == [Context.TESTING.value]
def test_explicit_contexts_override_default(self):
"""Explicit contexts override default contexts."""
registry = FixtureRegistry(contexts=[Context.TESTING])
@registry.register(contexts=[Context.PRODUCTION])
def prod_data():
return []
fixture = registry.get("prod_data")
assert fixture.contexts == [Context.PRODUCTION.value]
def test_no_default_contexts_uses_base(self):
"""Without default contexts, BASE is used."""
registry = FixtureRegistry()
@registry.register
def data():
return []
fixture = registry.get("data")
assert fixture.contexts == [Context.BASE.value]
def test_multiple_default_contexts(self):
"""Multiple default contexts are applied."""
registry = FixtureRegistry(contexts=[Context.DEVELOPMENT, Context.TESTING])
@registry.register
def dev_test_data():
return []
fixture = registry.get("dev_test_data")
assert Context.DEVELOPMENT.value in fixture.contexts
assert Context.TESTING.value in fixture.contexts
def test_default_contexts_with_string_values(self):
"""Default contexts work with string values."""
registry = FixtureRegistry(contexts=["custom_context"])
@registry.register
def custom_data():
return []
fixture = registry.get("custom_data")
assert fixture.contexts == ["custom_context"]
class TestDependencyResolution:
"""Tests for fixture dependency resolution."""
def test_resolve_simple_dependency(self):
"""Resolve simple dependency chain."""
registry = FixtureRegistry()
@registry.register
def roles():
return []
@registry.register(depends_on=["roles"])
def users():
return []
order = registry.resolve_dependencies("users")
assert order == ["roles", "users"]
def test_resolve_multiple_dependencies(self):
"""Resolve multiple dependencies."""
registry = FixtureRegistry()
@registry.register
def roles():
return []
@registry.register
def permissions():
return []
@registry.register(depends_on=["roles", "permissions"])
def users():
return []
order = registry.resolve_dependencies("users")
assert "roles" in order
assert "permissions" in order
assert order.index("roles") < order.index("users")
assert order.index("permissions") < order.index("users")
def test_resolve_transitive_dependencies(self):
"""Resolve transitive dependencies."""
registry = FixtureRegistry()
@registry.register
def base():
return []
@registry.register(depends_on=["base"])
def middle():
return []
@registry.register(depends_on=["middle"])
def top():
return []
order = registry.resolve_dependencies("top")
assert order == ["base", "middle", "top"]
def test_detect_circular_dependency(self):
"""Detect circular dependencies."""
registry = FixtureRegistry()
@registry.register(depends_on=["b"])
def a():
return []
@registry.register(depends_on=["a"])
def b():
return []
with pytest.raises(ValueError, match="Circular dependency"):
registry.resolve_dependencies("a")
def test_resolve_context_dependencies(self):
"""Resolve all fixtures for a context with dependencies."""
registry = FixtureRegistry()
@registry.register(contexts=[Context.BASE])
def roles():
return []
@registry.register(depends_on=["roles"], contexts=[Context.TESTING])
def test_users():
return []
order = registry.resolve_context_dependencies(Context.BASE, Context.TESTING)
assert "roles" in order
assert "test_users" in order
assert order.index("roles") < order.index("test_users")
class TestLoadFixtures:
"""Tests for load_fixtures function."""
@pytest.mark.anyio
async def test_load_single_fixture(self, db_session: AsyncSession):
"""Load a single fixture."""
registry = FixtureRegistry()
role_id_1 = uuid.uuid4()
role_id_2 = uuid.uuid4()
@registry.register
def roles():
return [
Role(id=role_id_1, name="admin"),
Role(id=role_id_2, name="user"),
]
result = await load_fixtures(db_session, registry, "roles")
assert "roles" in result
assert len(result["roles"]) == 2
from .conftest import RoleCrud
count = await RoleCrud.count(db_session)
assert count == 2
@pytest.mark.anyio
async def test_load_with_dependencies(self, db_session: AsyncSession):
"""Load fixtures with dependencies."""
registry = FixtureRegistry()
role_id = uuid.uuid4()
user_id = uuid.uuid4()
@registry.register
def roles():
return [Role(id=role_id, name="admin")]
@registry.register(depends_on=["roles"])
def users():
return [
User(
id=user_id,
username="admin",
email="admin@test.com",
role_id=role_id,
)
]
result = await load_fixtures(db_session, registry, "users")
assert "roles" in result
assert "users" in result
from .conftest import RoleCrud, UserCrud
assert await RoleCrud.count(db_session) == 1
assert await UserCrud.count(db_session) == 1
@pytest.mark.anyio
async def test_load_with_merge_strategy(self, db_session: AsyncSession):
"""Load fixtures with MERGE strategy updates existing."""
registry = FixtureRegistry()
role_id = uuid.uuid4()
@registry.register
def roles():
return [Role(id=role_id, name="admin")]
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
from .conftest import RoleCrud
count = await RoleCrud.count(db_session)
assert count == 1
@pytest.mark.anyio
async def test_load_with_skip_existing_strategy(self, db_session: AsyncSession):
"""Load fixtures with SKIP_EXISTING strategy."""
registry = FixtureRegistry()
role_id = uuid.uuid4()
@registry.register
def roles():
return [Role(id=role_id, name="original")]
await load_fixtures(
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
)
@registry.register(name="roles_updated")
def roles_v2():
return [Role(id=role_id, name="updated")]
registry._fixtures["roles"] = registry._fixtures.pop("roles_updated")
await load_fixtures(
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
)
from .conftest import RoleCrud
role = await RoleCrud.first(db_session, [Role.id == role_id])
assert role is not None
assert role.name == "original"
@pytest.mark.anyio
async def test_load_with_insert_strategy(self, db_session: AsyncSession):
"""Load fixtures with INSERT strategy."""
registry = FixtureRegistry()
role_id_1 = uuid.uuid4()
role_id_2 = uuid.uuid4()
@registry.register
def roles():
return [
Role(id=role_id_1, name="admin"),
Role(id=role_id_2, name="user"),
]
result = await load_fixtures(
db_session, registry, "roles", strategy=LoadStrategy.INSERT
)
assert "roles" in result
assert len(result["roles"]) == 2
from .conftest import RoleCrud
count = await RoleCrud.count(db_session)
assert count == 2
@pytest.mark.anyio
async def test_load_empty_fixture(self, db_session: AsyncSession):
"""Load a fixture that returns an empty list."""
registry = FixtureRegistry()
@registry.register
def empty_roles():
return []
result = await load_fixtures(db_session, registry, "empty_roles")
assert "empty_roles" in result
assert result["empty_roles"] == []
@pytest.mark.anyio
async def test_load_multiple_fixtures_without_dependencies(
self, db_session: AsyncSession
):
"""Load multiple independent fixtures."""
registry = FixtureRegistry()
role_id_1 = uuid.uuid4()
role_id_2 = uuid.uuid4()
@registry.register
def roles():
return [Role(id=role_id_1, name="admin")]
@registry.register
def other_roles():
return [Role(id=role_id_2, name="user")]
result = await load_fixtures(db_session, registry, "roles", "other_roles")
assert "roles" in result
assert "other_roles" in result
from .conftest import RoleCrud
count = await RoleCrud.count(db_session)
assert count == 2
class TestLoadFixturesByContext:
"""Tests for load_fixtures_by_context function."""
@pytest.mark.anyio
async def test_load_by_single_context(self, db_session: AsyncSession):
"""Load fixtures by single context."""
registry = FixtureRegistry()
base_role_id = uuid.uuid4()
test_role_id = uuid.uuid4()
@registry.register(contexts=[Context.BASE])
def base_roles():
return [Role(id=base_role_id, name="base_role")]
@registry.register(contexts=[Context.TESTING])
def test_roles():
return [Role(id=test_role_id, name="test_role")]
await load_fixtures_by_context(db_session, registry, Context.BASE)
from .conftest import RoleCrud
count = await RoleCrud.count(db_session)
assert count == 1
role = await RoleCrud.first(db_session, [Role.id == base_role_id])
assert role is not None
assert role.name == "base_role"
@pytest.mark.anyio
async def test_load_by_multiple_contexts(self, db_session: AsyncSession):
"""Load fixtures by multiple contexts."""
registry = FixtureRegistry()
base_role_id = uuid.uuid4()
test_role_id = uuid.uuid4()
@registry.register(contexts=[Context.BASE])
def base_roles():
return [Role(id=base_role_id, name="base_role")]
@registry.register(contexts=[Context.TESTING])
def test_roles():
return [Role(id=test_role_id, name="test_role")]
await load_fixtures_by_context(
db_session, registry, Context.BASE, Context.TESTING
)
from .conftest import RoleCrud
count = await RoleCrud.count(db_session)
assert count == 2
@pytest.mark.anyio
async def test_load_context_with_dependencies(self, db_session: AsyncSession):
"""Load context fixtures with cross-context dependencies."""
registry = FixtureRegistry()
role_id = uuid.uuid4()
user_id = uuid.uuid4()
@registry.register(contexts=[Context.BASE])
def roles():
return [Role(id=role_id, name="admin")]
@registry.register(depends_on=["roles"], contexts=[Context.TESTING])
def test_users():
return [
User(
id=user_id,
username="tester",
email="test@test.com",
role_id=role_id,
)
]
await load_fixtures_by_context(db_session, registry, Context.TESTING)
from .conftest import RoleCrud, UserCrud
assert await RoleCrud.count(db_session) == 1
assert await UserCrud.count(db_session) == 1
class TestGetObjByAttr:
"""Tests for get_obj_by_attr helper function."""
def setup_method(self):
"""Set up test fixtures for each test."""
self.registry = FixtureRegistry()
self.role_id_1 = uuid.uuid4()
self.role_id_2 = uuid.uuid4()
self.role_id_3 = uuid.uuid4()
self.user_id_1 = uuid.uuid4()
self.user_id_2 = uuid.uuid4()
role_id_1 = self.role_id_1
role_id_2 = self.role_id_2
role_id_3 = self.role_id_3
user_id_1 = self.user_id_1
user_id_2 = self.user_id_2
@self.registry.register
def roles() -> list[Role]:
return [
Role(id=role_id_1, name="admin"),
Role(id=role_id_2, name="user"),
Role(id=role_id_3, name="moderator"),
]
@self.registry.register(depends_on=["roles"])
def users() -> list[User]:
return [
User(
id=user_id_1,
username="alice",
email="alice@example.com",
role_id=role_id_1,
),
User(
id=user_id_2,
username="bob",
email="bob@example.com",
role_id=role_id_1,
),
]
self.roles = roles
self.users = users
def test_get_by_id(self):
"""Get an object by its id attribute."""
role = get_obj_by_attr(self.roles, "id", self.role_id_1)
assert role.name == "admin"
def test_get_user_by_username(self):
"""Get a user by username."""
user = get_obj_by_attr(self.users, "username", "bob")
assert user.id == self.user_id_2
assert user.email == "bob@example.com"
def test_returns_first_match(self):
"""Returns the first matching object when multiple could match."""
user = get_obj_by_attr(self.users, "role_id", self.role_id_1)
assert user.username == "alice"
def test_no_match_raises_stop_iteration(self):
"""Raises StopIteration when no object matches."""
with pytest.raises(StopIteration):
get_obj_by_attr(self.roles, "name", "nonexistent")
def test_no_match_on_wrong_value_type(self):
"""Raises StopIteration when value type doesn't match."""
with pytest.raises(StopIteration):
get_obj_by_attr(self.roles, "id", "not-a-uuid")
-118
View File
@@ -1,118 +0,0 @@
import logging
import sys
import pytest
from fastapi_toolsets.logger import (
DEFAULT_FORMAT,
UVICORN_LOGGERS,
configure_logging,
get_logger,
)
@pytest.fixture(autouse=True)
def _reset_loggers():
"""Reset the root and uvicorn loggers after each test."""
yield
root = logging.getLogger()
root.handlers.clear()
root.setLevel(logging.WARNING)
for name in UVICORN_LOGGERS:
uv = logging.getLogger(name)
uv.handlers.clear()
uv.setLevel(logging.NOTSET)
class TestConfigureLogging:
def test_sets_up_handler_and_format(self):
logger = configure_logging()
assert len(logger.handlers) == 1
handler = logger.handlers[0]
assert isinstance(handler, logging.StreamHandler)
assert handler.stream is sys.stdout
assert handler.formatter is not None
assert handler.formatter._fmt == DEFAULT_FORMAT
def test_default_level_is_info(self):
logger = configure_logging()
assert logger.level == logging.INFO
def test_custom_level_string(self):
logger = configure_logging(level="DEBUG")
assert logger.level == logging.DEBUG
def test_custom_level_int(self):
logger = configure_logging(level=logging.WARNING)
assert logger.level == logging.WARNING
def test_custom_format(self):
custom_fmt = "%(levelname)s: %(message)s"
logger = configure_logging(fmt=custom_fmt)
handler = logger.handlers[0]
assert handler.formatter is not None
assert handler.formatter._fmt == custom_fmt
def test_named_logger(self):
logger = configure_logging(logger_name="myapp")
assert logger.name == "myapp"
assert len(logger.handlers) == 1
def test_default_configures_root_logger(self):
logger = configure_logging()
assert logger is logging.getLogger()
def test_idempotent_no_duplicate_handlers(self):
configure_logging()
configure_logging()
logger = configure_logging()
assert len(logger.handlers) == 1
def test_configures_uvicorn_loggers(self):
configure_logging(level="DEBUG")
for name in UVICORN_LOGGERS:
uv_logger = logging.getLogger(name)
assert len(uv_logger.handlers) == 1
assert uv_logger.level == logging.DEBUG
handler = uv_logger.handlers[0]
assert handler.formatter is not None
assert handler.formatter._fmt == DEFAULT_FORMAT
def test_returns_configured_logger(self):
logger = configure_logging(logger_name="test.return")
assert isinstance(logger, logging.Logger)
assert logger.name == "test.return"
class TestGetLogger:
def test_returns_named_logger(self):
logger = get_logger("myapp.services")
assert isinstance(logger, logging.Logger)
assert logger.name == "myapp.services"
def test_returns_root_logger_when_none(self):
logger = get_logger(None)
assert logger is logging.getLogger()
def test_defaults_to_caller_module_name(self):
logger = get_logger()
assert logger.name == __name__
def test_same_name_returns_same_logger(self):
a = get_logger("myapp")
b = get_logger("myapp")
assert a is b
-293
View File
@@ -1,293 +0,0 @@
"""Tests for fastapi_toolsets.pytest module."""
import uuid
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from fastapi_toolsets.fixtures import Context, FixtureRegistry
from fastapi_toolsets.pytest import (
create_async_client,
create_db_session,
register_fixtures,
)
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User, UserCrud
test_registry = FixtureRegistry()
# Fixed UUIDs for test fixtures to allow consistent assertions
ROLE_ADMIN_ID = uuid.UUID("00000000-0000-0000-0000-000000001000")
ROLE_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000001001")
USER_ADMIN_ID = uuid.UUID("00000000-0000-0000-0000-000000002000")
USER_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000002001")
USER_EXTRA_ID = uuid.UUID("00000000-0000-0000-0000-000000002002")
@test_registry.register(contexts=[Context.BASE])
def roles() -> list[Role]:
return [
Role(id=ROLE_ADMIN_ID, name="plugin_admin"),
Role(id=ROLE_USER_ID, name="plugin_user"),
]
@test_registry.register(depends_on=["roles"], contexts=[Context.BASE])
def users() -> list[User]:
return [
User(
id=USER_ADMIN_ID,
username="plugin_admin",
email="padmin@test.com",
role_id=ROLE_ADMIN_ID,
),
User(
id=USER_USER_ID,
username="plugin_user",
email="puser@test.com",
role_id=ROLE_USER_ID,
),
]
@test_registry.register(depends_on=["users"], contexts=[Context.TESTING])
def extra_users() -> list[User]:
return [
User(
id=USER_EXTRA_ID,
username="plugin_extra",
email="pextra@test.com",
role_id=ROLE_USER_ID,
),
]
register_fixtures(test_registry, globals())
class TestRegisterFixtures:
"""Tests for register_fixtures function."""
def test_creates_fixtures_in_namespace(self):
"""Fixtures are created in the namespace."""
assert "fixture_roles" in globals()
assert "fixture_users" in globals()
assert "fixture_extra_users" in globals()
def test_fixtures_are_callable(self):
"""Created fixtures are callable."""
assert callable(globals()["fixture_roles"])
assert callable(globals()["fixture_users"])
class TestGeneratedFixtures:
"""Tests for the generated pytest fixtures."""
@pytest.mark.anyio
async def test_fixture_loads_data(
self, db_session: AsyncSession, fixture_roles: list[Role]
):
"""Fixture loads data into database and returns it."""
assert len(fixture_roles) == 2
assert fixture_roles[0].name == "plugin_admin"
assert fixture_roles[1].name == "plugin_user"
# Verify data is in database
count = await RoleCrud.count(db_session)
assert count == 2
@pytest.mark.anyio
async def test_fixture_with_dependency(
self, db_session: AsyncSession, fixture_users: list[User]
):
"""Fixture with dependency loads parent fixture first."""
# fixture_users depends on fixture_roles
# Both should be loaded
assert len(fixture_users) == 2
# Roles should also be in database
roles_count = await RoleCrud.count(db_session)
assert roles_count == 2
# Users should be in database
users_count = await UserCrud.count(db_session)
assert users_count == 2
@pytest.mark.anyio
async def test_fixture_returns_models(
self, db_session: AsyncSession, fixture_users: list[User]
):
"""Fixture returns actual model instances."""
user = fixture_users[0]
assert isinstance(user, User)
assert user.id == USER_ADMIN_ID
assert user.username == "plugin_admin"
@pytest.mark.anyio
async def test_fixture_relationships_work(
self, db_session: AsyncSession, fixture_users: list[User]
):
"""Loaded fixtures have working relationships."""
# Load user with role relationship
user = await UserCrud.get(
db_session,
[User.id == USER_ADMIN_ID],
load_options=[selectinload(User.role)],
)
assert user.role is not None
assert user.role.name == "plugin_admin"
@pytest.mark.anyio
async def test_chained_dependencies(
self, db_session: AsyncSession, fixture_extra_users: list[User]
):
"""Chained dependencies are resolved correctly."""
# fixture_extra_users -> fixture_users -> fixture_roles
assert len(fixture_extra_users) == 1
# All fixtures should be loaded
roles_count = await RoleCrud.count(db_session)
users_count = await UserCrud.count(db_session)
assert roles_count == 2
assert users_count == 3 # 2 from users + 1 from extra_users
@pytest.mark.anyio
async def test_can_query_loaded_data(
self, db_session: AsyncSession, fixture_users: list[User]
):
"""Can query the loaded fixture data."""
# Get all users loaded by fixture
users = await UserCrud.get_multi(
db_session,
order_by=User.username,
)
assert len(users) == 2
assert users[0].username == "plugin_admin"
assert users[1].username == "plugin_user"
@pytest.mark.anyio
async def test_multiple_fixtures_in_same_test(
self,
db_session: AsyncSession,
fixture_roles: list[Role],
fixture_users: list[User],
):
"""Multiple fixtures can be used in the same test."""
assert len(fixture_roles) == 2
assert len(fixture_users) == 2
# Both should be in database
roles = await RoleCrud.get_multi(db_session)
users = await UserCrud.get_multi(db_session)
assert len(roles) == 2
assert len(users) == 2
class TestCreateAsyncClient:
"""Tests for create_async_client helper."""
@pytest.mark.anyio
async def test_creates_working_client(self):
"""Client can make requests to the app."""
app = FastAPI()
@app.get("/health")
async def health():
return {"status": "ok"}
async with create_async_client(app) as client:
assert isinstance(client, AsyncClient)
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
@pytest.mark.anyio
async def test_custom_base_url(self):
"""Client uses custom base URL."""
app = FastAPI()
@app.get("/test")
async def test_endpoint():
return {"url": "test"}
async with create_async_client(app, base_url="http://custom") as client:
assert str(client.base_url) == "http://custom"
@pytest.mark.anyio
async def test_client_closes_properly(self):
"""Client is properly closed after context exit."""
app = FastAPI()
async with create_async_client(app) as client:
client_ref = client
assert client_ref.is_closed
class TestCreateDbSession:
"""Tests for create_db_session helper."""
@pytest.mark.anyio
async def test_creates_working_session(self):
"""Session can perform database operations."""
role_id = uuid.uuid4()
async with create_db_session(DATABASE_URL, Base) as session:
assert isinstance(session, AsyncSession)
role = Role(id=role_id, name="test_helper_role")
session.add(role)
await session.commit()
result = await session.execute(select(Role).where(Role.id == role_id))
fetched = result.scalar_one()
assert fetched.name == "test_helper_role"
@pytest.mark.anyio
async def test_tables_created_before_session(self):
"""Tables exist when session is yielded."""
async with create_db_session(DATABASE_URL, Base) as session:
# Should not raise - tables exist
result = await session.execute(select(Role))
assert result.all() == []
@pytest.mark.anyio
async def test_tables_dropped_after_session(self):
"""Tables are dropped after session closes when drop_tables=True."""
role_id = uuid.uuid4()
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
role = Role(id=role_id, name="will_be_dropped")
session.add(role)
await session.commit()
# Verify tables were dropped by creating new session
async with create_db_session(DATABASE_URL, Base) as session:
result = await session.execute(select(Role))
assert result.all() == []
@pytest.mark.anyio
async def test_tables_preserved_when_drop_disabled(self):
"""Tables are preserved when drop_tables=False."""
role_id = uuid.uuid4()
async with create_db_session(DATABASE_URL, Base, drop_tables=False) as session:
role = Role(id=role_id, name="preserved_role")
session.add(role)
await session.commit()
# Create another session without dropping
async with create_db_session(DATABASE_URL, Base, drop_tables=False) as session:
result = await session.execute(select(Role).where(Role.id == role_id))
fetched = result.scalar_one_or_none()
assert fetched is not None
assert fetched.name == "preserved_role"
# Cleanup: drop tables manually
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as _:
pass
-284
View File
@@ -1,284 +0,0 @@
"""Tests for fastapi_toolsets.schemas module."""
import pytest
from pydantic import ValidationError
from fastapi_toolsets.schemas import (
ApiError,
ErrorResponse,
PaginatedResponse,
Pagination,
Response,
ResponseStatus,
)
class TestResponseStatus:
"""Tests for ResponseStatus enum."""
def test_success_value(self):
"""SUCCESS has correct value."""
assert ResponseStatus.SUCCESS.value == "SUCCESS"
def test_fail_value(self):
"""FAIL has correct value."""
assert ResponseStatus.FAIL.value == "FAIL"
def test_is_string_enum(self):
"""ResponseStatus is a string enum."""
assert isinstance(ResponseStatus.SUCCESS, str)
class TestApiError:
"""Tests for ApiError schema."""
def test_create_api_error(self):
"""Create ApiError with all fields."""
error = ApiError(
code=404,
msg="Not Found",
desc="The resource was not found.",
err_code="RES-404",
)
assert error.code == 404
assert error.msg == "Not Found"
assert error.desc == "The resource was not found."
assert error.err_code == "RES-404"
def test_requires_all_fields(self):
"""ApiError requires all fields."""
with pytest.raises(ValidationError):
ApiError(code=404, msg="Not Found") # type: ignore
class TestResponse:
"""Tests for Response schema."""
def test_create_with_data(self):
"""Create Response with data."""
response = Response(data={"id": 1, "name": "test"})
assert response.data == {"id": 1, "name": "test"}
assert response.status == ResponseStatus.SUCCESS
assert response.message == "Success"
assert response.error_code is None
def test_create_with_custom_message(self):
"""Create Response with custom message."""
response = Response(data="result", message="Operation completed")
assert response.message == "Operation completed"
def test_create_with_none_data(self):
"""Create Response with None data."""
response = Response[dict](data=None)
assert response.data is None
assert response.status == ResponseStatus.SUCCESS
def test_generic_type_hint(self):
"""Response supports generic type hints."""
response: Response[list[str]] = Response(data=["a", "b", "c"])
assert response.data == ["a", "b", "c"]
def test_serialization(self):
"""Response serializes correctly."""
response = Response(data={"key": "value"}, message="Test")
data = response.model_dump()
assert data["status"] == "SUCCESS"
assert data["message"] == "Test"
assert data["data"] == {"key": "value"}
assert data["error_code"] is None
class TestErrorResponse:
"""Tests for ErrorResponse schema."""
def test_default_values(self):
"""ErrorResponse has correct defaults."""
response = ErrorResponse()
assert response.status == ResponseStatus.FAIL
assert response.data is None
def test_with_description(self):
"""ErrorResponse with description."""
response = ErrorResponse(
message="Bad Request",
description="The request was invalid.",
error_code="BAD-400",
)
assert response.message == "Bad Request"
assert response.description == "The request was invalid."
assert response.error_code == "BAD-400"
def test_serialization(self):
"""ErrorResponse serializes correctly."""
response = ErrorResponse(
message="Error",
description="Details",
error_code="ERR-500",
)
data = response.model_dump()
assert data["status"] == "FAIL"
assert data["description"] == "Details"
class TestPagination:
"""Tests for Pagination schema."""
def test_create_pagination(self):
"""Create Pagination with all fields."""
pagination = Pagination(
total_count=100,
items_per_page=10,
page=1,
has_more=True,
)
assert pagination.total_count == 100
assert pagination.items_per_page == 10
assert pagination.page == 1
assert pagination.has_more is True
def test_last_page_has_more_false(self):
"""Last page has has_more=False."""
pagination = Pagination(
total_count=25,
items_per_page=10,
page=3,
has_more=False,
)
assert pagination.has_more is False
def test_serialization(self):
"""Pagination serializes correctly."""
pagination = Pagination(
total_count=50,
items_per_page=20,
page=2,
has_more=True,
)
data = pagination.model_dump()
assert data["total_count"] == 50
assert data["items_per_page"] == 20
assert data["page"] == 2
assert data["has_more"] is True
class TestPaginatedResponse:
"""Tests for PaginatedResponse schema."""
def test_create_paginated_response(self):
"""Create PaginatedResponse with data and pagination."""
pagination = Pagination(
total_count=30,
items_per_page=10,
page=1,
has_more=True,
)
response = PaginatedResponse(
data=[{"id": 1}, {"id": 2}],
pagination=pagination,
)
assert len(response.data) == 2
assert response.pagination.total_count == 30
assert response.status == ResponseStatus.SUCCESS
def test_with_custom_message(self):
"""PaginatedResponse with custom message."""
pagination = Pagination(
total_count=5,
items_per_page=10,
page=1,
has_more=False,
)
response = PaginatedResponse(
data=[1, 2, 3, 4, 5],
pagination=pagination,
message="Found 5 items",
)
assert response.message == "Found 5 items"
def test_empty_data(self):
"""PaginatedResponse with empty data."""
pagination = Pagination(
total_count=0,
items_per_page=10,
page=1,
has_more=False,
)
response = PaginatedResponse[dict](
data=[],
pagination=pagination,
)
assert response.data == []
assert response.pagination.total_count == 0
def test_generic_type_hint(self):
"""PaginatedResponse supports generic type hints."""
class UserOut:
id: int
name: str
pagination = Pagination(
total_count=1,
items_per_page=10,
page=1,
has_more=False,
)
response: PaginatedResponse[dict] = PaginatedResponse(
data=[{"id": 1, "name": "test"}],
pagination=pagination,
)
assert response.data[0]["id"] == 1
def test_serialization(self):
"""PaginatedResponse serializes correctly."""
pagination = Pagination(
total_count=100,
items_per_page=10,
page=5,
has_more=True,
)
response = PaginatedResponse(
data=["item1", "item2"],
pagination=pagination,
message="Page 5",
)
data = response.model_dump()
assert data["status"] == "SUCCESS"
assert data["message"] == "Page 5"
assert data["data"] == ["item1", "item2"]
assert data["pagination"]["page"] == 5
class TestFromAttributes:
"""Tests for from_attributes config (ORM mode)."""
def test_response_from_orm_object(self):
"""Response can accept ORM-like objects."""
class FakeOrmObject:
def __init__(self):
self.id = 1
self.name = "test"
obj = FakeOrmObject()
response = Response(data=obj)
assert response.data.id == 1 # type: ignore
assert response.data.name == "test" # type: ignore
Generated
-804
View File
@@ -1,804 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" },
{ url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" },
{ url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" },
{ url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" },
{ url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" },
{ url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/09/1ac74e37cf45f17eb41e11a21854f7f92a4c2d6c6098ef4a1becb0c6d8d3/coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73", size = 219276, upload-time = "2026-02-03T14:00:00.296Z" },
{ url = "https://files.pythonhosted.org/packages/2e/cb/71908b08b21beb2c437d0d5870c4ec129c570ca1b386a8427fcdb11cf89c/coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00", size = 219776, upload-time = "2026-02-03T14:00:02.414Z" },
{ url = "https://files.pythonhosted.org/packages/09/85/c4f3dd69232887666a2c0394d4be21c60ea934d404db068e6c96aa59cd87/coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2", size = 250196, upload-time = "2026-02-03T14:00:04.197Z" },
{ url = "https://files.pythonhosted.org/packages/9c/cc/560ad6f12010344d0778e268df5ba9aa990aacccc310d478bf82bf3d302c/coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c", size = 252111, upload-time = "2026-02-03T14:00:05.639Z" },
{ url = "https://files.pythonhosted.org/packages/f0/66/3193985fb2c58e91f94cfbe9e21a6fdf941e9301fe2be9e92c072e9c8f8c/coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b", size = 254217, upload-time = "2026-02-03T14:00:07.738Z" },
{ url = "https://files.pythonhosted.org/packages/c5/78/f0f91556bf1faa416792e537c523c5ef9db9b1d32a50572c102b3d7c45b3/coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0", size = 250318, upload-time = "2026-02-03T14:00:09.224Z" },
{ url = "https://files.pythonhosted.org/packages/6f/aa/fc654e45e837d137b2c1f3a2cc09b4aea1e8b015acd2f774fa0f3d2ddeba/coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14", size = 251909, upload-time = "2026-02-03T14:00:10.712Z" },
{ url = "https://files.pythonhosted.org/packages/73/4d/ab53063992add8a9ca0463c9d92cce5994a29e17affd1c2daa091b922a93/coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4", size = 249971, upload-time = "2026-02-03T14:00:12.402Z" },
{ url = "https://files.pythonhosted.org/packages/29/25/83694b81e46fcff9899694a1b6f57573429cdd82b57932f09a698f03eea5/coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad", size = 249692, upload-time = "2026-02-03T14:00:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ef/d68fc304301f4cb4bf6aefa0045310520789ca38dabdfba9dbecd3f37919/coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222", size = 250597, upload-time = "2026-02-03T14:00:15.461Z" },
{ url = "https://files.pythonhosted.org/packages/8d/85/240ad396f914df361d0f71e912ddcedb48130c71b88dc4193fe3c0306f00/coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb", size = 221773, upload-time = "2026-02-03T14:00:17.462Z" },
{ url = "https://files.pythonhosted.org/packages/2f/71/165b3a6d3d052704a9ab52d11ea64ef3426745de517dda44d872716213a7/coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301", size = 222711, upload-time = "2026-02-03T14:00:19.449Z" },
{ url = "https://files.pythonhosted.org/packages/51/d0/0ddc9c5934cdd52639c5df1f1eb0fdab51bb52348f3a8d1c7db9c600d93a/coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba", size = 221377, upload-time = "2026-02-03T14:00:20.968Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" },
{ url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" },
{ url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" },
{ url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" },
{ url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" },
{ url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" },
{ url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" },
{ url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" },
{ url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" },
{ url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" },
{ url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" },
{ url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" },
{ url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" },
{ url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" },
{ url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" },
{ url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" },
{ url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" },
{ url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" },
{ url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" },
{ url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" },
{ url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" },
{ url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" },
{ url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" },
{ url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" },
{ url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" },
{ url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" },
{ url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" },
{ url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" },
{ url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" },
{ url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" },
{ url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" },
{ url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" },
{ url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" },
{ url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" },
{ url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" },
{ url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" },
{ url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" },
{ url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" },
{ url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" },
{ url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" },
{ url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" },
{ url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" },
{ url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" },
{ url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" },
{ url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" },
{ url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" },
{ url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" },
{ url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "fastapi"
version = "0.128.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/59/28bde150415783ff084334e3de106eb7461a57864cf69f343950ad5a5ddd/fastapi-0.128.1.tar.gz", hash = "sha256:ce5be4fa26d4ce6f54debcc873d1fb8e0e248f5c48d7502ba6c61457ab2dc766", size = 374260, upload-time = "2026-02-04T17:35:10.542Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/08/3953db1979ea131c68279b997c6465080118b407f0800445b843f8e164b3/fastapi-0.128.1-py3-none-any.whl", hash = "sha256:ee82146bbf91ea5bbf2bb8629e4c6e056c4fbd997ea6068501b11b15260b50fb", size = 103810, upload-time = "2026-02-04T17:35:08.02Z" },
]
[[package]]
name = "fastapi-toolsets"
version = "0.7.0"
source = { editable = "." }
dependencies = [
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "typer" },
]
[package.optional-dependencies]
dev = [
{ name = "coverage" },
{ name = "pytest" },
{ name = "pytest-anyio" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "ty" },
]
test = [
{ name = "coverage" },
{ name = "pytest" },
{ name = "pytest-anyio" },
{ name = "pytest-cov" },
]
[package.metadata]
requires-dist = [
{ name = "asyncpg", specifier = ">=0.29.0" },
{ name = "coverage", marker = "extra == 'test'", specifier = ">=7.0.0" },
{ name = "fastapi", specifier = ">=0.100.0" },
{ name = "fastapi-toolsets", extras = ["test"], marker = "extra == 'dev'" },
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pytest", marker = "extra == 'test'", 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 = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
{ name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a0" },
{ name = "typer", specifier = ">=0.9.0" },
]
provides-extras = ["test", "dev"]
[[package]]
name = "greenlet"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" },
{ url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" },
{ url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" },
{ url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" },
{ url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" },
{ url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462, upload-time = "2026-01-23T15:36:50.422Z" },
{ url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715, upload-time = "2026-01-23T15:33:17.298Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" },
{ url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" },
{ url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" },
{ url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" },
{ url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" },
{ url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
{ url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" },
{ url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" },
{ url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" },
{ url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" },
{ url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" },
{ url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" },
{ url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" },
{ url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" },
{ url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" },
{ url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" },
{ url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" },
{ url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" },
{ url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" },
{ url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" },
{ url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" },
{ url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },
{ 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 = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
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" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
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" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
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" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
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" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
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" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-anyio"
version = "0.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/44/a02e5877a671b0940f21a7a0d9704c22097b123ed5cdbcca9cab39f17acc/pytest-anyio-0.0.0.tar.gz", hash = "sha256:b41234e9e9ad7ea1dbfefcc1d6891b23d5ef7c9f07ccf804c13a9cc338571fd3", size = 1560, upload-time = "2021-06-29T22:57:30.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/25/bd6493ae85d0a281b6a0f248d0fdb1d9aa2b31f18bcd4a8800cf397d8209/pytest_anyio-0.0.0-py2.py3-none-any.whl", hash = "sha256:dc8b5c4741cb16ff90be37fddd585ca943ed12bbeb563de7ace6cd94441d8746", size = 1999, upload-time = "2021-06-29T22:57:29.158Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "rich"
version = "14.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" },
]
[[package]]
name = "ruff"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" },
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
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" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.46"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/ac/b42ad16800d0885105b59380ad69aad0cce5a65276e269ce2729a2343b6a/sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684", size = 2154851, upload-time = "2026-01-21T18:27:30.54Z" },
{ url = "https://files.pythonhosted.org/packages/a0/60/d8710068cb79f64d002ebed62a7263c00c8fd95f4ebd4b5be8f7ca93f2bc/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62", size = 3311241, upload-time = "2026-01-21T18:32:33.45Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/20c71487c7219ab3aa7421c7c62d93824c97c1460f2e8bb72404b0192d13/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f", size = 3310741, upload-time = "2026-01-21T18:44:57.887Z" },
{ url = "https://files.pythonhosted.org/packages/65/80/d26d00b3b249ae000eee4db206fcfc564bf6ca5030e4747adf451f4b5108/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7568fe771f974abadce52669ef3a03150ff03186d8eb82613bc8adc435a03f01", size = 3263116, upload-time = "2026-01-21T18:32:35.044Z" },
{ url = "https://files.pythonhosted.org/packages/da/ee/74dda7506640923821340541e8e45bd3edd8df78664f1f2e0aae8077192b/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf7e1e78af38047e08836d33502c7a278915698b7c2145d045f780201679999", size = 3285327, upload-time = "2026-01-21T18:44:59.254Z" },
{ url = "https://files.pythonhosted.org/packages/9f/25/6dcf8abafff1389a21c7185364de145107b7394ecdcb05233815b236330d/sqlalchemy-2.0.46-cp311-cp311-win32.whl", hash = "sha256:9d80ea2ac519c364a7286e8d765d6cd08648f5b21ca855a8017d9871f075542d", size = 2114564, upload-time = "2026-01-21T18:33:15.85Z" },
{ url = "https://files.pythonhosted.org/packages/93/5f/e081490f8523adc0088f777e4ebad3cac21e498ec8a3d4067074e21447a1/sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl", hash = "sha256:585af6afe518732d9ccd3aea33af2edaae4a7aa881af5d8f6f4fe3a368699597", size = 2139233, upload-time = "2026-01-21T18:33:17.528Z" },
{ url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" },
{ url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" },
{ url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" },
{ url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" },
{ url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" },
{ url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" },
{ url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" },
{ url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" },
{ url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" },
{ url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" },
{ url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" },
{ url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" },
{ url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" },
{ url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" },
{ url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" },
{ url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" },
{ url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" },
{ url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" },
{ url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" },
{ url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" },
{ url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" },
{ url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" },
]
[package.optional-dependencies]
asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "starlette"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
name = "tomli"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
{ url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
{ url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
]
[[package]]
name = "ty"
version = "0.0.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/57/22c3d6bf95c2229120c49ffc2f0da8d9e8823755a1c3194da56e51f1cc31/ty-0.0.14.tar.gz", hash = "sha256:a691010565f59dd7f15cf324cdcd1d9065e010c77a04f887e1ea070ba34a7de2", size = 5036573, upload-time = "2026-01-27T00:57:31.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/cb/cc6d1d8de59beb17a41f9a614585f884ec2d95450306c173b3b7cc090d2e/ty-0.0.14-py3-none-linux_armv6l.whl", hash = "sha256:32cf2a7596e693094621d3ae568d7ee16707dce28c34d1762947874060fdddaa", size = 10034228, upload-time = "2026-01-27T00:57:53.133Z" },
{ url = "https://files.pythonhosted.org/packages/f3/96/dd42816a2075a8f31542296ae687483a8d047f86a6538dfba573223eaf9a/ty-0.0.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f971bf9805f49ce8c0968ad53e29624d80b970b9eb597b7cbaba25d8a18ce9a2", size = 9939162, upload-time = "2026-01-27T00:57:43.857Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b4/73c4859004e0f0a9eead9ecb67021438b2e8e5fdd8d03e7f5aca77623992/ty-0.0.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:45448b9e4806423523268bc15e9208c4f3f2ead7c344f615549d2e2354d6e924", size = 9418661, upload-time = "2026-01-27T00:58:03.411Z" },
{ url = "https://files.pythonhosted.org/packages/58/35/839c4551b94613db4afa20ee555dd4f33bfa7352d5da74c5fa416ffa0fd2/ty-0.0.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee94a9b747ff40114085206bdb3205a631ef19a4d3fb89e302a88754cbbae54c", size = 9837872, upload-time = "2026-01-27T00:57:23.718Z" },
{ url = "https://files.pythonhosted.org/packages/41/2b/bbecf7e2faa20c04bebd35fc478668953ca50ee5847ce23e08acf20ea119/ty-0.0.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6756715a3c33182e9ab8ffca2bb314d3c99b9c410b171736e145773ee0ae41c3", size = 9848819, upload-time = "2026-01-27T00:57:58.501Z" },
{ url = "https://files.pythonhosted.org/packages/be/60/3c0ba0f19c0f647ad9d2b5b5ac68c0f0b4dc899001bd53b3a7537fb247a2/ty-0.0.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89d0038a2f698ba8b6fec5cf216a4e44e2f95e4a5095a8c0f57fe549f87087c2", size = 10324371, upload-time = "2026-01-27T00:57:29.291Z" },
{ url = "https://files.pythonhosted.org/packages/24/32/99d0a0b37d0397b0a989ffc2682493286aa3bc252b24004a6714368c2c3d/ty-0.0.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c64a83a2d669b77f50a4957039ca1450626fb474619f18f6f8a3eb885bf7544", size = 10865898, upload-time = "2026-01-27T00:57:33.542Z" },
{ url = "https://files.pythonhosted.org/packages/1a/88/30b583a9e0311bb474269cfa91db53350557ebec09002bfc3fb3fc364e8c/ty-0.0.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242488bfb547ef080199f6fd81369ab9cb638a778bb161511d091ffd49c12129", size = 10555777, upload-time = "2026-01-27T00:58:05.853Z" },
{ url = "https://files.pythonhosted.org/packages/cd/a2/cb53fb6325dcf3d40f2b1d0457a25d55bfbae633c8e337bde8ec01a190eb/ty-0.0.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4790c3866f6c83a4f424fc7d09ebdb225c1f1131647ba8bdc6fcdc28f09ed0ff", size = 10412913, upload-time = "2026-01-27T00:57:38.834Z" },
{ url = "https://files.pythonhosted.org/packages/42/8f/f2f5202d725ed1e6a4e5ffaa32b190a1fe70c0b1a2503d38515da4130b4c/ty-0.0.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:950f320437f96d4ea9a2332bbfb5b68f1c1acd269ebfa4c09b6970cc1565bd9d", size = 9837608, upload-time = "2026-01-27T00:57:55.898Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ba/59a2a0521640c489dafa2c546ae1f8465f92956fede18660653cce73b4c5/ty-0.0.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a0ec3ee70d83887f86925bbc1c56f4628bd58a0f47f6f32ddfe04e1f05466df", size = 9884324, upload-time = "2026-01-27T00:57:46.786Z" },
{ url = "https://files.pythonhosted.org/packages/03/95/8d2a49880f47b638743212f011088552ecc454dd7a665ddcbdabea25772a/ty-0.0.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1a4e6b6da0c58b34415955279eff754d6206b35af56a18bb70eb519d8d139ef", size = 10033537, upload-time = "2026-01-27T00:58:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/e9/40/4523b36f2ce69f92ccf783855a9e0ebbbd0f0bb5cdce6211ee1737159ed3/ty-0.0.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dc04384e874c5de4c5d743369c277c8aa73d1edea3c7fc646b2064b637db4db3", size = 10495910, upload-time = "2026-01-27T00:57:26.691Z" },
{ url = "https://files.pythonhosted.org/packages/08/d5/655beb51224d1bfd4f9ddc0bb209659bfe71ff141bcf05c418ab670698f0/ty-0.0.14-py3-none-win32.whl", hash = "sha256:b20e22cf54c66b3e37e87377635da412d9a552c9bf4ad9fc449fed8b2e19dad2", size = 9507626, upload-time = "2026-01-27T00:57:41.43Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d9/c569c9961760e20e0a4bc008eeb1415754564304fd53997a371b7cf3f864/ty-0.0.14-py3-none-win_amd64.whl", hash = "sha256:e312ff9475522d1a33186657fe74d1ec98e4a13e016d66f5758a452c90ff6409", size = 10437980, upload-time = "2026-01-27T00:57:36.422Z" },
{ url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" },
]
[[package]]
name = "typer"
version = "0.21.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
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" },
]
+1264
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+29
View File
@@ -0,0 +1,29 @@
-------------------------------------------------------------------------------
Third-Party licenses
-------------------------------------------------------------------------------
Package: clipboard@2.0.11
License: MIT
Copyright: Zeno Rocha
-------------------------------------------------------------------------------
Package: escape-html@1.0.3
License: MIT
Copyright: 2012-2013 TJ Holowaychuk
2015 Andreas Lubbe
2015 Tiancheng "Timothy" Gu
-------------------------------------------------------------------------------
Package: focus-visible@5.2.1
License: W3C
Copyright: WICG
-------------------------------------------------------------------------------
Package: rxjs@7.8.2
License: Apache-2.0
Copyright: 2015-2018 Google, Inc.,
2015-2018 Netflix, Inc.,
2015-2018 Microsoft Corp. and contributors
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+1574
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+7
View 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 %}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+69
View File
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/examples/pagination-search/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/module/cli/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/module/crud/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/module/db/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/module/dependencies/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/module/exceptions/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/module/fixtures/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/module/logger/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/module/metrics/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/module/pytest/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/module/schemas/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/reference/cli/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/reference/crud/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/reference/db/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/reference/dependencies/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/reference/exceptions/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/reference/fixtures/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/reference/logger/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/reference/metrics/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/reference/pytest/</loc>
</url>
<url>
<loc>https://fastapi-toolsets.d3vyce.fr/v1.3/reference/schemas/</loc>
</url>
</urlset>
+1477
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+29
View File
@@ -0,0 +1,29 @@
-------------------------------------------------------------------------------
Third-Party licenses
-------------------------------------------------------------------------------
Package: clipboard@2.0.11
License: MIT
Copyright: Zeno Rocha
-------------------------------------------------------------------------------
Package: escape-html@1.0.3
License: MIT
Copyright: 2012-2013 TJ Holowaychuk
2015 Andreas Lubbe
2015 Tiancheng "Timothy" Gu
-------------------------------------------------------------------------------
Package: focus-visible@5.2.1
License: W3C
Copyright: WICG
-------------------------------------------------------------------------------
Package: rxjs@7.8.2
License: Apache-2.0
Copyright: 2015-2018 Google, Inc.,
2015-2018 Netflix, Inc.,
2015-2018 Microsoft Corp. and contributors
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+1788
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More