commit 762ed35341b8694352912bfab155432b79562135 Author: d3vyce Date: Sun Jan 25 16:11:44 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09be0ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,207 @@ +# 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__/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..79ece0b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf90055 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# 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. + +[![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 for fixtures and custom commands +- **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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..17053cb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,81 @@ +[project] +name = "fastapi-toolsets" +version = "0.1.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", + "Framework :: SQLAlchemy", + "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", + "Topic :: Database", + "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] +fastapi-toolsets = "fastapi_toolsets.cli:app" + +[build-system] +requires = ["uv_build>=0.9.26,<0.10.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", +] diff --git a/src/fastapi_toolsets/__init__.py b/src/fastapi_toolsets/__init__.py new file mode 100644 index 0000000..936cc64 --- /dev/null +++ b/src/fastapi_toolsets/__init__.py @@ -0,0 +1,24 @@ +"""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.1.0" diff --git a/src/fastapi_toolsets/cli/__init__.py b/src/fastapi_toolsets/cli/__init__.py new file mode 100644 index 0000000..2e76e66 --- /dev/null +++ b/src/fastapi_toolsets/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI for FastAPI projects.""" + +from .app import app, register_command + +__all__ = ["app", "register_command"] diff --git a/src/fastapi_toolsets/cli/app.py b/src/fastapi_toolsets/cli/app.py new file mode 100644 index 0000000..03ea393 --- /dev/null +++ b/src/fastapi_toolsets/cli/app.py @@ -0,0 +1,95 @@ +"""Main CLI application.""" + +import importlib.util +import sys +from pathlib import Path +from typing import Annotated + +import typer + +from .commands import fixtures + +app = typer.Typer( + name="fastapi-utils", + help="CLI utilities for FastAPI projects.", + no_args_is_help=True, +) + +# Register built-in commands +app.add_typer(fixtures.app, name="fixtures") + + +def register_command(command: typer.Typer, name: str) -> None: + """Register a custom command group. + + Args: + command: Typer app for the command group + name: Name for the command group + + Example: + # In your project's cli.py: + import typer + from fastapi_toolsets.cli import app, register_command + + my_commands = typer.Typer() + + @my_commands.command() + def seed(): + '''Seed the database.''' + ... + + register_command(my_commands, "db") + # Now available as: fastapi-utils db seed + """ + app.add_typer(command, name=name) + + +@app.callback() +def main( + ctx: typer.Context, + config: Annotated[ + Path | None, + typer.Option( + "--config", + "-c", + help="Path to project config file (Python module with fixtures registry).", + envvar="FASTAPI_TOOLSETS_CONFIG", + ), + ] = None, +) -> None: + """FastAPI utilities CLI.""" + ctx.ensure_object(dict) + + if config: + ctx.obj["config_path"] = config + # Load the config module + config_module = _load_module_from_path(config) + ctx.obj["config_module"] = config_module + + +def _load_module_from_path(path: Path) -> object: + """Load a Python module from a file path. + + Handles both absolute and relative imports by adding the config's + parent directory to sys.path temporarily. + """ + path = path.resolve() + + # Add the parent directory to sys.path to support relative imports + parent_dir = str(path.parent.parent) # Go up two levels (e.g., from app/cli_config.py to project root) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + + # Also add immediate parent for direct module imports + immediate_parent = str(path.parent) + if immediate_parent not in sys.path: + sys.path.insert(0, immediate_parent) + + spec = importlib.util.spec_from_file_location("config", path) + if spec is None or spec.loader is None: + raise typer.BadParameter(f"Cannot load module from {path}") + + module = importlib.util.module_from_spec(spec) + sys.modules["config"] = module + spec.loader.exec_module(module) + return module diff --git a/src/fastapi_toolsets/cli/commands/__init__.py b/src/fastapi_toolsets/cli/commands/__init__.py new file mode 100644 index 0000000..b762eaa --- /dev/null +++ b/src/fastapi_toolsets/cli/commands/__init__.py @@ -0,0 +1 @@ +"""Built-in CLI commands.""" diff --git a/src/fastapi_toolsets/cli/commands/fixtures.py b/src/fastapi_toolsets/cli/commands/fixtures.py new file mode 100644 index 0000000..7e2219a --- /dev/null +++ b/src/fastapi_toolsets/cli/commands/fixtures.py @@ -0,0 +1,211 @@ +"""Fixture management commands.""" + +import asyncio +from typing import Annotated + +import typer + +from ...fixtures import Context, FixtureRegistry, LoadStrategy, load_fixtures_by_context + +app = typer.Typer( + name="fixtures", + help="Manage database fixtures.", + no_args_is_help=True, +) + + +def _get_registry(ctx: typer.Context) -> FixtureRegistry: + """Get fixture registry from context.""" + config = ctx.obj.get("config_module") if ctx.obj else None + if config is None: + raise typer.BadParameter( + "No config provided. Use --config to specify a config file with a 'fixtures' registry." + ) + + registry = getattr(config, "fixtures", None) + if registry is None: + raise typer.BadParameter( + "Config module must have a 'fixtures' attribute (FixtureRegistry instance)." + ) + + 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(ctx: typer.Context): + """Get database context manager from config.""" + config = ctx.obj.get("config_module") if ctx.obj else None + if config is None: + raise typer.BadParameter("No config provided.") + + get_db_context = getattr(config, "get_db_context", None) + if get_db_context is None: + raise typer.BadParameter( + "Config module must have a 'get_db_context' function." + ) + + return get_db_context + + +@app.command("list") +def list_fixtures( + ctx: typer.Context, + context: Annotated[ + str | None, + typer.Option("--context", "-c", help="Filter by context (base, production, development, testing)."), + ] = None, +) -> None: + """List all registered fixtures.""" + registry = _get_registry(ctx) + + if context: + fixtures = registry.get_by_context(context) + else: + fixtures = registry.get_all() + + if not fixtures: + typer.echo("No fixtures found.") + return + + typer.echo(f"\n{'Name':<30} {'Contexts':<30} {'Dependencies'}") + typer.echo("-" * 80) + + for fixture in fixtures: + contexts = ", ".join(fixture.contexts) + deps = ", ".join(fixture.depends_on) if fixture.depends_on else "-" + typer.echo(f"{fixture.name:<30} {contexts:<30} {deps}") + + typer.echo(f"\nTotal: {len(fixtures)} fixture(s)") + + +@app.command("graph") +def show_graph( + ctx: typer.Context, + fixture_name: Annotated[ + str | None, + typer.Argument(help="Show dependencies for a specific fixture."), + ] = None, +) -> None: + """Show fixture dependency graph.""" + registry = _get_registry(ctx) + + if fixture_name: + try: + order = registry.resolve_dependencies(fixture_name) + typer.echo(f"\nDependency chain for '{fixture_name}':\n") + for i, name in enumerate(order): + indent = " " * i + arrow = "└─> " if i > 0 else "" + typer.echo(f"{indent}{arrow}{name}") + except KeyError: + typer.echo(f"Fixture '{fixture_name}' not found.", err=True) + raise typer.Exit(1) + else: + # Show full graph + fixtures = registry.get_all() + + typer.echo("\nFixture Dependency Graph:\n") + for fixture in fixtures: + deps = f" -> [{', '.join(fixture.depends_on)}]" if fixture.depends_on else "" + typer.echo(f" {fixture.name}{deps}") + + +@app.command("load") +def load( + ctx: typer.Context, + contexts: Annotated[ + list[str] | None, + typer.Argument(help="Contexts to load (base, production, development, testing)."), + ] = None, + strategy: Annotated[ + str, + typer.Option("--strategy", "-s", help="Load strategy: merge, insert, skip_existing."), + ] = "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_registry(ctx) + get_db_context = _get_db_context(ctx) + + # Parse contexts + if contexts: + context_list = contexts + else: + context_list = [Context.BASE] + + # Parse strategy + try: + load_strategy = LoadStrategy(strategy) + except ValueError: + typer.echo(f"Invalid strategy: {strategy}. Use: merge, insert, skip_existing", err=True) + raise typer.Exit(1) + + # Resolve what will be loaded + ordered = registry.resolve_context_dependencies(*context_list) + + if not ordered: + typer.echo("No fixtures to load for the specified context(s).") + return + + typer.echo(f"\nFixtures to load ({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 "?" + typer.echo(f" - {name}: {len(instances)} {model_name}(s)") + + if dry_run: + typer.echo("\n[Dry run - no changes made]") + return + + typer.echo("\nLoading...") + + async def do_load(): + async with get_db_context() as session: + result = await load_fixtures_by_context( + session, registry, *context_list, strategy=load_strategy + ) + return result + + result = asyncio.run(do_load()) + + total = sum(len(items) for items in result.values()) + typer.echo(f"\nLoaded {total} record(s) successfully.") + + +@app.command("show") +def show_fixture( + ctx: typer.Context, + name: Annotated[str, typer.Argument(help="Fixture name to show.")], +) -> None: + """Show details of a specific fixture.""" + registry = _get_registry(ctx) + + try: + fixture = registry.get(name) + except KeyError: + typer.echo(f"Fixture '{name}' not found.", err=True) + raise typer.Exit(1) + + typer.echo(f"\nFixture: {fixture.name}") + typer.echo(f"Contexts: {', '.join(fixture.contexts)}") + typer.echo(f"Dependencies: {', '.join(fixture.depends_on) if fixture.depends_on else 'None'}") + + # Show instances + instances = list(fixture.func()) + if instances: + model_name = type(instances[0]).__name__ + typer.echo(f"\nInstances ({len(instances)} {model_name}):") + for instance in instances[:10]: # Limit to 10 + typer.echo(f" - {instance!r}") + if len(instances) > 10: + typer.echo(f" ... and {len(instances) - 10} more") + else: + typer.echo("\nNo instances (empty fixture)") diff --git a/src/fastapi_toolsets/crud.py b/src/fastapi_toolsets/crud.py new file mode 100644 index 0000000..5c54eb9 --- /dev/null +++ b/src/fastapi_toolsets/crud.py @@ -0,0 +1,378 @@ +"""Generic async CRUD operations for SQLAlchemy models.""" + +from collections.abc import Sequence +from typing import Any, ClassVar, Generic, Self, TypeVar, cast + +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 + +__all__ = [ + "AsyncCrud", + "CrudFactory", +] + +ModelType = TypeVar("ModelType", bound=DeclarativeBase) + + +class AsyncCrud(Generic[ModelType]): + """Generic async CRUD operations for SQLAlchemy models. + + Subclass this and set the `model` class variable, or use `CrudFactory`. + + Example: + class UserCrud(AsyncCrud[User]): + model = User + + # Or use the factory: + UserCrud = CrudFactory(User) + + # Then use it: + user = await UserCrud.get(session, [User.id == 1]) + users = await UserCrud.get_multi(session, limit=10) + """ + + model: ClassVar[type[DeclarativeBase]] + + @classmethod + async def create( + cls: type[Self], + session: AsyncSession, + obj: BaseModel, + ) -> ModelType: + """Create a new record in the database. + + Args: + session: DB async session + obj: Pydantic model with data to create + + Returns: + Created model instance + """ + async with get_transaction(session): + db_model = cls.model(**obj.model_dump()) + session.add(db_model) + await session.refresh(db_model) + return cast(ModelType, db_model) + + @classmethod + async def get( + cls: type[Self], + session: AsyncSession, + filters: list[Any], + *, + with_for_update: bool = False, + load_options: list[Any] | None = None, + ) -> ModelType: + """Get exactly one record. Raises NotFoundError if not found. + + Args: + session: DB async session + filters: List of SQLAlchemy filter conditions + with_for_update: Lock the row for update + load_options: SQLAlchemy loader options (e.g., selectinload) + + Returns: + Model instance + + Raises: + NotFoundError: If no record found + MultipleResultsFound: If more than one record found + """ + q = select(cls.model).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() + return cast(ModelType, item) + + @classmethod + async def first( + cls: type[Self], + session: AsyncSession, + filters: list[Any] | None = None, + *, + 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 + load_options: SQLAlchemy loader options + + Returns: + Model instance or None + """ + q = select(cls.model) + 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, + 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 + 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 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()) + + @classmethod + async def update( + cls: type[Self], + session: AsyncSession, + obj: BaseModel, + filters: list[Any], + *, + exclude_unset: bool = True, + exclude_none: bool = False, + ) -> 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 + + Returns: + Updated model instance + + 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) + 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) + + @classmethod + async def delete( + cls: type[Self], + session: AsyncSession, + filters: list[Any], + ) -> bool: + """Delete records from the database. + + Args: + session: DB async session + filters: List of SQLAlchemy filter conditions + + Returns: + True if deletion was executed + """ + async with get_transaction(session): + q = sql_delete(cls.model).where(and_(*filters)) + await session.execute(q) + return True + + @classmethod + async def count( + cls: type[Self], + session: AsyncSession, + filters: list[Any] | None = None, + ) -> int: + """Count records matching the filters. + + Args: + session: DB async session + filters: List of SQLAlchemy filter conditions + + Returns: + Number of matching records + """ + q = select(func.count()).select_from(cls.model) + 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], + ) -> bool: + """Check if a record exists. + + Args: + session: DB async session + filters: List of SQLAlchemy filter conditions + + Returns: + True if at least one record matches + """ + q = select(cls.model).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, + load_options: list[Any] | None = None, + order_by: Any | None = None, + page: int = 1, + items_per_page: int = 20, + ) -> dict[str, Any]: + """Get paginated results with metadata. + + Args: + session: DB async session + filters: List of SQLAlchemy filter conditions + 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 + + Returns: + Dict with 'data' and 'pagination' keys + """ + filters = filters or [] + offset = (page - 1) * items_per_page + + items = await cls.get_multi( + session, + filters=filters, + load_options=load_options, + order_by=order_by, + limit=items_per_page, + offset=offset, + ) + + total_count = await cls.count(session, filters=filters) + + return { + "data": items, + "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], +) -> type[AsyncCrud[ModelType]]: + """Create a CRUD class for a specific model. + + Args: + model: SQLAlchemy model class + + 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) + + # Usage + user = await UserCrud.get(session, [User.id == 1]) + posts = await PostCrud.get_multi(session, filters=[Post.user_id == user.id]) + """ + cls = type(f"Async{model.__name__}Crud", (AsyncCrud,), {"model": model}) + return cast(type[AsyncCrud[ModelType]], cls) diff --git a/src/fastapi_toolsets/db.py b/src/fastapi_toolsets/db.py new file mode 100644 index 0000000..d60987d --- /dev/null +++ b/src/fastapi_toolsets/db.py @@ -0,0 +1,175 @@ +"""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 diff --git a/src/fastapi_toolsets/exceptions/__init__.py b/src/fastapi_toolsets/exceptions/__init__.py new file mode 100644 index 0000000..490175f --- /dev/null +++ b/src/fastapi_toolsets/exceptions/__init__.py @@ -0,0 +1,19 @@ +from .exceptions import ( + ApiException, + ConflictError, + ForbiddenError, + NotFoundError, + UnauthorizedError, + generate_error_responses, +) +from .handler import init_exceptions_handlers + +__all__ = [ + "init_exceptions_handlers", + "generate_error_responses", + "ApiException", + "ConflictError", + "ForbiddenError", + "NotFoundError", + "UnauthorizedError", +] diff --git a/src/fastapi_toolsets/exceptions/exceptions.py b/src/fastapi_toolsets/exceptions/exceptions.py new file mode 100644 index 0000000..9a625dd --- /dev/null +++ b/src/fastapi_toolsets/exceptions/exceptions.py @@ -0,0 +1,166 @@ +"""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", + ) + + +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 diff --git a/src/fastapi_toolsets/exceptions/handler.py b/src/fastapi_toolsets/exceptions/handler.py new file mode 100644 index 0000000..6ede049 --- /dev/null +++ b/src/fastapi_toolsets/exceptions/handler.py @@ -0,0 +1,169 @@ +"""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 diff --git a/src/fastapi_toolsets/fixtures/__init__.py b/src/fastapi_toolsets/fixtures/__init__.py new file mode 100644 index 0000000..4aaee2b --- /dev/null +++ b/src/fastapi_toolsets/fixtures/__init__.py @@ -0,0 +1,17 @@ +from .fixtures import ( + Context, + FixtureRegistry, + LoadStrategy, + load_fixtures, + load_fixtures_by_context, +) +from .pytest_plugin import register_fixtures + +__all__ = [ + "Context", + "FixtureRegistry", + "LoadStrategy", + "load_fixtures", + "load_fixtures_by_context", + "register_fixtures", +] diff --git a/src/fastapi_toolsets/fixtures/fixtures.py b/src/fastapi_toolsets/fixtures/fixtures.py new file mode 100644 index 0000000..11273c5 --- /dev/null +++ b/src/fastapi_toolsets/fixtures/fixtures.py @@ -0,0 +1,321 @@ +"""Fixture system with dependency management and context support.""" + +import logging +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, cast + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import DeclarativeBase + +from ..db import get_transaction + +logger = logging.getLogger(__name__) + + +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.""" + + +@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) -> None: + self._fixtures: dict[str, Fixture] = {} + + 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__ + fixture_contexts = [ + c.value if isinstance(c, Context) else c + for c in (contexts or [Context.BASE]) + ] + + 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 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) + + +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 diff --git a/src/fastapi_toolsets/fixtures/pytest_plugin.py b/src/fastapi_toolsets/fixtures/pytest_plugin.py new file mode 100644 index 0000000..d4f8dc9 --- /dev/null +++ b/src/fastapi_toolsets/fixtures/pytest_plugin.py @@ -0,0 +1,205 @@ +"""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 diff --git a/src/fastapi_toolsets/py.typed b/src/fastapi_toolsets/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_toolsets/schemas.py b/src/fastapi_toolsets/schemas.py new file mode 100644 index 0000000..ea7839f --- /dev/null +++ b/src/fastapi_toolsets/schemas.py @@ -0,0 +1,116 @@ +"""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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..894dc98 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for fastapi-utils package.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6e97bf3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,199 @@ +"""Shared pytest fixtures for fastapi-utils tests.""" + +import os + +import pytest +from pydantic import BaseModel +from sqlalchemy import ForeignKey, String +from sqlalchemy.ext.asyncio import AsyncSession, 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( + "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[int] = mapped_column(primary_key=True) + 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[int] = mapped_column(primary_key=True) + 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[int | 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[int] = mapped_column(primary_key=True) + 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[int] = mapped_column(ForeignKey("users.id")) + + +# ============================================================================= +# Test Schemas +# ============================================================================= + + +class RoleCreate(BaseModel): + """Schema for creating a role.""" + + id: int | 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: int | None = None + username: str + email: str + is_active: bool = True + role_id: int | 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: int | None = None + + +class PostCreate(BaseModel): + """Schema for creating a post.""" + + id: int | None = None + title: str + content: str = "" + is_published: bool = False + author_id: int + + +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) -> AsyncSession: + """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=1, + ) diff --git a/tests/test_crud.py b/tests/test_crud.py new file mode 100644 index 0000000..adf71f2 --- /dev/null +++ b/tests/test_crud.py @@ -0,0 +1,475 @@ +"""Tests for fastapi_toolsets.crud module.""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from fastapi_toolsets.crud import AsyncCrud, CrudFactory +from fastapi_toolsets.exceptions import NotFoundError + +from .conftest import ( + 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.""" + with pytest.raises(NotFoundError): + await RoleCrud.get(db_session, [Role.id == 99999]) + + @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.""" + with pytest.raises(NotFoundError): + await RoleCrud.update( + db_session, + RoleUpdate(name="new"), + [Role.id == 99999], + ) + + @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.""" + data = RoleCreate(id=1, 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.""" + # First insert + data = RoleCreate(id=100, name="original_name") + await RoleCrud.upsert(db_session, data, index_elements=["id"]) + + # Upsert with update + updated_data = RoleCreate(id=100, 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 == 100]) + 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.""" + # First insert + data = RoleCreate(id=200, name="do_nothing_original") + await RoleCrud.upsert(db_session, data, index_elements=["id"]) + + # Upsert without set_ (do nothing) + conflict_data = RoleCreate(id=200, 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 == 200]) + 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"] diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..2cfda03 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,243 @@ +"""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 diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..80bfe8a --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,265 @@ +"""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} diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..79b5f0a --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,401 @@ +"""Tests for fastapi_toolsets.fixtures module.""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from fastapi_toolsets.fixtures import ( + Context, + FixtureRegistry, + LoadStrategy, + 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() + + @registry.register + def roles(): + return [Role(id=1, 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() + + @registry.register(name="custom_roles") + def roles(): + return [Role(id=1, name="admin")] + + fixture = registry.get("custom_roles") + assert fixture.name == "custom_roles" + + def test_register_with_dependencies(self): + """Register fixture with dependencies.""" + registry = FixtureRegistry() + + @registry.register + def roles(): + return [Role(id=1, name="admin")] + + @registry.register(depends_on=["roles"]) + def users(): + return [User(id=1, username="admin", email="admin@test.com", role_id=1)] + + fixture = registry.get("users") + assert fixture.depends_on == ["roles"] + + def test_register_with_contexts(self): + """Register fixture with contexts.""" + registry = FixtureRegistry() + + @registry.register(contexts=[Context.TESTING]) + def test_data(): + return [Role(id=100, 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 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() + + @registry.register + def roles(): + return [ + Role(id=1, name="admin"), + 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() + + @registry.register + def roles(): + return [Role(id=1, name="admin")] + + @registry.register(depends_on=["roles"]) + def users(): + return [User(id=1, username="admin", email="admin@test.com", role_id=1)] + + 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() + + @registry.register + def roles(): + return [Role(id=1, 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() + + @registry.register + def roles(): + return [Role(id=1, name="original")] + + await load_fixtures( + db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING + ) + + @registry.register(name="roles_updated") + def roles_v2(): + return [Role(id=1, 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 == 1]) + assert role is not None + assert role.name == "original" + + +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() + + @registry.register(contexts=[Context.BASE]) + def base_roles(): + return [Role(id=1, name="base_role")] + + @registry.register(contexts=[Context.TESTING]) + def test_roles(): + return [Role(id=100, 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 == 1]) + 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() + + @registry.register(contexts=[Context.BASE]) + def base_roles(): + return [Role(id=1, name="base_role")] + + @registry.register(contexts=[Context.TESTING]) + def test_roles(): + return [Role(id=100, 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() + + @registry.register(contexts=[Context.BASE]) + def roles(): + return [Role(id=1, name="admin")] + + @registry.register(depends_on=["roles"], contexts=[Context.TESTING]) + def test_users(): + return [User(id=1, username="tester", email="test@test.com", role_id=1)] + + 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 diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py new file mode 100644 index 0000000..62aabf5 --- /dev/null +++ b/tests/test_pytest_plugin.py @@ -0,0 +1,160 @@ +"""Tests for fastapi_toolsets.pytest_plugin module.""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from fastapi_toolsets.fixtures import Context, FixtureRegistry, register_fixtures + +from .conftest import Role, RoleCrud, User, UserCrud + +test_registry = FixtureRegistry() + + +@test_registry.register(contexts=[Context.BASE]) +def roles() -> list[Role]: + return [ + Role(id=1000, name="plugin_admin"), + Role(id=1001, name="plugin_user"), + ] + + +@test_registry.register(depends_on=["roles"], contexts=[Context.BASE]) +def users() -> list[User]: + return [ + User(id=1000, username="plugin_admin", email="padmin@test.com", role_id=1000), + User(id=1001, username="plugin_user", email="puser@test.com", role_id=1001), + ] + + +@test_registry.register(depends_on=["users"], contexts=[Context.TESTING]) +def extra_users() -> list[User]: + return [ + User(id=1002, username="plugin_extra", email="pextra@test.com", role_id=1001), + ] + + +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, [Role.id >= 1000]) + 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, [Role.id >= 1000]) + assert roles_count == 2 + + # Users should be in database + users_count = await UserCrud.count(db_session, [User.id >= 1000]) + 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 == 1000 + 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 == 1000], + 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, [Role.id >= 1000]) + users_count = await UserCrud.count(db_session, [User.id >= 1000]) + + 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, + filters=[User.id >= 1000], + order_by=User.id, + ) + + 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, filters=[Role.id >= 1000]) + users = await UserCrud.get_multi(db_session, filters=[User.id >= 1000]) + + assert len(roles) == 2 + assert len(users) == 2 diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..35a65c5 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,284 @@ +"""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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..fb2e6df --- /dev/null +++ b/uv.lock @@ -0,0 +1,793 @@ +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.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, + { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[[package]] +name = "fastapi-toolsets" +version = "0.1.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.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, + { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, + { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, + { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, +] + +[[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.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[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.45" +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/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" }, + { url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" }, + { url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, + { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, + { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, + { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, + { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, + { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, +] + +[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.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/dc/b607f00916f5a7c52860b84a66dc17bc6988e8445e96b1d6e175a3837397/ty-0.0.13.tar.gz", hash = "sha256:7a1d135a400ca076407ea30012d1f75419634160ed3b9cad96607bf2956b23b3", size = 4999183, upload-time = "2026-01-21T13:21:16.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/df/3632f1918f4c0a33184f107efc5d436ab6da147fd3d3b94b3af6461efbf4/ty-0.0.13-py3-none-linux_armv6l.whl", hash = "sha256:1b2b8e02697c3a94c722957d712a0615bcc317c9b9497be116ef746615d892f2", size = 9993501, upload-time = "2026-01-21T13:21:26.628Z" }, + { url = "https://files.pythonhosted.org/packages/92/87/6a473ced5ac280c6ce5b1627c71a8a695c64481b99aabc798718376a441e/ty-0.0.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f15cdb8e233e2b5adfce673bb21f4c5e8eaf3334842f7eea3c70ac6fda8c1de5", size = 9860986, upload-time = "2026-01-21T13:21:24.425Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/d89ae375cf0a7cd9360e1164ce017f8c753759be63b6a11ed4c944abe8c6/ty-0.0.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0819e89ac9f0d8af7a062837ce197f0461fee2fc14fd07e2c368780d3a397b73", size = 9350748, upload-time = "2026-01-21T13:21:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a6/9ad58518056fab344b20c0bb2c1911936ebe195318e8acc3bc45ac1c6b6b/ty-0.0.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de79f481084b7cc7a202ba0d7a75e10970d10ffa4f025b23f2e6b7324b74886", size = 9849884, upload-time = "2026-01-21T13:21:21.886Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c3/8add69095fa179f523d9e9afcc15a00818af0a37f2b237a9b59bc0046c34/ty-0.0.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4fb2154cff7c6e95d46bfaba283c60642616f20d73e5f96d0c89c269f3e1bcec", size = 9822975, upload-time = "2026-01-21T13:21:14.292Z" }, + { url = "https://files.pythonhosted.org/packages/a4/05/4c0927c68a0a6d43fb02f3f0b6c19c64e3461dc8ed6c404dde0efb8058f7/ty-0.0.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00be58d89337c27968a20d58ca553458608c5b634170e2bec82824c2e4cf4d96", size = 10294045, upload-time = "2026-01-21T13:21:30.505Z" }, + { url = "https://files.pythonhosted.org/packages/b4/86/6dc190838aba967557fe0bfd494c595d00b5081315a98aaf60c0e632aaeb/ty-0.0.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72435eade1fa58c6218abb4340f43a6c3ff856ae2dc5722a247d3a6dd32e9737", size = 10916460, upload-time = "2026-01-21T13:21:07.788Z" }, + { url = "https://files.pythonhosted.org/packages/04/40/9ead96b7c122e1109dfcd11671184c3506996bf6a649306ec427e81d9544/ty-0.0.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:77a548742ee8f621d718159e7027c3b555051d096a49bb580249a6c5fc86c271", size = 10597154, upload-time = "2026-01-21T13:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7d/e832a2c081d2be845dc6972d0c7998914d168ccbc0b9c86794419ab7376e/ty-0.0.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da067c57c289b7cf914669704b552b6207c2cc7f50da4118c3e12388642e6b3f", size = 10410710, upload-time = "2026-01-21T13:21:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/31/e3/898be3a96237a32f05c4c29b43594dc3b46e0eedfe8243058e46153b324f/ty-0.0.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d1b50a01fffa140417fca5a24b658fbe0734074a095d5b6f0552484724474343", size = 9826299, upload-time = "2026-01-21T13:21:00.845Z" }, + { url = "https://files.pythonhosted.org/packages/bb/eb/db2d852ce0ed742505ff18ee10d7d252f3acfd6fc60eca7e9c7a0288a6d8/ty-0.0.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0f33c46f52e5e9378378eca0d8059f026f3c8073ace02f7f2e8d079ddfe5207e", size = 9831610, upload-time = "2026-01-21T13:21:05.842Z" }, + { url = "https://files.pythonhosted.org/packages/9e/61/149f59c8abaddcbcbb0bd13b89c7741ae1c637823c5cf92ed2c644fcadef/ty-0.0.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:168eda24d9a0b202cf3758c2962cc295878842042b7eca9ed2965259f59ce9f2", size = 9978885, upload-time = "2026-01-21T13:21:10.306Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/026d4e4af60a80918a8d73d2c42b8262dd43ab2fa7b28d9743004cb88d57/ty-0.0.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d4917678b95dc8cb399cc459fab568ba8d5f0f33b7a94bf840d9733043c43f29", size = 10506453, upload-time = "2026-01-21T13:20:56.633Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/8932833a4eca2df49c997a29afb26721612de8078ae79074c8fe87e17516/ty-0.0.13-py3-none-win32.whl", hash = "sha256:c1f2ec40daa405508b053e5b8e440fbae5fdb85c69c9ab0ee078f8bc00eeec3d", size = 9433482, upload-time = "2026-01-21T13:20:58.717Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fd/e8d972d1a69df25c2cecb20ea50e49ad5f27a06f55f1f5f399a563e71645/ty-0.0.13-py3-none-win_amd64.whl", hash = "sha256:8b7b1ab9f187affbceff89d51076038363b14113be29bda2ddfa17116de1d476", size = 10319156, upload-time = "2026-01-21T13:21:03.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c2/05fdd64ac003a560d4fbd1faa7d9a31d75df8f901675e5bed1ee2ceeff87/ty-0.0.13-py3-none-win_arm64.whl", hash = "sha256:1c9630333497c77bb9bcabba42971b96ee1f36c601dd3dcac66b4134f9fa38f0", size = 9808316, upload-time = "2026-01-21T13:20:54.053Z" }, +] + +[[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" }, +]