From d165506add07b501dab76809fc2e8b5c5025bb22 Mon Sep 17 00:00:00 2001 From: d3vyce Date: Sun, 8 Feb 2026 10:09:48 +0100 Subject: [PATCH] Initial commit --- .gitignore | 211 +++ .pre-commit-config.yaml | 24 + .python-version | 1 + README.md | 193 +++ .../alembic/create-materialized-view-op.md | 13 + docs/api/alembic/create-view-op.md | 14 + docs/api/alembic/drop-materialized-view-op.md | 12 + docs/api/alembic/drop-view-op.md | 12 + .../alembic/refresh-materialized-view-op.md | 12 + docs/api/ddl/create-materialized-view.md | 9 + docs/api/ddl/create-view.md | 9 + docs/api/ddl/drop-materialized-view.md | 12 + docs/api/ddl/drop-view.md | 12 + docs/api/ddl/refresh-materialized-view.md | 12 + docs/api/dependencies/get-all-views.md | 7 + docs/api/dependencies/get-dependency-order.md | 7 + .../dependencies/get-reverse-dependencies.md | 7 + docs/api/dependencies/get-view-definition.md | 7 + .../api/dependencies/get-view-dependencies.md | 7 + docs/api/dependencies/view-dependency.md | 17 + docs/api/dependencies/view-info.md | 16 + docs/api/views/auto-refresh-context.md | 9 + docs/api/views/get-materialized-views.md | 7 + docs/api/views/get-views.md | 7 + docs/api/views/materialized-view.md | 13 + docs/api/views/view.md | 17 + docs/guide/alembic.md | 352 +++++ docs/guide/async.md | 219 +++ docs/guide/dependencies.md | 207 +++ docs/guide/materialized-views.md | 307 ++++ docs/guide/views.md | 242 ++++ docs/index.md | 172 +++ mkdocs.yml | 122 ++ pyproject.toml | 79 + src/sqlalchemy_pgview/__init__.py | 87 ++ src/sqlalchemy_pgview/alembic/__init__.py | 42 + src/sqlalchemy_pgview/alembic/autogenerate.py | 215 +++ src/sqlalchemy_pgview/alembic/ops.py | 349 +++++ src/sqlalchemy_pgview/ddl.py | 305 ++++ src/sqlalchemy_pgview/declarative.py | 298 ++++ src/sqlalchemy_pgview/dependencies.py | 274 ++++ src/sqlalchemy_pgview/py.typed | 0 src/sqlalchemy_pgview/util.py | 8 + src/sqlalchemy_pgview/view.py | 407 ++++++ test.py | 59 + tests/__init__.py | 1 + tests/conftest.py | 270 ++++ tests/test_alembic.py | 1061 ++++++++++++++ tests/test_async.py | 396 +++++ tests/test_auto_refresh.py | 369 +++++ tests/test_ddl.py | 182 +++ tests/test_declarative.py | 395 +++++ tests/test_dependencies.py | 327 +++++ tests/test_metadata_integration.py | 290 ++++ tests/test_relationships.py | 448 ++++++ tests/test_view.py | 113 ++ tests/test_view_updates.py | 341 +++++ uv.lock | 1275 +++++++++++++++++ 58 files changed, 9879 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version create mode 100644 README.md create mode 100644 docs/api/alembic/create-materialized-view-op.md create mode 100644 docs/api/alembic/create-view-op.md create mode 100644 docs/api/alembic/drop-materialized-view-op.md create mode 100644 docs/api/alembic/drop-view-op.md create mode 100644 docs/api/alembic/refresh-materialized-view-op.md create mode 100644 docs/api/ddl/create-materialized-view.md create mode 100644 docs/api/ddl/create-view.md create mode 100644 docs/api/ddl/drop-materialized-view.md create mode 100644 docs/api/ddl/drop-view.md create mode 100644 docs/api/ddl/refresh-materialized-view.md create mode 100644 docs/api/dependencies/get-all-views.md create mode 100644 docs/api/dependencies/get-dependency-order.md create mode 100644 docs/api/dependencies/get-reverse-dependencies.md create mode 100644 docs/api/dependencies/get-view-definition.md create mode 100644 docs/api/dependencies/get-view-dependencies.md create mode 100644 docs/api/dependencies/view-dependency.md create mode 100644 docs/api/dependencies/view-info.md create mode 100644 docs/api/views/auto-refresh-context.md create mode 100644 docs/api/views/get-materialized-views.md create mode 100644 docs/api/views/get-views.md create mode 100644 docs/api/views/materialized-view.md create mode 100644 docs/api/views/view.md create mode 100644 docs/guide/alembic.md create mode 100644 docs/guide/async.md create mode 100644 docs/guide/dependencies.md create mode 100644 docs/guide/materialized-views.md create mode 100644 docs/guide/views.md create mode 100644 docs/index.md create mode 100644 mkdocs.yml create mode 100644 pyproject.toml create mode 100644 src/sqlalchemy_pgview/__init__.py create mode 100644 src/sqlalchemy_pgview/alembic/__init__.py create mode 100644 src/sqlalchemy_pgview/alembic/autogenerate.py create mode 100644 src/sqlalchemy_pgview/alembic/ops.py create mode 100644 src/sqlalchemy_pgview/ddl.py create mode 100644 src/sqlalchemy_pgview/declarative.py create mode 100644 src/sqlalchemy_pgview/dependencies.py create mode 100644 src/sqlalchemy_pgview/py.typed create mode 100644 src/sqlalchemy_pgview/util.py create mode 100644 src/sqlalchemy_pgview/view.py create mode 100644 test.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_alembic.py create mode 100644 tests/test_async.py create mode 100644 tests/test_auto_refresh.py create mode 100644 tests/test_ddl.py create mode 100644 tests/test_declarative.py create mode 100644 tests/test_dependencies.py create mode 100644 tests/test_metadata_integration.py create mode 100644 tests/test_relationships.py create mode 100644 tests/test_view.py create mode 100644 tests/test_view_updates.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ff148a --- /dev/null +++ b/.gitignore @@ -0,0 +1,211 @@ +# 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__/ + + +# MkDocs +site/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a6c5c8b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.11 + hooks: + # Run the linter. + - id: ruff-check + # Run the formatter. + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: check-yaml + - id: check-json + - id: check-merge-conflict + - id: end-of-file-fixer + - repo: local + hooks: + - id: ty + name: ty check + entry: uvx ty check src/ + language: system + pass_filenames: false + always_run: true 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/README.md b/README.md new file mode 100644 index 0000000..dec8772 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# SQLAlchemy-PGView + +A SQLAlchemy 2.0+ extension that provides first-class support for PostgreSQL views and materialized views. + +[![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.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![SQLAlchemy 2.0+](https://img.shields.io/badge/SQLAlchemy-2.0+-green.svg)](https://www.sqlalchemy.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Features + +- **Declarative Views** - Class-based view definitions using multiple inheritance with SQLAlchemy ORM +- **View & MaterializedView Classes** - Define PostgreSQL views as Python objects with full DDL support +- **Alembic Integration** - Database migration operations (`op.create_view()`, `op.drop_view()`, etc.) +- **Auto-Refresh** - Automatically refresh materialized views on data changes +- **Async Support** - Works with asyncpg and SQLAlchemy's async engines +- **Dependency Tracking** - Query PostgreSQL system catalogs for view dependencies +- **Type Safety** - Full type annotations for modern Python development + +## Requirements + +- Python 3.10+ +- SQLAlchemy 2.0+ +- PostgreSQL database +- Alembic 1.10+ (optional, for migrations) + +## Installation + +Base package +```bash +uv pip install "sqlalchemy-pgview" +``` + +With alembic support +```bash +uv pip install "sqlalchemy-pgview[alembic]" +``` + +## Quick Start + +```python +from decimal import Decimal +from sqlalchemy import create_engine, select, func, String, Numeric, Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session +from sqlalchemy_pgview import ViewBase, MaterializedViewBase + +# Define your base and models +class Base(DeclarativeBase): + pass + +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + is_active: Mapped[bool] = mapped_column(default=True) + +class Order(Base): + __tablename__ = "orders" + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(Integer) + total: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + +# Define a regular view (computed on every query) +class ActiveUsers(ViewBase, Base): + __tablename__ = "active_users" + __select__ = select(User.id, User.name).where(User.is_active == True) + +# Define a materialized view (cached results, needs refresh) +class UserStats(MaterializedViewBase, Base): + __tablename__ = "user_stats" + __select__ = select( + User.id.label("user_id"), + User.name, + func.count(Order.id).label("order_count"), + func.coalesce(func.sum(Order.total), 0).label("total_spent"), + ).select_from(User.__table__.outerjoin(Order.__table__, User.id == Order.user_id) + ).group_by(User.id, User.name) + +# Create everything (tables + views) +engine = create_engine("postgresql://user:pass@localhost/mydb") +Base.metadata.create_all(engine) + +# Query the view +with engine.connect() as conn: + result = conn.execute(select(ActiveUsers.as_table())).fetchall() + for row in result: + print(f"{row.name}") + +# Refresh materialized view +with engine.begin() as conn: + UserStats.refresh(conn) +``` + +Automatically refresh materialized views when underlying data changes: + +```python +from sqlalchemy.orm import Session + +# Enable auto-refresh when Order table changes +UserStats.auto_refresh_on(Session, Order.__table__) + +# Now commits automatically refresh the materialized view +with Session(engine) as session: + session.add(Order(user_id=1, total=Decimal("100.00"))) + session.commit() # UserStats is automatically refreshed +``` + +## Alembic Integration + +SQLAlchemy-PGView integrates with Alembic for automatic view detection and migration generation. + +Import the alembic module in your `env.py` to enable autogenerate: + +```python +# env.py +import sqlalchemy_pgview.alembic # Registers autogenerate support +``` + +Then generate migrations automatically: + +```bash +alembic revision --autogenerate -m "add user views" +``` + +Alembic will detect: +- **New views**: Views in metadata but not in database +- **Removed views**: Views in database but not in metadata + +You also can manually add refresh in existing migration: + +```python +def upgrade(): + # After data changes, refresh materialized views + op.refresh_materialized_view("user_stats", concurrently=True) +``` + +## API Reference + +### ViewBase (Declarative) + +```python +class MyView(ViewBase, Base): + __tablename__ = "my_view" # Required: view name + __select__ = select(...) # Required: SELECT statement + __schema__ = "public" # Optional: schema name +``` + +### MaterializedViewBase (Declarative) + +```python +class MyMaterializedView(MaterializedViewBase, Base): + __tablename__ = "my_mview" # Required: view name + __select__ = select(...) # Required: SELECT statement + __schema__ = "public" # Optional: schema name + __with_data__ = True # Optional: populate on creation (default: True) +``` + +### View (Imperative) + +```python +View( + name: str, # View name + selectable: Select, # SQLAlchemy SELECT statement + schema: str | None = None, # Schema name (default: public) + metadata: MetaData | None = None, # MetaData for auto-registration +) +``` + +### MaterializedView (Imperative) + +```python +MaterializedView( + name: str, # View name + selectable: Select, # SQLAlchemy SELECT statement + schema: str | None = None, # Schema name (default: public) + metadata: MetaData | None = None, # MetaData for auto-registration + with_data: bool = True, # Populate on creation +) +``` + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +## Contributing + +Contributions are welcome! Please feel free to submit issues and pull requests. + +## Documentation + +For full documentation, visit the [docs](docs/) directory. diff --git a/docs/api/alembic/create-materialized-view-op.md b/docs/api/alembic/create-materialized-view-op.md new file mode 100644 index 0000000..981e0fe --- /dev/null +++ b/docs/api/alembic/create-materialized-view-op.md @@ -0,0 +1,13 @@ +# `CreateMaterializedViewOp` class + +Alembic operation to create a materialized view. + +```python +from sqlalchemy_pgview.alembic import CreateMaterializedViewOp +``` + +::: sqlalchemy_pgview.alembic.CreateMaterializedViewOp + options: + members: + - create_materialized_view + - reverse diff --git a/docs/api/alembic/create-view-op.md b/docs/api/alembic/create-view-op.md new file mode 100644 index 0000000..55f525a --- /dev/null +++ b/docs/api/alembic/create-view-op.md @@ -0,0 +1,14 @@ +# `CreateViewOp` class + +Alembic operation to create a view. + +```python +import sqlalchemy_pgview.alembic # Registers operations +from sqlalchemy_pgview.alembic import CreateViewOp +``` + +::: sqlalchemy_pgview.alembic.CreateViewOp + options: + members: + - create_view + - reverse diff --git a/docs/api/alembic/drop-materialized-view-op.md b/docs/api/alembic/drop-materialized-view-op.md new file mode 100644 index 0000000..80793ad --- /dev/null +++ b/docs/api/alembic/drop-materialized-view-op.md @@ -0,0 +1,12 @@ +# `DropMaterializedViewOp` class + +Alembic operation to drop a materialized view. + +```python +from sqlalchemy_pgview.alembic import DropMaterializedViewOp +``` + +::: sqlalchemy_pgview.alembic.DropMaterializedViewOp + options: + members: + - drop_materialized_view diff --git a/docs/api/alembic/drop-view-op.md b/docs/api/alembic/drop-view-op.md new file mode 100644 index 0000000..6b09ab2 --- /dev/null +++ b/docs/api/alembic/drop-view-op.md @@ -0,0 +1,12 @@ +# `DropViewOp` class + +Alembic operation to drop a view. + +```python +from sqlalchemy_pgview.alembic import DropViewOp +``` + +::: sqlalchemy_pgview.alembic.DropViewOp + options: + members: + - drop_view diff --git a/docs/api/alembic/refresh-materialized-view-op.md b/docs/api/alembic/refresh-materialized-view-op.md new file mode 100644 index 0000000..dd4c6b3 --- /dev/null +++ b/docs/api/alembic/refresh-materialized-view-op.md @@ -0,0 +1,12 @@ +# `RefreshMaterializedViewOp` class + +Alembic operation to refresh a materialized view. + +```python +from sqlalchemy_pgview.alembic import RefreshMaterializedViewOp +``` + +::: sqlalchemy_pgview.alembic.RefreshMaterializedViewOp + options: + members: + - refresh_materialized_view diff --git a/docs/api/ddl/create-materialized-view.md b/docs/api/ddl/create-materialized-view.md new file mode 100644 index 0000000..2701b96 --- /dev/null +++ b/docs/api/ddl/create-materialized-view.md @@ -0,0 +1,9 @@ +# `CreateMaterializedView` class + +DDL element to create a PostgreSQL materialized view. + +```python +from sqlalchemy_pgview import CreateMaterializedView +``` + +::: sqlalchemy_pgview.CreateMaterializedView diff --git a/docs/api/ddl/create-view.md b/docs/api/ddl/create-view.md new file mode 100644 index 0000000..2020302 --- /dev/null +++ b/docs/api/ddl/create-view.md @@ -0,0 +1,9 @@ +# `CreateView` class + +DDL element to create a PostgreSQL view. + +```python +from sqlalchemy_pgview import CreateView +``` + +::: sqlalchemy_pgview.CreateView diff --git a/docs/api/ddl/drop-materialized-view.md b/docs/api/ddl/drop-materialized-view.md new file mode 100644 index 0000000..776bb29 --- /dev/null +++ b/docs/api/ddl/drop-materialized-view.md @@ -0,0 +1,12 @@ +# `DropMaterializedView` class + +DDL element to drop a PostgreSQL materialized view. + +```python +from sqlalchemy_pgview import DropMaterializedView +``` + +::: sqlalchemy_pgview.DropMaterializedView + options: + members: + - fullname diff --git a/docs/api/ddl/drop-view.md b/docs/api/ddl/drop-view.md new file mode 100644 index 0000000..cd10d0d --- /dev/null +++ b/docs/api/ddl/drop-view.md @@ -0,0 +1,12 @@ +# `DropView` class + +DDL element to drop a PostgreSQL view. + +```python +from sqlalchemy_pgview import DropView +``` + +::: sqlalchemy_pgview.DropView + options: + members: + - fullname diff --git a/docs/api/ddl/refresh-materialized-view.md b/docs/api/ddl/refresh-materialized-view.md new file mode 100644 index 0000000..fc7cf9b --- /dev/null +++ b/docs/api/ddl/refresh-materialized-view.md @@ -0,0 +1,12 @@ +# `RefreshMaterializedView` class + +DDL element to refresh a PostgreSQL materialized view. + +```python +from sqlalchemy_pgview import RefreshMaterializedView +``` + +::: sqlalchemy_pgview.RefreshMaterializedView + options: + members: + - fullname diff --git a/docs/api/dependencies/get-all-views.md b/docs/api/dependencies/get-all-views.md new file mode 100644 index 0000000..9bc40b5 --- /dev/null +++ b/docs/api/dependencies/get-all-views.md @@ -0,0 +1,7 @@ +# `get_all_views` + +```python +from sqlalchemy_pgview import get_all_views +``` + +::: sqlalchemy_pgview.get_all_views diff --git a/docs/api/dependencies/get-dependency-order.md b/docs/api/dependencies/get-dependency-order.md new file mode 100644 index 0000000..78010f9 --- /dev/null +++ b/docs/api/dependencies/get-dependency-order.md @@ -0,0 +1,7 @@ +# `get_dependency_order` + +```python +from sqlalchemy_pgview import get_dependency_order +``` + +::: sqlalchemy_pgview.get_dependency_order diff --git a/docs/api/dependencies/get-reverse-dependencies.md b/docs/api/dependencies/get-reverse-dependencies.md new file mode 100644 index 0000000..b7d5138 --- /dev/null +++ b/docs/api/dependencies/get-reverse-dependencies.md @@ -0,0 +1,7 @@ +# `get_reverse_dependencies` + +```python +from sqlalchemy_pgview import get_reverse_dependencies +``` + +::: sqlalchemy_pgview.get_reverse_dependencies diff --git a/docs/api/dependencies/get-view-definition.md b/docs/api/dependencies/get-view-definition.md new file mode 100644 index 0000000..e1c2000 --- /dev/null +++ b/docs/api/dependencies/get-view-definition.md @@ -0,0 +1,7 @@ +# `get_view_definition` + +```python +from sqlalchemy_pgview import get_view_definition +``` + +::: sqlalchemy_pgview.get_view_definition diff --git a/docs/api/dependencies/get-view-dependencies.md b/docs/api/dependencies/get-view-dependencies.md new file mode 100644 index 0000000..a2848b6 --- /dev/null +++ b/docs/api/dependencies/get-view-dependencies.md @@ -0,0 +1,7 @@ +# `get_view_dependencies` + +```python +from sqlalchemy_pgview import get_view_dependencies +``` + +::: sqlalchemy_pgview.get_view_dependencies diff --git a/docs/api/dependencies/view-dependency.md b/docs/api/dependencies/view-dependency.md new file mode 100644 index 0000000..066d976 --- /dev/null +++ b/docs/api/dependencies/view-dependency.md @@ -0,0 +1,17 @@ +# `ViewDependency` class + +Represents a dependency between views. + +```python +from sqlalchemy_pgview import ViewDependency +``` + +::: sqlalchemy_pgview.ViewDependency + options: + members: + - dependent_view + - dependent_schema + - referenced_view + - referenced_schema + - dependent_fullname + - referenced_fullname diff --git a/docs/api/dependencies/view-info.md b/docs/api/dependencies/view-info.md new file mode 100644 index 0000000..4f6a2c8 --- /dev/null +++ b/docs/api/dependencies/view-info.md @@ -0,0 +1,16 @@ +# `ViewInfo` class + +Information about a view from the database. + +```python +from sqlalchemy_pgview import ViewInfo +``` + +::: sqlalchemy_pgview.ViewInfo + options: + members: + - name + - schema + - definition + - is_materialized + - fullname diff --git a/docs/api/views/auto-refresh-context.md b/docs/api/views/auto-refresh-context.md new file mode 100644 index 0000000..07ada7d --- /dev/null +++ b/docs/api/views/auto-refresh-context.md @@ -0,0 +1,9 @@ +# `AutoRefreshContext` class + +Context manager for auto-refreshing materialized views when using SQLAlchemy Core. + +```python +from sqlalchemy_pgview import AutoRefreshContext +``` + +::: sqlalchemy_pgview.AutoRefreshContext diff --git a/docs/api/views/get-materialized-views.md b/docs/api/views/get-materialized-views.md new file mode 100644 index 0000000..02ccb37 --- /dev/null +++ b/docs/api/views/get-materialized-views.md @@ -0,0 +1,7 @@ +# `get_materialized_views` + +```python +from sqlalchemy_pgview import get_materialized_views +``` + +::: sqlalchemy_pgview.get_materialized_views diff --git a/docs/api/views/get-views.md b/docs/api/views/get-views.md new file mode 100644 index 0000000..5776cb0 --- /dev/null +++ b/docs/api/views/get-views.md @@ -0,0 +1,7 @@ +# `get_views` + +```python +from sqlalchemy_pgview import get_views +``` + +::: sqlalchemy_pgview.get_views diff --git a/docs/api/views/materialized-view.md b/docs/api/views/materialized-view.md new file mode 100644 index 0000000..1860af2 --- /dev/null +++ b/docs/api/views/materialized-view.md @@ -0,0 +1,13 @@ +# `MaterializedView` class + +Here's the reference for the `MaterializedView` class, which extends `View` with materialized view features. + +```python +from sqlalchemy_pgview import MaterializedView +``` + +::: sqlalchemy_pgview.MaterializedView + options: + members: + - refresh + - auto_refresh_on diff --git a/docs/api/views/view.md b/docs/api/views/view.md new file mode 100644 index 0000000..80341c7 --- /dev/null +++ b/docs/api/views/view.md @@ -0,0 +1,17 @@ +# `View` class + +Here's the reference for the `View` class, with all its parameters, attributes and methods. + +You can import it directly from `sqlalchemy_pgview`: + +```python +from sqlalchemy_pgview import View +``` + +::: sqlalchemy_pgview.View + options: + members: + - fullname + - columns + - as_table + - as_from_clause diff --git a/docs/guide/alembic.md b/docs/guide/alembic.md new file mode 100644 index 0000000..8701ac2 --- /dev/null +++ b/docs/guide/alembic.md @@ -0,0 +1,352 @@ +# Alembic Migrations + +SQLAlchemy-PGView provides Alembic operations for managing views in database migrations, including autogenerate support for automatic view detection. + +## Setup + +Install with Alembic support: + +```bash +pip install sqlalchemy-pgview[alembic] +``` + +In your `env.py`, import the alembic module to register comparators and renderers: + +```python +# env.py +from alembic import context +from sqlalchemy import engine_from_config +import sqlalchemy_pgview.alembic # Registers autogenerate support + +# Import your models/views +from myapp.models import metadata +``` + +## Autogenerate Support + +When you run `alembic revision --autogenerate`, Alembic will: + +1. **Detect new views**: Views in metadata but not in database → generates `create_view`/`create_materialized_view` +2. **Detect removed views**: Views in database but not in metadata → generates `drop_view`/`drop_materialized_view` +3. **Detect changed views**: Views with different definitions → generates drop + create + +```bash +# Generate migration with view changes +alembic revision --autogenerate -m "add user stats view" +``` + +Register views with metadata to enable detection: + +```python +from sqlalchemy import MetaData, Table, Column, Integer, String, select +from sqlalchemy_pgview import View, MaterializedView + +metadata = MetaData() + +users = Table("users", metadata, ...) + +# These views will be detected by autogenerate +active_users = View( + "active_users", + select(users.c.id, users.c.name).where(users.c.is_active == True), + metadata=metadata, +) + +user_stats = MaterializedView( + "user_stats", + select(users.c.id, users.c.name), + metadata=metadata, +) +``` + +Generated migration example: + +```python +"""add user stats view + +Revision ID: abc123 +""" +from alembic import op +import sqlalchemy_pgview.alembic + +def upgrade(): + op.create_view( + "active_users", + "SELECT users.id, users.name FROM users WHERE users.is_active = true" + ) + op.create_materialized_view( + "user_stats", + "SELECT users.id, users.name FROM users", + with_data=True + ) + +def downgrade(): + op.drop_materialized_view("user_stats") + op.drop_view("active_users") +``` + +## Creating Views + +```python +def upgrade(): + # Regular view + op.create_view( + "active_users", + """ + SELECT id, name, email + FROM users + WHERE is_active = true + """, + ) + + # With schema + op.create_view( + "user_metrics", + """ + SELECT + user_id, + COUNT(*) as order_count, + SUM(total) as total_spent + FROM orders + GROUP BY user_id + """, + schema="analytics", + ) + + # CREATE OR REPLACE (won't fail if exists) + op.create_view( + "user_stats", + "SELECT id, name, created_at FROM users", + or_replace=True, + ) + +def downgrade(): + op.drop_view("user_stats") + op.drop_view("user_metrics", schema="analytics") + op.drop_view("active_users") +``` + +## Creating Materialized Views + +```python +def upgrade(): + # With data populated immediately + op.create_materialized_view( + "monthly_revenue", + """ + SELECT + date_trunc('month', created_at) as month, + SUM(total) as revenue + FROM orders + GROUP BY date_trunc('month', created_at) + """, + with_data=True, + ) + + # Without initial data (populate later) + op.create_materialized_view( + "large_summary", + "SELECT ...", + with_data=False, + ) + +def downgrade(): + op.drop_materialized_view("large_summary") + op.drop_materialized_view("monthly_revenue") +``` + +## Dropping Views + +```python +def upgrade(): + # Regular view + op.drop_view("old_view") + + # With options + op.drop_view("another_view", if_exists=True, cascade=True) + + # Materialized view + op.drop_materialized_view("old_mv", if_exists=True, cascade=True) +``` + +## Refreshing Materialized Views + +Use in data migrations: + +```python +def upgrade(): + # After data changes, refresh the view + op.refresh_materialized_view("monthly_revenue") + + # Concurrent refresh (requires unique index) + op.refresh_materialized_view("monthly_revenue", concurrently=True) +``` + +## Modifying a View + +Views can't be altered - drop and recreate: + +```python +def upgrade(): + op.drop_view("user_stats") + op.create_view( + "user_stats", + """ + SELECT + u.id, + u.name, + u.email, -- Added column + COUNT(o.id) as order_count, + COALESCE(SUM(o.total), 0) as total_spent + FROM users u + LEFT JOIN orders o ON u.id = o.user_id + GROUP BY u.id, u.name, u.email + """, + ) + +def downgrade(): + op.drop_view("user_stats") + op.create_view( + "user_stats", + """ + SELECT + u.id, + u.name, + COUNT(o.id) as order_count, + COALESCE(SUM(o.total), 0) as total_spent + FROM users u + LEFT JOIN orders o ON u.id = o.user_id + GROUP BY u.id, u.name + """, + ) +``` + +!!! tip "Use OR REPLACE When Possible" + If you're only changing the query (not column names/types), use `or_replace=True`: + + ```python + def upgrade(): + op.create_view( + "user_stats", + "SELECT ... (new query)", + or_replace=True, + ) + ``` + +## Dependent Views + +Drop dependents first, recreate in order: + +```python +def upgrade(): + # Drop in reverse dependency order + op.drop_view("derived_view", if_exists=True) + op.drop_view("base_view", if_exists=True) + + # Recreate base view with changes + op.create_view("base_view", "SELECT ...") + + # Recreate derived view + op.create_view("derived_view", "SELECT * FROM base_view WHERE ...") + +def downgrade(): + op.drop_view("derived_view") + op.drop_view("base_view") + op.create_view("base_view", "SELECT ... (old query)") + op.create_view("derived_view", "SELECT ... (old query)") +``` + +## Converting Table to Materialized View + +```python +def upgrade(): + # Create materialized view from table data + op.create_materialized_view( + "summary_mv", + """ + SELECT + category, + COUNT(*) as count, + SUM(amount) as total + FROM transactions + GROUP BY category + """, + with_data=True, + ) + + # Optionally drop the old summary table + op.drop_table("summary_table") + +def downgrade(): + # Recreate table + op.create_table( + "summary_table", + sa.Column("category", sa.String(50)), + sa.Column("count", sa.Integer), + sa.Column("total", sa.Numeric(10, 2)), + ) + + # Populate from materialized view + op.execute(""" + INSERT INTO summary_table + SELECT * FROM summary_mv + """) + + op.drop_materialized_view("summary_mv") +``` + +## Complete Example + +```python +"""Add analytics views + +Revision ID: abc123 +""" +from alembic import op +import sqlalchemy_pgview.alembic + +revision = 'abc123' +down_revision = 'xyz789' + +def upgrade(): + # Create schema + op.execute("CREATE SCHEMA IF NOT EXISTS analytics") + + # Regular view for real-time queries + op.create_view( + "active_orders", + """ + SELECT * + FROM orders + WHERE status = 'pending' + """, + schema="analytics", + ) + + # Materialized view for reports + op.create_materialized_view( + "daily_revenue", + """ + SELECT + date_trunc('day', created_at) as day, + COUNT(*) as orders, + SUM(total) as revenue + FROM orders + WHERE status = 'completed' + GROUP BY date_trunc('day', created_at) + """, + schema="analytics", + with_data=True, + ) + + # Create index for concurrent refresh + op.execute(""" + CREATE UNIQUE INDEX idx_daily_revenue_day + ON analytics.daily_revenue (day) + """) + +def downgrade(): + op.drop_materialized_view("daily_revenue", schema="analytics") + op.drop_view("active_orders", schema="analytics") + op.execute("DROP SCHEMA IF EXISTS analytics") +``` diff --git a/docs/guide/async.md b/docs/guide/async.md new file mode 100644 index 0000000..15856d7 --- /dev/null +++ b/docs/guide/async.md @@ -0,0 +1,219 @@ +# Async Support + +SQLAlchemy-PGView works with SQLAlchemy's async engine using `asyncpg`. + +## Setup + +Install asyncpg: + +```bash +pip install asyncpg +``` + +Create an async engine: + +```python +from sqlalchemy.ext.asyncio import create_async_engine + +engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db") +``` + +## Creating Views (Async) + +```python +from sqlalchemy import select, func +from sqlalchemy_pgview import View, CreateView, DropView + +user_stats = View( + "user_stats", + select( + users.c.id, + users.c.name, + func.count(orders.c.id).label("order_count"), + ) + .join(orders) + .group_by(users.c.id, users.c.name), +) + +async with engine.begin() as conn: + # Create view + await conn.execute(CreateView(user_stats, or_replace=True)) + + # Query view + result = await conn.execute(select(user_stats.as_table())) + rows = result.fetchall() + + # Drop view + await conn.execute(DropView(user_stats, if_exists=True)) +``` + +## Materialized Views (Async) + +```python +from sqlalchemy_pgview import ( + MaterializedView, + CreateMaterializedView, + RefreshMaterializedView, + DropMaterializedView, +) + +monthly_stats = MaterializedView( + "monthly_stats", + select( + func.date_trunc('month', orders.c.created_at).label("month"), + func.sum(orders.c.total).label("revenue"), + ).group_by(func.date_trunc('month', orders.c.created_at)), + with_data=True, +) + +async with engine.begin() as conn: + # Create + await conn.execute(CreateMaterializedView(monthly_stats)) + + # Query + result = await conn.execute(select(monthly_stats.as_table())) + rows = result.fetchall() + + # Refresh + await conn.execute(RefreshMaterializedView(monthly_stats)) + + # Drop + await conn.execute(DropMaterializedView(monthly_stats, if_exists=True)) +``` + +## Refreshing with .refresh() Method + +The `.refresh()` method is synchronous. Use `run_sync` to call it from async code: + +```python +async with engine.begin() as conn: + # Use run_sync for the synchronous refresh method + def refresh_sync(sync_conn): + monthly_stats.refresh(sync_conn) + + await conn.run_sync(refresh_sync) +``` + +Or use the DDL directly: + +```python +async with engine.begin() as conn: + await conn.execute(RefreshMaterializedView(monthly_stats, concurrently=True)) +``` + +!!! tip "Prefer DDL Classes for Async" + For async code, prefer using `RefreshMaterializedView` DDL class over the `.refresh()` method to avoid `run_sync` overhead. + +## Dependency Functions (Async) + +The dependency tracking functions are synchronous. Use `run_sync`: + +```python +from sqlalchemy_pgview import get_all_views, get_view_definition + +async with engine.connect() as conn: + # Get all views + views = await conn.run_sync(lambda c: get_all_views(c)) + + for view in views: + print(f"{view.fullname}: {'MV' if view.is_materialized else 'V'}") + + # Get view definition + definition = await conn.run_sync( + lambda c: get_view_definition(c, "user_stats") + ) +``` + +## Sync vs Async Operations + +| Operation | Sync | Async | +|-----------|------|-------| +| DDL (CREATE/DROP) | Direct | Direct | +| Queries | Direct | Direct | +| `.refresh()` method | Direct | Use `run_sync` | +| Dependency functions | Direct | Use `run_sync` | + +## Complete Example + +```python +import asyncio +from decimal import Decimal + +from sqlalchemy import Column, Integer, String, Numeric, ForeignKey, MetaData, Table, select, func +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy_pgview import ( + View, + MaterializedView, + CreateView, + CreateMaterializedView, + RefreshMaterializedView, + DropView, + DropMaterializedView, +) + + +async def main(): + engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db") + metadata = MetaData() + + users = Table( + "users", metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100)), + ) + + orders = Table( + "orders", metadata, + Column("id", Integer, primary_key=True), + Column("user_id", Integer, ForeignKey("users.id")), + Column("total", Numeric(10, 2)), + ) + + # Create tables + async with engine.begin() as conn: + await conn.run_sync(metadata.create_all) + + # Define views + user_summary = View( + "user_summary", + select( + users.c.id, + users.c.name, + func.count(orders.c.id).label("order_count"), + ) + .select_from(users.outerjoin(orders)) + .group_by(users.c.id, users.c.name), + ) + + revenue_mv = MaterializedView( + "revenue_summary", + select(func.sum(orders.c.total).label("total_revenue")), + with_data=True, + ) + + async with engine.begin() as conn: + # Create views + await conn.execute(CreateView(user_summary, or_replace=True)) + await conn.execute(CreateMaterializedView(revenue_mv)) + + # Query regular view (always current) + result = await conn.execute(select(user_summary.as_table())) + print("User Summary:", result.fetchall()) + + # Query materialized view + result = await conn.execute(select(revenue_mv.as_table())) + print("Revenue:", result.fetchone()) + + # Refresh materialized view + await conn.execute(RefreshMaterializedView(revenue_mv)) + + # Cleanup + await conn.execute(DropMaterializedView(revenue_mv, if_exists=True)) + await conn.execute(DropView(user_summary, if_exists=True)) + + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) +``` diff --git a/docs/guide/dependencies.md b/docs/guide/dependencies.md new file mode 100644 index 0000000..4adfd64 --- /dev/null +++ b/docs/guide/dependencies.md @@ -0,0 +1,207 @@ +# Dependency Tracking + +SQLAlchemy-PGView can query PostgreSQL system catalogs to discover view dependencies, helping you manage complex view hierarchies. + +## Why Track Dependencies? + +- **Safe migrations**: Know which views to drop/recreate when modifying a base view +- **Impact analysis**: Understand what breaks if you change a table or view +- **Correct ordering**: Create views in the right order during deployment + +## Getting All Views + +```python +from sqlalchemy_pgview import get_all_views + +with engine.connect() as conn: + views = get_all_views(conn) + + for view in views: + print(f"{view.schema}.{view.name}") + print(f" Materialized: {view.is_materialized}") + print(f" Definition: {view.definition[:50]}...") + + # Only views in 'analytics' schema + views = get_all_views(conn, schema="analytics") + + # Exclude materialized views + views = get_all_views(conn, include_materialized=False) +``` + +## Getting View Definition + +```python +from sqlalchemy_pgview import get_view_definition + +with engine.connect() as conn: + definition = get_view_definition(conn, "user_stats") + print(definition) + # SELECT u.id, u.name, count(o.id) AS order_count + # FROM users u LEFT JOIN orders o ON u.id = o.user_id + # GROUP BY u.id, u.name +``` + +## Direct Dependencies + +Find what a view depends on: + +```python +from sqlalchemy_pgview import get_view_dependencies + +with engine.connect() as conn: + deps = get_view_dependencies(conn, "user_order_summary") + + for dep in deps: + print(f"{dep.dependent_fullname} depends on {dep.referenced_fullname}") +``` + +## Reverse Dependencies + +Find what depends on a view (impact analysis): + +```python +from sqlalchemy_pgview import get_reverse_dependencies + +with engine.connect() as conn: + # What views depend on 'users' table/view? + dependents = get_reverse_dependencies(conn, "users") + + print("Views that depend on 'users':") + for view in dependents: + print(f" - {view.fullname}") +``` + +## Dependency Order + +Get views sorted by dependencies (dependencies first): + +```python +from sqlalchemy_pgview import get_dependency_order + +with engine.connect() as conn: + ordered_views = get_dependency_order(conn) + + print("Views in creation order:") + for i, view in enumerate(ordered_views, 1): + print(f"{i}. {view.fullname}") +``` + +This is useful for: + +- **Creating views**: Process in order to avoid "relation does not exist" errors +- **Dropping views**: Process in reverse order to avoid dependency errors + +## Safe View Modification + +```python +from sqlalchemy_pgview import ( + get_reverse_dependencies, + get_view_definition, + DropView, + CreateView, +) + +def modify_view_safely(conn, view_name, new_definition): + """Modify a view by dropping/recreating it and all dependents.""" + + # Get all views that depend on this one + dependents = get_reverse_dependencies(conn, view_name) + + # Save their definitions + saved_definitions = {} + for dep in dependents: + saved_definitions[dep.fullname] = get_view_definition( + conn, dep.name, dep.schema + ) + + # Drop dependents in reverse order + for dep in reversed(dependents): + conn.execute(DropView(dep.name, schema=dep.schema, if_exists=True)) + + # Drop and recreate the target view + conn.execute(DropView(view_name, if_exists=True)) + conn.execute(text(f"CREATE VIEW {view_name} AS {new_definition}")) + + # Recreate dependents in order + for dep in dependents: + definition = saved_definitions[dep.fullname] + conn.execute(text( + f"CREATE VIEW {dep.fullname} AS {definition}" + )) +``` + +## Migration Helper + +```python +def generate_view_migration(conn, schema=None): + """Generate migration code for all views in dependency order.""" + + views = get_dependency_order(conn, schema=schema) + + print("def upgrade():") + for view in views: + view_type = "materialized_view" if view.is_materialized else "view" + definition = view.definition.replace("'", "\\'") + print(f" op.create_{view_type}(") + print(f" '{view.name}',") + print(f" '''{definition}''',") + if view.schema != "public": + print(f" schema='{view.schema}',") + print(f" )") + print() + + print("def downgrade():") + for view in reversed(views): + view_type = "materialized_view" if view.is_materialized else "view" + print(f" op.drop_{view_type}('{view.name}'", end="") + if view.schema != "public": + print(f", schema='{view.schema}'", end="") + print(")") +``` + +## Dependency Visualization + +```python +def print_dependency_tree(conn, view_name, indent=0): + """Print a tree of view dependencies.""" + prefix = " " * indent + print(f"{prefix}- {view_name}") + + deps = get_view_dependencies(conn, view_name) + for dep in deps: + print_dependency_tree(conn, dep.referenced_fullname, indent + 1) +``` + +Output: +``` +- sales_summary + - monthly_sales + - orders + - products + - customer_stats + - customers + - orders +``` + +## Data Classes + +### ViewInfo + +| Attribute | Type | Description | +|-----------|------|-------------| +| `name` | `str` | View name | +| `schema` | `str` | Schema name | +| `definition` | `str` | SQL definition | +| `is_materialized` | `bool` | Whether the view is materialized | +| `fullname` | `str` | `schema.name` (property) | + +### ViewDependency + +| Attribute | Type | Description | +|-----------|------|-------------| +| `dependent_view` | `str` | Name of the dependent view | +| `dependent_schema` | `str` | Schema of the dependent view | +| `referenced_view` | `str` | Name of the referenced object | +| `referenced_schema` | `str` | Schema of the referenced object | +| `dependent_fullname` | `str` | `schema.view` (property) | +| `referenced_fullname` | `str` | `schema.view` (property) | diff --git a/docs/guide/materialized-views.md b/docs/guide/materialized-views.md new file mode 100644 index 0000000..8fec882 --- /dev/null +++ b/docs/guide/materialized-views.md @@ -0,0 +1,307 @@ +# Materialized Views + +Materialized views store the query results physically, making them faster to query but requiring explicit refresh to update. + +## When to Use Materialized Views + +| Use Case | Regular View | Materialized View | +|----------|--------------|-------------------| +| Complex aggregations queried frequently | Slow | **Fast** | +| Data that changes frequently | **Current** | Stale | +| Reports and dashboards | Slow | **Fast** | +| Real-time data requirements | **Yes** | No | +| Large dataset summaries | Slow | **Fast** | + +## Creating Materialized Views (Declarative) + +The recommended way to create materialized views is using the declarative pattern with multiple inheritance: + +```python +from decimal import Decimal +from sqlalchemy import select, func, String, Numeric, Integer, DateTime +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy_pgview import MaterializedViewBase + +class Base(DeclarativeBase): + pass + +class Order(Base): + __tablename__ = "orders" + id: Mapped[int] = mapped_column(primary_key=True) + total: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + created_at: Mapped[DateTime] = mapped_column(DateTime) + +# Define materialized view with data populated immediately +class MonthlySales(MaterializedViewBase, Base): + __tablename__ = "monthly_sales" + __select__ = select( + func.date_trunc('month', Order.created_at).label("month"), + func.count(Order.id).label("order_count"), + func.sum(Order.total).label("revenue"), + ).group_by(func.date_trunc('month', Order.created_at)) + __with_data__ = True # Default: populate on creation + +# Create tables and views together +engine = create_engine("postgresql://user:pass@localhost/mydb") +Base.metadata.create_all(engine) +``` + +### MaterializedViewBase Attributes + +| Attribute | Required | Description | +|-----------|----------|-------------| +| `__tablename__` | Yes | Name of the materialized view in the database | +| `__select__` | Yes | SELECT statement that defines the view | +| `__schema__` | No | Database schema (default: None/public) | +| `__with_data__` | No | Populate data on creation (default: True) | + +## Querying Materialized Views + +```python +from sqlalchemy import select + +with engine.connect() as conn: + # Query all rows + result = conn.execute(select(MonthlySales.as_table())).fetchall() + + # Query with filtering using .c accessor + result = conn.execute( + select(MonthlySales.as_table()) + .where(MonthlySales.c.order_count > 10) + .order_by(MonthlySales.c.revenue.desc()) + ).fetchall() + + for row in result: + print(f"{row.month}: {row.order_count} orders, ${row.revenue}") +``` + +## Refreshing Materialized Views + +Materialized views become stale when underlying data changes. Refresh to update: + +```python +with engine.begin() as conn: + # Using the class method (declarative) + MonthlySales.refresh(conn) + + # Or using the view object + MonthlySales.as_view().refresh(conn) + + # Concurrent refresh (requires a unique index, doesn't block reads) + MonthlySales.refresh(conn, concurrently=True) + + # Refresh without data (empties the view) + MonthlySales.refresh(conn, with_data=False) +``` + +!!! warning "Concurrent Refresh Requirements" + `REFRESH MATERIALIZED VIEW CONCURRENTLY` requires: + + - A unique index on the materialized view + - The view must already contain data (can't be empty) + +## Stale Data Behavior + +!!! warning "Materialized Views Don't Auto-Update" + Unlike regular views, materialized views show **stale data** until refreshed. + +```python +with engine.begin() as conn: + # Insert new data into underlying table + conn.execute(insert(Order.__table__).values(...)) + + # Materialized view still shows old data! + # Refresh to see new data + MonthlySales.refresh(conn) + + # Now the view reflects the changes + result = conn.execute(select(MonthlySales.as_table())).fetchall() +``` + +## Auto-Refresh (ORM) + +SQLAlchemy-PGView can automatically refresh materialized views when watched tables are modified via ORM: + +```python +from sqlalchemy.orm import Session + +# Enable auto-refresh when Order table changes +MonthlySales.auto_refresh_on(Session, Order.__table__) + +# Now ORM commits automatically refresh the view +with Session(engine) as session: + session.add(Order(total=Decimal("99.99"), created_at=datetime.now())) + session.commit() # MonthlySales is refreshed automatically! +``` + +!!! tip "Custom Session Class" + For isolated testing or specific workflows, create a custom Session subclass: + + ```python + class AnalyticsSession(Session): + pass + + MonthlySales.auto_refresh_on(AnalyticsSession, Order.__table__) + ``` + +## Auto-Refresh (Core) + +For SQLAlchemy Core (without ORM), use `AutoRefreshContext`: + +```python +from sqlalchemy_pgview import AutoRefreshContext + +# Get the underlying view and table objects +mview = MonthlySales.as_view() +orders_table = Order.__table__ + +with engine.begin() as conn: + with AutoRefreshContext(conn, mview, orders_table): + conn.execute(insert(orders_table).values(total=100, created_at=datetime.now())) + conn.execute(insert(orders_table).values(total=200, created_at=datetime.now())) + # View is refreshed when exiting the context + + # View now shows updated data + result = conn.execute(select(MonthlySales.as_table())).fetchone() +``` + +!!! warning "Auto-Refresh Considerations" + - **Performance**: Each commit triggers a refresh. For high-frequency writes, use scheduled refresh instead. + - **Exceptions**: `AutoRefreshContext` only refreshes on successful exit (no exception). + - **ORM-only**: `auto_refresh_on()` only catches changes made through the ORM Session, not raw SQL. + +## Refresh Strategies + +| Data Freshness Requirement | Strategy | +|---------------------------|----------| +| Real-time (careful!) | Auto-refresh on commit | +| Minutes | Frequent scheduled refresh | +| Hours | Hourly cron job | +| Daily | Nightly refresh | +| On-demand | Manual refresh before queries | + +## Imperative API (Alternative) + +For SQLAlchemy Core or when declarative style isn't suitable: + +```python +from sqlalchemy import MetaData, Table, Column, Integer, Numeric, DateTime, select, func +from sqlalchemy_pgview import MaterializedView, CreateMaterializedView, RefreshMaterializedView + +metadata = MetaData() + +orders = Table( + "orders", metadata, + Column("id", Integer, primary_key=True), + Column("total", Numeric(10, 2)), + Column("created_at", DateTime), +) + +# Define materialized view +monthly_sales = MaterializedView( + "monthly_sales", + select( + func.date_trunc('month', orders.c.created_at).label("month"), + func.count(orders.c.id).label("order_count"), + func.sum(orders.c.total).label("revenue"), + ).group_by(func.date_trunc('month', orders.c.created_at)), + metadata=metadata, + with_data=True, +) + +# Create tables and views +metadata.create_all(engine) + +# Refresh +with engine.begin() as conn: + monthly_sales.refresh(conn) + # Or using DDL directly + conn.execute(RefreshMaterializedView(monthly_sales, concurrently=True)) +``` + +## Dropping Materialized Views + +Materialized views are automatically dropped when using `metadata.drop_all()`. For manual control: + +```python +from sqlalchemy_pgview import DropMaterializedView + +with engine.begin() as conn: + # Drop using view object + conn.execute(DropMaterializedView(MonthlySales.as_view())) + + # Drop by name with options + conn.execute(DropMaterializedView("old_mv", if_exists=True)) + + # Drop with cascade (drops dependent objects) + conn.execute(DropMaterializedView("base_mv", cascade=True)) +``` + +## List Registered Views + +```python +from sqlalchemy_pgview import get_views + +# Get all registered views from metadata (includes materialized views) +views = get_views(Base.metadata) +print(views) # {'monthly_sales': } +``` + +## Complete Example + +```python +from decimal import Decimal +from datetime import datetime +from sqlalchemy import create_engine, select, func, String, Numeric, Integer, DateTime +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session +from sqlalchemy_pgview import MaterializedViewBase + +class Base(DeclarativeBase): + pass + +class Product(Base): + __tablename__ = "products" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + category: Mapped[str] = mapped_column(String(50)) + +class Sale(Base): + __tablename__ = "sales" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(Integer) + quantity: Mapped[int] = mapped_column(Integer) + amount: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + sold_at: Mapped[datetime] = mapped_column(DateTime) + +class CategorySales(MaterializedViewBase, Base): + __tablename__ = "category_sales" + __select__ = select( + Product.category, + func.count(Sale.id).label("sale_count"), + func.sum(Sale.quantity).label("total_quantity"), + func.sum(Sale.amount).label("total_revenue"), + ).select_from( + Sale.__table__.join(Product.__table__, Sale.product_id == Product.id) + ).group_by(Product.category) + +# Setup +engine = create_engine("postgresql://...") +Base.metadata.create_all(engine) + +# Enable auto-refresh +CategorySales.auto_refresh_on(Session, Sale.__table__) + +# Add data - view refreshes automatically on commit +with Session(engine) as session: + session.add(Sale(product_id=1, quantity=5, amount=Decimal("49.95"), sold_at=datetime.now())) + session.commit() + +# Query fresh data +with engine.connect() as conn: + results = conn.execute( + select(CategorySales.as_table()).order_by(CategorySales.c.total_revenue.desc()) + ).fetchall() + + for row in results: + print(f"{row.category}: {row.sale_count} sales, ${row.total_revenue}") +``` diff --git a/docs/guide/views.md b/docs/guide/views.md new file mode 100644 index 0000000..e932098 --- /dev/null +++ b/docs/guide/views.md @@ -0,0 +1,242 @@ +# Views + +Views are virtual tables whose contents are defined by a query. Unlike regular tables, views don't store data - they compute results on each access. + +## Creating Views (Declarative) + +The recommended way to create views is using the declarative pattern with multiple inheritance: + +```python +from sqlalchemy import select, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy_pgview import ViewBase + +class Base(DeclarativeBase): + pass + +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + email: Mapped[str] = mapped_column(String(100)) + is_active: Mapped[bool] = mapped_column(default=True) + +# Define a view using ViewBase +class ActiveUsers(ViewBase, Base): + __tablename__ = "active_users" + __select__ = select(User.id, User.name, User.email).where(User.is_active == True) + +# Create tables and views together +engine = create_engine("postgresql://user:pass@localhost/mydb") +Base.metadata.create_all(engine) +``` + +### ViewBase Attributes + +| Attribute | Required | Description | +|-----------|----------|-------------| +| `__tablename__` | Yes | Name of the view in the database | +| `__select__` | Yes | SELECT statement that defines the view | +| `__schema__` | No | Database schema (default: None/public) | + +## Querying Views + +```python +from sqlalchemy import select + +with engine.connect() as conn: + # Query all rows + result = conn.execute(select(ActiveUsers.as_table())).fetchall() + + # Query with filtering using .c accessor + result = conn.execute( + select(ActiveUsers.as_table()) + .where(ActiveUsers.c.name.like('A%')) + .order_by(ActiveUsers.c.name) + ).fetchall() + + for row in result: + print(f"{row.name} ({row.email})") +``` + +## Views with Joins + +Views can encapsulate complex joins: + +```python +from sqlalchemy import func + +class Author(Base): + __tablename__ = "authors" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + +class Book(Base): + __tablename__ = "books" + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + author_id: Mapped[int] = mapped_column(Integer) + price: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + +# View with one-to-many join and aggregation +class AuthorStats(ViewBase, Base): + __tablename__ = "author_stats" + __select__ = select( + Author.id, + Author.name, + func.count(Book.id).label("book_count"), + func.avg(Book.price).label("avg_price"), + ).select_from( + Author.__table__.outerjoin(Book.__table__, Author.id == Book.author_id) + ).group_by(Author.id, Author.name) +``` + +## View Update Behavior + +!!! info "Views Always Show Current Data" + Regular views are just stored queries. When you INSERT, UPDATE, or DELETE rows in the underlying tables, the view immediately reflects those changes. + +```python +from sqlalchemy.orm import Session + +with Session(engine) as session: + # Add new user + session.add(User(name="Alice", email="alice@example.com", is_active=True)) + session.commit() + +# View immediately shows Alice +with engine.connect() as conn: + result = conn.execute(select(ActiveUsers.as_table())).fetchall() + # Alice is included! +``` + +## Imperative API (Alternative) + +For SQLAlchemy Core or when declarative style isn't suitable: + +```python +from sqlalchemy import MetaData, Table, Column, Integer, String, Boolean, select +from sqlalchemy_pgview import View + +metadata = MetaData() + +users = Table( + "users", metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100)), + Column("email", String(100)), + Column("is_active", Boolean, default=True), +) + +# Define the view +active_users = View( + name="active_users", + selectable=select(users.c.id, users.c.name, users.c.email) + .where(users.c.is_active == True), + metadata=metadata, +) + +# Create tables and views +metadata.create_all(engine) + +# Query the view +with engine.connect() as conn: + result = conn.execute(select(active_users.as_table())).fetchall() +``` + +## Dropping Views + +Views are automatically dropped when using `metadata.drop_all()`. For manual control: + +```python +from sqlalchemy_pgview import DropView + +with engine.begin() as conn: + # Drop using view object + conn.execute(DropView(ActiveUsers.as_view())) + + # Drop by name with options + conn.execute(DropView("old_view", if_exists=True)) + + # Drop with cascade (drops dependent objects) + conn.execute(DropView("base_view", cascade=True)) +``` + +## List Registered Views + +```python +from sqlalchemy_pgview import get_views + +# Get all registered views from metadata +views = get_views(Base.metadata) +print(views) # {'active_users': } +``` + +## Complete Example + +```python +from decimal import Decimal +from sqlalchemy import create_engine, select, func, String, Numeric, Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session +from sqlalchemy_pgview import ViewBase + +class Base(DeclarativeBase): + pass + +class Product(Base): + __tablename__ = "products" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + category: Mapped[str] = mapped_column(String(50)) + price: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + in_stock: Mapped[bool] = mapped_column(default=True) + +# View for available products only +class AvailableProducts(ViewBase, Base): + __tablename__ = "available_products" + __select__ = select( + Product.id, + Product.name, + Product.category, + Product.price, + ).where(Product.in_stock == True) + +# View with aggregation by category +class CategoryPricing(ViewBase, Base): + __tablename__ = "category_pricing" + __select__ = select( + Product.category, + func.count(Product.id).label("product_count"), + func.min(Product.price).label("min_price"), + func.max(Product.price).label("max_price"), + func.avg(Product.price).label("avg_price"), + ).where(Product.in_stock == True).group_by(Product.category) + +# Setup +engine = create_engine("postgresql://...") +Base.metadata.create_all(engine) + +# Add products +with Session(engine) as session: + session.add_all([ + Product(name="Widget", category="Tools", price=Decimal("9.99")), + Product(name="Gadget", category="Electronics", price=Decimal("49.99")), + Product(name="Doohickey", category="Tools", price=Decimal("19.99")), + ]) + session.commit() + +# Query views +with engine.connect() as conn: + # Get available products + available = conn.execute(select(AvailableProducts.as_table())).fetchall() + print(f"Available products: {len(available)}") + + # Get category pricing + pricing = conn.execute( + select(CategoryPricing.as_table()) + .order_by(CategoryPricing.c.avg_price.desc()) + ).fetchall() + + for row in pricing: + print(f"{row.category}: {row.product_count} products, avg ${row.avg_price:.2f}") +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..3e4cc09 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,172 @@ +# SQLAlchemy-PGView + +**A SQLAlchemy 2.0+ extension that provides first-class support for PostgreSQL views and materialized views.** + +[![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.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![SQLAlchemy 2.0+](https://img.shields.io/badge/SQLAlchemy-2.0+-green.svg)](https://www.sqlalchemy.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +--- + +## Features + +- **Declarative Views** - Class-based view definitions using multiple inheritance with SQLAlchemy ORM +- **View & MaterializedView Classes** - Define PostgreSQL views as Python objects with full DDL support +- **Alembic Integration** - Database migration operations (`op.create_view()`, `op.drop_view()`, etc.) +- **Auto-Refresh** - Automatically refresh materialized views on data changes +- **Async Support** - Works with asyncpg and SQLAlchemy's async engines +- **Dependency Tracking** - Query PostgreSQL system catalogs for view dependencies +- **Type Safety** - Full type annotations for modern Python development + +## Requirements + +- Python 3.10+ +- SQLAlchemy 2.0+ +- PostgreSQL 12+ +- Alembic 1.10+ (optional, for migrations) + +## Installation + +=== "Base package" + ```bash + uv pip install "sqlalchemy-pgview" + ``` +=== "With alembic support" + ```bash + uv pip install "sqlalchemy-pgview[alembic]" + ``` + +## Quick Start + +The recommended way to define views is using the **declarative pattern** with multiple inheritance. This integrates seamlessly with SQLAlchemy ORM models. + +### Define Your Models and Views + +```python +from decimal import Decimal +from sqlalchemy import create_engine, select, func, String, Numeric, Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session +from sqlalchemy_pgview import ViewBase, MaterializedViewBase + +# Define your base and models +class Base(DeclarativeBase): + pass + +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + is_active: Mapped[bool] = mapped_column(default=True) + +class Order(Base): + __tablename__ = "orders" + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(Integer) + total: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + +# Define a regular view (computed on every query) +class ActiveUsers(ViewBase, Base): + __tablename__ = "active_users" + __select__ = select(User.id, User.name).where(User.is_active == True) + +# Define a materialized view (cached results, needs refresh) +class UserStats(MaterializedViewBase, Base): + __tablename__ = "user_stats" + __select__ = select( + User.id.label("user_id"), + User.name, + func.count(Order.id).label("order_count"), + func.coalesce(func.sum(Order.total), 0).label("total_spent"), + ).select_from(User.__table__.outerjoin(Order.__table__, User.id == Order.user_id) + ).group_by(User.id, User.name) + +# Create everything (tables + views) +engine = create_engine("postgresql://user:pass@localhost/mydb") +Base.metadata.create_all(engine) +``` + +### Query Views + +```python +from sqlalchemy import select + +with engine.connect() as conn: + # Query regular view (always shows current data) + result = conn.execute(select(ActiveUsers.as_table())).fetchall() + for row in result: + print(f"{row.name}") + + # Query materialized view (shows cached data) + stats = conn.execute(select(UserStats.as_table())).fetchall() + for stat in stats: + print(f"{stat.name}: {stat.order_count} orders, ${stat.total_spent}") +``` + +### Refresh Materialized Views + +Materialized views store cached results - refresh them when data changes: + +```python +with engine.begin() as conn: + UserStats.refresh(conn) + + # Concurrent refresh (allows reads during refresh, requires unique index) + UserStats.refresh(conn, concurrently=True) +``` + +### Auto-Refresh on Data Changes + +Automatically refresh materialized views when underlying data changes: + +```python +from sqlalchemy.orm import Session + +# Enable auto-refresh when Order table changes +UserStats.auto_refresh_on(Session, Order.__table__) + +# Now commits automatically refresh the materialized view +with Session(engine) as session: + session.add(Order(user_id=1, total=Decimal("100.00"))) + session.commit() # UserStats is automatically refreshed +``` + +## Next Steps + +
+ +- :material-book-open-variant:{ .lg .middle } **Views** + + --- + + Learn about regular views in depth + + [:octicons-arrow-right-24: Views](guide/views.md) + +- :material-database-refresh:{ .lg .middle } **Materialized Views** + + --- + + Caching, refreshing, and auto-refresh + + [:octicons-arrow-right-24: Materialized views](guide/materialized-views.md) + +- :material-source-branch:{ .lg .middle } **Alembic Migrations** + + --- + + Database migrations with autogenerate + + [:octicons-arrow-right-24: Alembic guide](guide/alembic.md) + +- :material-api:{ .lg .middle } **API Reference** + + --- + + Complete API documentation + + [:octicons-arrow-right-24: API reference](api/views/view.md) + +
diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..ea7d580 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,122 @@ +site_name: SQLAlchemy-PGView +site_description: SQLAlchemy extension for PostgreSQL views and materialized views +site_url: https://d3vyce.github.io/sqlalchemy-pgview +repo_url: https://github.com/d3vyce/sqlalchemy-pgview +repo_name: d3vyce/sqlalchemy-pgview +edit_uri: edit/main/docs/ + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.instant + - navigation.instant.prefetch + - navigation.tracking + - navigation.sections + - navigation.expand + - navigation.top + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + - content.tabs.link + icon: + repo: fontawesome/brands/github + +plugins: + - search + - mkdocstrings: + handlers: + python: + options: + extensions: + - griffe_typingdoc + show_root_heading: true + show_if_no_docstring: true + inherited_members: true + members_order: source + separate_signature: true + unwrap_annotated: true + filters: + - '!^_' + merge_init_into_class: true + docstring_section_style: spacy + signature_crossrefs: true + show_symbol_type_heading: true + show_symbol_type_toc: true + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - admonition + - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - attr_list + - md_in_html + - tables + - toc: + permalink: true + +nav: + - Home: index.md + - User Guide: + - Views: guide/views.md + - Materialized Views: guide/materialized-views.md + - Async Support: guide/async.md + - Alembic Migrations: guide/alembic.md + - Dependency Tracking: guide/dependencies.md + - API Reference: + - Views: + - View: api/views/view.md + - MaterializedView: api/views/materialized-view.md + - AutoRefreshContext: api/views/auto-refresh-context.md + - get_views: api/views/get-views.md + - get_materialized_views: api/views/get-materialized-views.md + - DDL Operations: + - CreateView: api/ddl/create-view.md + - DropView: api/ddl/drop-view.md + - CreateMaterializedView: api/ddl/create-materialized-view.md + - DropMaterializedView: api/ddl/drop-materialized-view.md + - RefreshMaterializedView: api/ddl/refresh-materialized-view.md + - Dependencies: + - ViewInfo: api/dependencies/view-info.md + - ViewDependency: api/dependencies/view-dependency.md + - get_view_definition: api/dependencies/get-view-definition.md + - get_all_views: api/dependencies/get-all-views.md + - get_view_dependencies: api/dependencies/get-view-dependencies.md + - get_dependency_order: api/dependencies/get-dependency-order.md + - get_reverse_dependencies: api/dependencies/get-reverse-dependencies.md + - Alembic: + - CreateViewOp: api/alembic/create-view-op.md + - DropViewOp: api/alembic/drop-view-op.md + - CreateMaterializedViewOp: api/alembic/create-materialized-view-op.md + - DropMaterializedViewOp: api/alembic/drop-materialized-view-op.md + - RefreshMaterializedViewOp: api/alembic/refresh-materialized-view-op.md + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/d3vyce/sqlalchemy-pgview diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fea72b5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[project] +name = "sqlalchemy-pgview" +version = "0.1.0" +description = "SQLAlchemy extension for PostgreSQL views and materialized views" +readme = "README.md" +authors = [ + { name = "d3vyce", email = "nicolas.sudres@proton.me" } +] +requires-python = ">=3.10" +dependencies = [ + "annotated-doc>=0.0.1", + "sqlalchemy>=2.0", +] +license = { text = "MIT" } +keywords = ["sqlalchemy", "postgresql", "views", "materialized-views", "database"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Database", + "Typing :: Typed", +] + +[project.optional-dependencies] +alembic = ["alembic>=1.10"] + +[build-system] +requires = ["uv_build>=0.9.17,<0.10.0"] +build-backend = "uv_build" + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "TCH", # flake8-type-checking + "RUF", # ruff-specific +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.lint.isort] +known-first-party = ["sqlalchemy_pgview"] + +[tool.ty.environment] +python-version = "3.10" + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[dependency-groups] +dev = [ + "asyncpg>=0.31.0", + "griffe-typingdoc>=0.2", + "mkdocs-material>=9.7.1", + "mkdocstrings[python]>=0.28", + "prek>=0.2.27", + "psycopg2-binary>=2.9.11", + "pytest-asyncio>=0.23", + "pytest-cov>=7.0.0", + "pytest>=8.0", + "ruff>=0.8", + "ty>=0.0.1a6", +] diff --git a/src/sqlalchemy_pgview/__init__.py b/src/sqlalchemy_pgview/__init__.py new file mode 100644 index 0000000..acfa186 --- /dev/null +++ b/src/sqlalchemy_pgview/__init__.py @@ -0,0 +1,87 @@ +"""SQLAlchemy extension for PostgreSQL views and materialized views. + +This package provides SQLAlchemy support for creating, managing, and +querying PostgreSQL views and materialized views. + +Example: +```python +from sqlalchemy import select, func, MetaData +from sqlalchemy_pgview import View, MaterializedView + +metadata = MetaData() + +# Create a regular view +user_stats = View( + "user_stats", + select(User.id, func.count(Order.id).label("order_count")) + .join(Order) + .group_by(User.id), + metadata=metadata, +) + +# Create a materialized view +monthly_sales = MaterializedView( + "monthly_sales", + select( + func.date_trunc('month', Order.created_at).label("month"), + func.sum(Order.total).label("total_sales") + ).group_by(func.date_trunc('month', Order.created_at)), + with_data=True, + metadata=metadata, +) + +metadata.create_all(engine) +``` +""" + +from sqlalchemy_pgview.ddl import ( + CreateMaterializedView, + CreateView, + DropMaterializedView, + DropView, + RefreshMaterializedView, +) +from sqlalchemy_pgview.declarative import ( + MaterializedViewBase, + ViewBase, +) +from sqlalchemy_pgview.dependencies import ( + ViewDependency, + ViewInfo, + get_all_views, + get_dependency_order, + get_reverse_dependencies, + get_view_definition, + get_view_dependencies, +) +from sqlalchemy_pgview.view import ( + AutoRefreshContext, + MaterializedView, + View, + get_materialized_views, + get_views, +) + +__version__ = "0.1.0" + +__all__ = [ + "AutoRefreshContext", + "CreateMaterializedView", + "CreateView", + "DropMaterializedView", + "DropView", + "MaterializedView", + "MaterializedViewBase", + "RefreshMaterializedView", + "View", + "ViewBase", + "ViewDependency", + "ViewInfo", + "get_all_views", + "get_dependency_order", + "get_materialized_views", + "get_reverse_dependencies", + "get_view_definition", + "get_view_dependencies", + "get_views", +] diff --git a/src/sqlalchemy_pgview/alembic/__init__.py b/src/sqlalchemy_pgview/alembic/__init__.py new file mode 100644 index 0000000..d5707be --- /dev/null +++ b/src/sqlalchemy_pgview/alembic/__init__.py @@ -0,0 +1,42 @@ +"""Alembic integration for sqlalchemy-pgview. + +Import this module to register Alembic operations and autogenerate support: + + import sqlalchemy_pgview.alembic + +This enables: + - op.create_view() + - op.drop_view() + - op.create_materialized_view() + - op.drop_materialized_view() + - op.refresh_materialized_view() + - Autogenerate detection of view changes +""" + +# Import autogenerate to register comparators and renderers +from sqlalchemy_pgview.alembic import autogenerate as _autogenerate # noqa: F401 +from sqlalchemy_pgview.alembic.ops import ( + CreateMaterializedViewOp, + CreateViewOp, + DropMaterializedViewOp, + DropViewOp, + RefreshMaterializedViewOp, + create_materialized_view, + create_view, + drop_materialized_view, + drop_view, + refresh_materialized_view, +) + +__all__ = [ + "CreateMaterializedViewOp", + "CreateViewOp", + "DropMaterializedViewOp", + "DropViewOp", + "RefreshMaterializedViewOp", + "create_materialized_view", + "create_view", + "drop_materialized_view", + "drop_view", + "refresh_materialized_view", +] diff --git a/src/sqlalchemy_pgview/alembic/autogenerate.py b/src/sqlalchemy_pgview/alembic/autogenerate.py new file mode 100644 index 0000000..16cc472 --- /dev/null +++ b/src/sqlalchemy_pgview/alembic/autogenerate.py @@ -0,0 +1,215 @@ +"""Alembic autogenerate support for views. + +This module provides automatic detection of view changes for Alembic migrations. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from alembic.autogenerate import comparators, renderers + +from sqlalchemy_pgview.alembic.ops import ( + CreateMaterializedViewOp, + CreateViewOp, + DropMaterializedViewOp, + DropViewOp, +) +from sqlalchemy_pgview.view import ( + _MATERIALIZED_VIEWS_KEY, + _VIEWS_KEY, +) + +if TYPE_CHECKING: + from alembic.autogenerate.api import AutogenContext + from alembic.operations.ops import UpgradeOps + from sqlalchemy import MetaData + from sqlalchemy.engine import Connection + + +def _get_db_views(connection: Connection, schema: str | None = None) -> dict[str, dict[str, Any]]: + """Get all views from the database. + + Returns dict mapping view key to {name, schema, definition, is_materialized} + """ + from sqlalchemy import text + + query = """ + SELECT + c.relname as name, + n.nspname as schema, + pg_get_viewdef(c.oid, true) as definition, + c.relkind = 'm' as is_materialized + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN ('v', 'm') + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + """ + + params: dict[str, str] = {} + if schema: + query += " AND n.nspname = :schema" + params["schema"] = schema + + result = connection.execute(text(query), params) + + views = {} + for row in result.fetchall(): + key = f"{row[1]}.{row[0]}" if row[1] != "public" else row[0] + views[key] = { + "name": row[0], + "schema": row[1] if row[1] != "public" else None, + "definition": row[2], + "is_materialized": row[3], + } + + return views + + +def _normalize_definition(definition: str | None) -> str: + """Normalize a view definition for comparison.""" + if not definition: + return "" + # Remove whitespace variations + import re + + normalized = re.sub(r"\s+", " ", definition.strip()) + # Remove trailing semicolon + normalized = normalized.rstrip(";").strip() + return normalized.lower() + + +@comparators.dispatch_for("schema") +def compare_views( + autogen_context: AutogenContext, + upgrade_ops: UpgradeOps, + schemas: list[str | None], +) -> None: + """Compare views between metadata and database. + + This is called by Alembic's autogenerate to detect view changes. + """ + metadata: MetaData = autogen_context.metadata # type: ignore[invalid-assignment] + connection: Connection = autogen_context.connection # type: ignore[invalid-assignment] + + if connection is None or connection.dialect.name != "postgresql": + return + + # Get views from metadata + metadata_views = metadata.info.get(_VIEWS_KEY, {}) + metadata_mviews = metadata.info.get(_MATERIALIZED_VIEWS_KEY, {}) + + # Get views from database + db_views = _get_db_views(connection) + + # Separate db views by type + db_regular_views = {k: v for k, v in db_views.items() if not v["is_materialized"]} + db_materialized_views = {k: v for k, v in db_views.items() if v["is_materialized"]} + + # Find views to create (in metadata but not in db) + for key, view in metadata_views.items(): + if key not in db_regular_views: + # Compile the selectable to SQL + select_sql = view.selectable.compile( + dialect=connection.dialect, + compile_kwargs={"literal_binds": True}, + ) + upgrade_ops.ops.append( + CreateViewOp( + view.name, + str(select_sql), + schema=view.schema, + or_replace=True, + ) + ) + + for key, mview in metadata_mviews.items(): + if key not in db_materialized_views: + select_sql = mview.selectable.compile( + dialect=connection.dialect, + compile_kwargs={"literal_binds": True}, + ) + upgrade_ops.ops.append( + CreateMaterializedViewOp( + mview.name, + str(select_sql), + schema=mview.schema, + with_data=mview.with_data, + ) + ) + + # Find views to drop (in db but not in metadata) + for key, db_view in db_regular_views.items(): + if key not in metadata_views: + upgrade_ops.ops.append( + DropViewOp( + db_view["name"], + schema=db_view["schema"], + if_exists=True, + ) + ) + + for key, db_mview in db_materialized_views.items(): + if key not in metadata_mviews: + upgrade_ops.ops.append( + DropMaterializedViewOp( + db_mview["name"], + schema=db_mview["schema"], + if_exists=True, + ) + ) + + +# Renderers for generating migration code + + +@renderers.dispatch_for(CreateViewOp) +def render_create_view(autogen_context: AutogenContext, op: CreateViewOp) -> str: + """Render CreateViewOp as Python code.""" + args = [repr(op.view_name), f'"""\n{op.select_query}\n"""'] + if op.schema: + args.append(f"schema={op.schema!r}") + if op.or_replace: + args.append("or_replace=True") + return f"op.create_view({', '.join(args)})" + + +@renderers.dispatch_for(DropViewOp) +def render_drop_view(autogen_context: AutogenContext, op: DropViewOp) -> str: + """Render DropViewOp as Python code.""" + args = [repr(op.view_name)] + if op.schema: + args.append(f"schema={op.schema!r}") + if not op.if_exists: + args.append("if_exists=False") + if op.cascade: + args.append("cascade=True") + return f"op.drop_view({', '.join(args)})" + + +@renderers.dispatch_for(CreateMaterializedViewOp) +def render_create_materialized_view( + autogen_context: AutogenContext, op: CreateMaterializedViewOp +) -> str: + """Render CreateMaterializedViewOp as Python code.""" + args = [repr(op.view_name), f'"""\n{op.select_query}\n"""'] + if op.schema: + args.append(f"schema={op.schema!r}") + if not op.with_data: + args.append("with_data=False") + return f"op.create_materialized_view({', '.join(args)})" + + +@renderers.dispatch_for(DropMaterializedViewOp) +def render_drop_materialized_view( + autogen_context: AutogenContext, op: DropMaterializedViewOp +) -> str: + """Render DropMaterializedViewOp as Python code.""" + args = [repr(op.view_name)] + if op.schema: + args.append(f"schema={op.schema!r}") + if not op.if_exists: + args.append("if_exists=False") + if op.cascade: + args.append("cascade=True") + return f"op.drop_materialized_view({', '.join(args)})" diff --git a/src/sqlalchemy_pgview/alembic/ops.py b/src/sqlalchemy_pgview/alembic/ops.py new file mode 100644 index 0000000..8676863 --- /dev/null +++ b/src/sqlalchemy_pgview/alembic/ops.py @@ -0,0 +1,349 @@ +"""Alembic operations for PostgreSQL views.""" + +from __future__ import annotations + +from typing import Annotated + +from alembic.operations import MigrateOperation, Operations +from annotated_doc import Doc + + +@Operations.register_operation("create_view") +class CreateViewOp(MigrateOperation): + """Alembic operation to create a view.""" + + def __init__( + self, + view_name: Annotated[str, Doc("Name of the view to create.")], + select_query: Annotated[str, Doc("The SELECT SQL string that defines the view.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + or_replace: Annotated[bool, Doc("If True, use CREATE OR REPLACE VIEW.")] = False, + ) -> None: + self.view_name = view_name + self.select_query = select_query + self.schema = schema + self.or_replace = or_replace + + @classmethod + def create_view( + cls, + operations: Annotated[Operations, Doc("Alembic Operations context.")], + view_name: Annotated[str, Doc("Name of the view to create.")], + select_query: Annotated[str, Doc("The SELECT SQL string that defines the view.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + or_replace: Annotated[bool, Doc("If True, use CREATE OR REPLACE VIEW.")] = False, + ) -> None: + """Create a PostgreSQL view. + + Example in migration:: + + def upgrade(): + op.create_view( + "user_stats", + "SELECT user_id, COUNT(*) as order_count FROM orders GROUP BY user_id", + schema="public", + ) + + def downgrade(): + op.drop_view("user_stats", schema="public") + """ + operations.invoke( + cls( + view_name, + select_query, + schema=schema, + or_replace=or_replace, + ) + ) + + def reverse(self) -> DropViewOp: + """Return the reverse operation (drop view).""" + return DropViewOp(self.view_name, schema=self.schema) + + +@Operations.register_operation("drop_view") +class DropViewOp(MigrateOperation): + """Alembic operation to drop a view.""" + + def __init__( + self, + view_name: Annotated[str, Doc("Name of the view to drop.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = True, + cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False, + ) -> None: + self.view_name = view_name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + + @classmethod + def drop_view( + cls, + operations: Annotated[Operations, Doc("Alembic Operations context.")], + view_name: Annotated[str, Doc("Name of the view to drop.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = True, + cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False, + ) -> None: + """Drop a PostgreSQL view. + + Example in migration:: + + def upgrade(): + op.drop_view("old_view", schema="public", cascade=True) + """ + operations.invoke( + cls( + view_name, + schema=schema, + if_exists=if_exists, + cascade=cascade, + ) + ) + + +@Operations.register_operation("create_materialized_view") +class CreateMaterializedViewOp(MigrateOperation): + """Alembic operation to create a materialized view.""" + + def __init__( + self, + view_name: Annotated[str, Doc("Name of the materialized view to create.")], + select_query: Annotated[str, Doc("The SELECT SQL string that defines the view.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + with_data: Annotated[bool, Doc("If True, populate with data on creation.")] = True, + if_not_exists: Annotated[bool, Doc("If True, don't error if view already exists.")] = False, + ) -> None: + self.view_name = view_name + self.select_query = select_query + self.schema = schema + self.with_data = with_data + self.if_not_exists = if_not_exists + + @classmethod + def create_materialized_view( + cls, + operations: Annotated[Operations, Doc("Alembic Operations context.")], + view_name: Annotated[str, Doc("Name of the materialized view to create.")], + select_query: Annotated[str, Doc("The SELECT SQL string that defines the view.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + with_data: Annotated[bool, Doc("If True, populate with data on creation.")] = True, + if_not_exists: Annotated[bool, Doc("If True, don't error if view already exists.")] = False, + ) -> None: + """Create a PostgreSQL materialized view. + + Example in migration:: + + def upgrade(): + op.create_materialized_view( + "monthly_sales", + ''' + SELECT + date_trunc('month', created_at) as month, + SUM(total) as total_sales + FROM orders + GROUP BY date_trunc('month', created_at) + ''', + schema="public", + with_data=True, + ) + + def downgrade(): + op.drop_materialized_view("monthly_sales", schema="public") + """ + operations.invoke( + cls( + view_name, + select_query, + schema=schema, + with_data=with_data, + if_not_exists=if_not_exists, + ) + ) + + def reverse(self) -> DropMaterializedViewOp: + """Return the reverse operation (drop materialized view).""" + return DropMaterializedViewOp(self.view_name, schema=self.schema) + + +@Operations.register_operation("drop_materialized_view") +class DropMaterializedViewOp(MigrateOperation): + """Alembic operation to drop a materialized view.""" + + def __init__( + self, + view_name: Annotated[str, Doc("Name of the materialized view to drop.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = True, + cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False, + ) -> None: + self.view_name = view_name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + + @classmethod + def drop_materialized_view( + cls, + operations: Annotated[Operations, Doc("Alembic Operations context.")], + view_name: Annotated[str, Doc("Name of the materialized view to drop.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = True, + cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False, + ) -> None: + """Drop a PostgreSQL materialized view. + + Example in migration:: + + def upgrade(): + op.drop_materialized_view("old_mview", schema="public", cascade=True) + """ + operations.invoke( + cls( + view_name, + schema=schema, + if_exists=if_exists, + cascade=cascade, + ) + ) + + +@Operations.register_operation("refresh_materialized_view") +class RefreshMaterializedViewOp(MigrateOperation): + """Alembic operation to refresh a materialized view.""" + + def __init__( + self, + view_name: Annotated[str, Doc("Name of the materialized view to refresh.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + concurrently: Annotated[bool, Doc("If True, refresh without locking reads.")] = False, + with_data: Annotated[bool, Doc("If True, populate with fresh data.")] = True, + ) -> None: + self.view_name = view_name + self.schema = schema + self.concurrently = concurrently + self.with_data = with_data + + @classmethod + def refresh_materialized_view( + cls, + operations: Annotated[Operations, Doc("Alembic Operations context.")], + view_name: Annotated[str, Doc("Name of the materialized view to refresh.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + concurrently: Annotated[bool, Doc("If True, refresh without locking reads.")] = False, + with_data: Annotated[bool, Doc("If True, populate with fresh data.")] = True, + ) -> None: + """Refresh a PostgreSQL materialized view. + + Example in migration:: + + def upgrade(): + op.refresh_materialized_view( + "monthly_sales", + schema="public", + concurrently=True, + ) + """ + operations.invoke( + cls( + view_name, + schema=schema, + concurrently=concurrently, + with_data=with_data, + ) + ) + + +# Implementation functions called by Alembic + + +@Operations.implementation_for(CreateViewOp) +def _create_view_impl(operations: Operations, operation: CreateViewOp) -> None: + """Execute CreateViewOp.""" + fullname = ( + f"{operation.schema}.{operation.view_name}" if operation.schema else operation.view_name + ) + create_stmt = "CREATE OR REPLACE VIEW" if operation.or_replace else "CREATE VIEW" + sql = f"{create_stmt} {fullname} AS {operation.select_query}" + operations.execute(sql) + + +@Operations.implementation_for(DropViewOp) +def _drop_view_impl(operations: Operations, operation: DropViewOp) -> None: + """Execute DropViewOp.""" + fullname = ( + f"{operation.schema}.{operation.view_name}" if operation.schema else operation.view_name + ) + sql = "DROP VIEW" + if operation.if_exists: + sql += " IF EXISTS" + sql += f" {fullname}" + if operation.cascade: + sql += " CASCADE" + operations.execute(sql) + + +@Operations.implementation_for(CreateMaterializedViewOp) +def _create_materialized_view_impl( + operations: Operations, operation: CreateMaterializedViewOp +) -> None: + """Execute CreateMaterializedViewOp.""" + fullname = ( + f"{operation.schema}.{operation.view_name}" if operation.schema else operation.view_name + ) + sql = "CREATE MATERIALIZED VIEW" + if operation.if_not_exists: + sql += " IF NOT EXISTS" + sql += f" {fullname} AS {operation.select_query}" + sql += " WITH DATA" if operation.with_data else " WITH NO DATA" + operations.execute(sql) + + +@Operations.implementation_for(DropMaterializedViewOp) +def _drop_materialized_view_impl(operations: Operations, operation: DropMaterializedViewOp) -> None: + """Execute DropMaterializedViewOp.""" + fullname = ( + f"{operation.schema}.{operation.view_name}" if operation.schema else operation.view_name + ) + sql = "DROP MATERIALIZED VIEW" + if operation.if_exists: + sql += " IF EXISTS" + sql += f" {fullname}" + if operation.cascade: + sql += " CASCADE" + operations.execute(sql) + + +@Operations.implementation_for(RefreshMaterializedViewOp) +def _refresh_materialized_view_impl( + operations: Operations, operation: RefreshMaterializedViewOp +) -> None: + """Execute RefreshMaterializedViewOp.""" + fullname = ( + f"{operation.schema}.{operation.view_name}" if operation.schema else operation.view_name + ) + sql = "REFRESH MATERIALIZED VIEW" + if operation.concurrently: + sql += " CONCURRENTLY" + sql += f" {fullname}" + sql += " WITH DATA" if operation.with_data else " WITH NO DATA" + operations.execute(sql) + + +# Convenience function aliases +create_view = CreateViewOp.create_view +drop_view = DropViewOp.drop_view +create_materialized_view = CreateMaterializedViewOp.create_materialized_view +drop_materialized_view = DropMaterializedViewOp.drop_materialized_view +refresh_materialized_view = RefreshMaterializedViewOp.refresh_materialized_view diff --git a/src/sqlalchemy_pgview/ddl.py b/src/sqlalchemy_pgview/ddl.py new file mode 100644 index 0000000..113d7b3 --- /dev/null +++ b/src/sqlalchemy_pgview/ddl.py @@ -0,0 +1,305 @@ +"""DDL operations for PostgreSQL views and materialized views.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, Any + +from annotated_doc import Doc +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.schema import DDLElement + +if TYPE_CHECKING: + from sqlalchemy.sql.compiler import DDLCompiler + + from sqlalchemy_pgview.view import MaterializedView, View + + +from sqlalchemy_pgview.util import get_table_key + + +class CreateView(DDLElement): + """DDL element to create a PostgreSQL view. + + Example: + ```python + from sqlalchemy_pgview import View + from sqlalchemy_pgview.ddl import CreateView + + view = View("user_stats", select(...)) + engine.execute(CreateView(view)) + ``` + """ + + inherit_cache = False + + def __init__( + self, + view: Annotated[View, Doc("The View to create.")], + *, + or_replace: Annotated[bool, Doc("If True, use CREATE OR REPLACE VIEW.")] = False, + ) -> None: + self.view = view + self.or_replace = or_replace + + +@compiles(CreateView) +def _compile_create_view_default(element: CreateView, compiler: DDLCompiler, **kw: Any) -> str: + """Default CreateView compiler - raises error for non-PostgreSQL.""" + raise NotImplementedError( + f"CreateView is only supported on PostgreSQL, not {compiler.dialect.name}" + ) + + +@compiles(CreateView, "postgresql") +def _compile_create_view(element: CreateView, compiler: DDLCompiler, **kw: Any) -> str: + """Compile CreateView to PostgreSQL DDL.""" + view = element.view + create_stmt = "CREATE OR REPLACE VIEW" if element.or_replace else "CREATE VIEW" + select_sql = compiler.sql_compiler.process(obj=view.selectable, literal_binds=True) + return f"{create_stmt} {view.fullname} AS {select_sql}" + + +class DropView(DDLElement): + """DDL element to drop a PostgreSQL view. + + Example: + ```python + from sqlalchemy_pgview.ddl import DropView + + engine.execute(DropView("user_stats")) + ``` + """ + + inherit_cache = False + + def __init__( + self, + view: Annotated[View | str, Doc("The View object or view name to drop.")], + *, + schema: Annotated[str | None, Doc("Schema name (only used if view is a string).")] = None, + if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = False, + cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False, + ) -> None: + if isinstance(view, str): + self.name = view + self.schema = schema + else: + self.name = view.name + self.schema = view.schema + self.if_exists = if_exists + self.cascade = cascade + + @property + def fullname(self) -> str: + """Return the fully qualified name of the view.""" + return get_table_key(name=self.name, schema=self.schema) + + +@compiles(DropView) +def _compile_drop_view_default(element: DropView, compiler: DDLCompiler, **kw: Any) -> str: + """Default DropView compiler - raises error for non-PostgreSQL.""" + raise NotImplementedError( + f"DropView is only supported on PostgreSQL, not {compiler.dialect.name}" + ) + + +@compiles(DropView, "postgresql") +def _compile_drop_view(element: DropView, compiler: DDLCompiler, **kw: Any) -> str: + """Compile DropView to PostgreSQL DDL.""" + stmt = "DROP VIEW" + if element.if_exists: + stmt += " IF EXISTS" + stmt += f" {element.fullname}" + if element.cascade: + stmt += " CASCADE" + return stmt + + +class CreateMaterializedView(DDLElement): + """DDL element to create a PostgreSQL materialized view. + + Example: + ```python + from sqlalchemy_pgview import MaterializedView + from sqlalchemy_pgview.ddl import CreateMaterializedView + + mview = MaterializedView("monthly_sales", select(...)) + engine.execute(CreateMaterializedView(mview)) + ``` + """ + + inherit_cache = False + + def __init__( + self, + view: Annotated[MaterializedView, Doc("The MaterializedView to create.")], + *, + if_not_exists: Annotated[ + bool, Doc("If True, don't error if view already exists. Requires PostgreSQL 9.3+.") + ] = False, + ) -> None: + self.view = view + self.if_not_exists = if_not_exists + + +@compiles(CreateMaterializedView) +def _compile_create_materialized_view_default( + element: CreateMaterializedView, compiler: DDLCompiler, **kw: Any +) -> str: + """Default CreateMaterializedView compiler - raises error for non-PostgreSQL.""" + raise NotImplementedError( + f"CreateMaterializedView is only supported on PostgreSQL, not {compiler.dialect.name}" + ) + + +@compiles(CreateMaterializedView, "postgresql") +def _compile_create_materialized_view( + element: CreateMaterializedView, compiler: DDLCompiler, **kw: Any +) -> str: + """Compile CreateMaterializedView to PostgreSQL DDL.""" + view = element.view + stmt = "CREATE MATERIALIZED VIEW" + if element.if_not_exists: + stmt += " IF NOT EXISTS" + stmt += f" {view.fullname}" + select_sql = compiler.sql_compiler.process(obj=view.selectable, literal_binds=True) + stmt += f" AS {select_sql}" + if view.with_data: + stmt += " WITH DATA" + else: + stmt += " WITH NO DATA" + return stmt + + +class DropMaterializedView(DDLElement): + """DDL element to drop a PostgreSQL materialized view. + + Example: + ```python + from sqlalchemy_pgview.ddl import DropMaterializedView + + engine.execute(DropMaterializedView("monthly_sales")) + ``` + """ + + inherit_cache = False + + def __init__( + self, + view: Annotated[ + MaterializedView | str, Doc("The MaterializedView object or view name to drop.") + ], + *, + schema: Annotated[str | None, Doc("Schema name (only used if view is a string).")] = None, + if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = False, + cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False, + ) -> None: + if isinstance(view, str): + self.name = view + self.schema = schema + else: + self.name = view.name + self.schema = view.schema + self.if_exists = if_exists + self.cascade = cascade + + @property + def fullname(self) -> str: + """Return the fully qualified name of the view.""" + return get_table_key(name=self.name, schema=self.schema) + + +@compiles(DropMaterializedView) +def _compile_drop_materialized_view_default( + element: DropMaterializedView, compiler: DDLCompiler, **kw: Any +) -> str: + """Default DropMaterializedView compiler - raises error for non-PostgreSQL.""" + raise NotImplementedError( + f"DropMaterializedView is only supported on PostgreSQL, not {compiler.dialect.name}" + ) + + +@compiles(DropMaterializedView, "postgresql") +def _compile_drop_materialized_view( + element: DropMaterializedView, compiler: DDLCompiler, **kw: Any +) -> str: + """Compile DropMaterializedView to PostgreSQL DDL.""" + stmt = "DROP MATERIALIZED VIEW" + if element.if_exists: + stmt += " IF EXISTS" + stmt += f" {element.fullname}" + if element.cascade: + stmt += " CASCADE" + return stmt + + +class RefreshMaterializedView(DDLElement): + """DDL element to refresh a PostgreSQL materialized view. + + Example: + ```python + from sqlalchemy_pgview.ddl import RefreshMaterializedView + + engine.execute(RefreshMaterializedView("monthly_sales", concurrently=True)) + ``` + """ + + inherit_cache = False + + def __init__( + self, + view: Annotated[ + MaterializedView | str, Doc("The MaterializedView object or view name to refresh.") + ], + *, + schema: Annotated[str | None, Doc("Schema name (only used if view is a string).")] = None, + concurrently: Annotated[ + bool, + Doc( + "If True, refresh without locking out concurrent reads. " + "Requires a unique index on the view." + ), + ] = False, + with_data: Annotated[ + bool, Doc("If True, populate with fresh data. If False, empty the view.") + ] = True, + ) -> None: + if isinstance(view, str): + self.name = view + self.schema = schema + else: + self.name = view.name + self.schema = view.schema + self.concurrently = concurrently + self.with_data = with_data + + @property + def fullname(self) -> str: + """Return the fully qualified name of the view.""" + return get_table_key(name=self.name, schema=self.schema) + + +@compiles(RefreshMaterializedView) +def _compile_refresh_materialized_view_default( + element: RefreshMaterializedView, compiler: DDLCompiler, **kw: Any +) -> str: + """Default RefreshMaterializedView compiler - raises error for non-PostgreSQL.""" + raise NotImplementedError( + f"RefreshMaterializedView is only supported on PostgreSQL, not {compiler.dialect.name}" + ) + + +@compiles(RefreshMaterializedView, "postgresql") +def _compile_refresh_materialized_view( + element: RefreshMaterializedView, compiler: DDLCompiler, **kw: Any +) -> str: + """Compile RefreshMaterializedView to PostgreSQL DDL.""" + stmt = "REFRESH MATERIALIZED VIEW" + if element.concurrently: + stmt += " CONCURRENTLY" + stmt += f" {element.fullname}" + if element.with_data: + stmt += " WITH DATA" + else: + stmt += " WITH NO DATA" + return stmt diff --git a/src/sqlalchemy_pgview/declarative.py b/src/sqlalchemy_pgview/declarative.py new file mode 100644 index 0000000..5779a00 --- /dev/null +++ b/src/sqlalchemy_pgview/declarative.py @@ -0,0 +1,298 @@ +"""Declarative base classes for PostgreSQL views. + +This module provides class-based view declarations similar to SQLAlchemy's +declarative ORM models. Use multiple inheritance with DeclarativeBase to +share metadata. + +Example: +```python +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy_pgview import ViewBase, MaterializedViewBase + +class Base(DeclarativeBase): + pass + +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + +class ActiveUsers(ViewBase, Base): + __tablename__ = "active_users" + __select__ = select(User.id, User.name).where(User.is_active == True) + +class UserStats(MaterializedViewBase, Base): + __tablename__ = "user_stats" + __select__ = select(func.count(User.id).label("count")) + +# Create tables AND views together +Base.metadata.create_all(engine) +``` +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, Any, ClassVar + +from annotated_doc import Doc +from sqlalchemy.orm import DeclarativeBase + +from sqlalchemy_pgview.view import ( + MaterializedView, + View, +) + +if TYPE_CHECKING: + from sqlalchemy import Table + from sqlalchemy.orm import Session + from sqlalchemy.sql import Select + + +# Get SQLAlchemy's DeclarativeMeta for compatibility +_SQLAlchemyDeclarativeMeta = type(DeclarativeBase) + + +def _find_metadata_from_bases(bases: tuple[type, ...]) -> Any: + """Find MetaData instance from parent classes. + + Searches through base classes for a metadata attribute, checking both + direct metadata attributes and registry.metadata for DeclarativeBase subclasses. + """ + for base in bases: + if hasattr(base, "metadata"): + inherited_meta = base.metadata + if inherited_meta is not None and hasattr(inherited_meta, "tables"): + return inherited_meta + if hasattr(base, "registry") and hasattr(base.registry, "metadata"): + return base.registry.metadata + return None + + +class ViewDeclarativeMeta(_SQLAlchemyDeclarativeMeta): + """Metaclass for declarative view classes. + + Inherits from SQLAlchemy's DeclarativeMeta to allow multiple inheritance + with DeclarativeBase subclasses. + """ + + def __new__( + mcs, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + **kwargs: Any, + ) -> ViewDeclarativeMeta: + # Mark as abstract to prevent SQLAlchemy from creating a mapper + # This allows inheriting from DeclarativeBase without ORM mapping + if "__abstract__" not in namespace: + namespace["__abstract__"] = True + + cls = super().__new__(mcs, name, bases, namespace) + + # Skip base classes + if name in ("ViewBase", "MaterializedViewBase"): + return cls + + # Get required attributes + tablename = namespace.get("__tablename__") + select_stmt = namespace.get("__select__") + + if tablename is None: + raise TypeError(f"View class {name} must define __tablename__") + + if select_stmt is None: + raise TypeError(f"View class {name} must define __select__") + + # Get optional attributes + schema = namespace.get("__schema__") + + # Get metadata from parent classes (via multiple inheritance) + metadata = _find_metadata_from_bases(bases=bases) + + # Determine if this is a materialized view + is_materialized = any( + hasattr(base, "_is_materialized_view") and base._is_materialized_view for base in bases + ) + + # Create the underlying View or MaterializedView + if is_materialized: + with_data = namespace.get("__with_data__", True) + view = MaterializedView( + name=tablename, + selectable=select_stmt, + schema=schema, + metadata=metadata, + ) + view.with_data = with_data + else: + view = View( + name=tablename, + selectable=select_stmt, + schema=schema, + metadata=metadata, + ) + + cls._view = view + cls._is_materialized_view = is_materialized + cls._table = view.as_table() + + return cls + + @property + def c(cls) -> Any: + """Column collection for the view (shorthand for as_table().c).""" + return cls._table.c # type: ignore[attr-defined] + + @property + def columns(cls) -> Any: + """Column collection for the view.""" + return cls._table.columns # type: ignore[attr-defined] + + +class ViewBase(metaclass=ViewDeclarativeMeta): + """Base class for declarative view definitions. + + Subclass this along with your DeclarativeBase to define a PostgreSQL view. + + Required class attributes: + __tablename__: The name of the view in the database. + __select__: The SELECT statement that defines the view. + + Optional class attributes: + __schema__: The database schema (default: None). + + Example: + ```python + from sqlalchemy.orm import DeclarativeBase + from sqlalchemy_pgview import ViewBase + + class Base(DeclarativeBase): + pass + + class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + is_active: Mapped[bool] + + class ActiveUsers(ViewBase, Base): + __tablename__ = "active_users" + __select__ = select(User.id, User.name).where(User.is_active == True) + + # Create tables AND views + Base.metadata.create_all(engine) + + # Query the view + with engine.connect() as conn: + result = conn.execute(select(ActiveUsers.c.name)).fetchall() + ``` + """ + + __tablename__: ClassVar[str] + __select__: ClassVar[Select[Any]] + __schema__: ClassVar[str | None] = None + + _view: ClassVar[View] + _table: ClassVar[Table] + _is_materialized_view: ClassVar[bool] = False + + def __class_getitem__(cls, item: Any) -> Any: + """Support for generic type hints.""" + return cls + + @classmethod + def as_table(cls) -> Table: + """Return the Table representation for querying.""" + return cls._table + + @classmethod + def as_view(cls) -> View: + """Return the underlying View instance.""" + return cls._view + + +class MaterializedViewBase(metaclass=ViewDeclarativeMeta): + """Base class for declarative materialized view definitions. + + Subclass this along with your DeclarativeBase to define a materialized view. + + Required class attributes: + __tablename__: The name of the materialized view. + __select__: The SELECT statement that defines the view. + + Optional class attributes: + __schema__: The database schema (default: None). + __with_data__: Whether to populate on creation (default: True). + + Example: + ```python + from sqlalchemy.orm import DeclarativeBase + from sqlalchemy_pgview import MaterializedViewBase + + class Base(DeclarativeBase): + pass + + class Order(Base): + __tablename__ = "orders" + id: Mapped[int] = mapped_column(primary_key=True) + total: Mapped[Decimal] + + class OrderStats(MaterializedViewBase, Base): + __tablename__ = "order_stats" + __select__ = select( + func.count(Order.id).label("count"), + func.sum(Order.total).label("total"), + ) + + # Create tables AND views + Base.metadata.create_all(engine) + + # Refresh the materialized view + with engine.begin() as conn: + OrderStats.refresh(conn) + ``` + """ + + __tablename__: ClassVar[str] + __select__: ClassVar[Select[Any]] + __schema__: ClassVar[str | None] = None + __with_data__: ClassVar[bool] = True + + _view: ClassVar[MaterializedView] + _table: ClassVar[Table] + _is_materialized_view: ClassVar[bool] = True + + def __class_getitem__(cls, item: Any) -> Any: + """Support for generic type hints.""" + return cls + + @classmethod + def as_table(cls) -> Table: + """Return the Table representation for querying.""" + return cls._table + + @classmethod + def as_view(cls) -> MaterializedView: + """Return the underlying MaterializedView instance.""" + return cls._view + + @classmethod + def refresh( + cls, + connection: Annotated[Any, Doc("SQLAlchemy connection.")], + *, + concurrently: Annotated[bool, Doc("If True, refresh without blocking reads.")] = False, + with_data: Annotated[bool, Doc("If True, populate with fresh data.")] = True, + ) -> None: + """Refresh the materialized view.""" + cls._view.refresh(connection=connection, concurrently=concurrently, with_data=with_data) + + @classmethod + def auto_refresh_on( + cls, + session_class: Annotated[type[Session], Doc("The Session class to attach events to.")], + *tables: Annotated[Table, Doc("Tables to watch for changes.")], + concurrently: Annotated[bool, Doc("If True, use CONCURRENTLY when refreshing.")] = False, + ) -> None: + """Enable automatic refresh when watched tables are modified.""" + cls._view.auto_refresh_on(session_class, *tables, concurrently=concurrently) diff --git a/src/sqlalchemy_pgview/dependencies.py b/src/sqlalchemy_pgview/dependencies.py new file mode 100644 index 0000000..ad56ffc --- /dev/null +++ b/src/sqlalchemy_pgview/dependencies.py @@ -0,0 +1,274 @@ +"""Dependency tracking for PostgreSQL views.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Annotated + +from annotated_doc import Doc +from sqlalchemy import text + +if TYPE_CHECKING: + from sqlalchemy.engine import Connection + + +from sqlalchemy_pgview.util import get_table_key + + +@dataclass +class ViewInfo: + """Information about a view from the database.""" + + name: Annotated[str, Doc("View name.")] + schema: Annotated[str | None, Doc("Schema name.")] + definition: Annotated[str, Doc("SQL definition of the view.")] + is_materialized: Annotated[bool, Doc("Whether this is a materialized view.")] + + @property + def fullname(self) -> str: + """Return the fully qualified name of the view.""" + return get_table_key(name=self.name, schema=self.schema) + + +@dataclass +class ViewDependency: + """Represents a dependency between views.""" + + dependent_view: Annotated[str, Doc("Name of the dependent view.")] + dependent_schema: Annotated[str, Doc("Schema of the dependent view.")] + referenced_view: Annotated[str, Doc("Name of the referenced view.")] + referenced_schema: Annotated[str, Doc("Schema of the referenced view.")] + + @property + def dependent_fullname(self) -> str: + """Return the fully qualified name of the dependent view.""" + return get_table_key(name=self.dependent_view, schema=self.dependent_schema) + + @property + def referenced_fullname(self) -> str: + """Return the fully qualified name of the referenced view.""" + return get_table_key(name=self.referenced_view, schema=self.referenced_schema) + + +def get_view_definition( + connection: Annotated[Connection, Doc("SQLAlchemy connection.")], + view_name: Annotated[str, Doc("Name of the view.")], + schema: Annotated[str, Doc("Schema name.")] = "public", +) -> str | None: + """Get the SQL definition of a view.""" + result = connection.execute( + text(""" + SELECT pg_get_viewdef(c.oid, true) as definition + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = :view_name + AND n.nspname = :schema + AND c.relkind IN ('v', 'm') + """), + {"view_name": view_name, "schema": schema}, + ) + row = result.fetchone() + return row[0] if row else None + + +def get_all_views( + connection: Annotated[Connection, Doc("SQLAlchemy connection.")], + schema: Annotated[str | None, Doc("If provided, only return views in this schema.")] = None, + include_materialized: Annotated[bool, Doc("If True, include materialized views.")] = True, +) -> list[ViewInfo]: + """Get all views in the database.""" + relkinds = "('v', 'm')" if include_materialized else "('v')" + + query = f""" + SELECT + c.relname as name, + n.nspname as schema, + pg_get_viewdef(c.oid, true) as definition, + c.relkind = 'm' as is_materialized + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN {relkinds} + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + """ + + params: dict[str, str] = {} + if schema: + query += " AND n.nspname = :schema" + params["schema"] = schema + + query += " ORDER BY n.nspname, c.relname" + + result = connection.execute(text(query), params) + return [ + ViewInfo( + name=row[0], + schema=row[1], + definition=row[2], + is_materialized=row[3], + ) + for row in result.fetchall() + ] + + +def get_view_dependencies( + connection: Annotated[Connection, Doc("SQLAlchemy connection.")], + view_name: Annotated[ + str | None, Doc("If provided, only return dependencies for this view.") + ] = None, + schema: Annotated[str, Doc("Schema name for filtering.")] = "public", +) -> list[ViewDependency]: + """Get dependencies between views. + + This queries the pg_depend system catalog to find which views + depend on other views. + """ + query = """ + SELECT DISTINCT + dependent.relname as dependent_view, + dep_ns.nspname as dependent_schema, + referenced.relname as referenced_view, + ref_ns.nspname as referenced_schema + FROM pg_depend d + JOIN pg_rewrite r ON r.oid = d.objid + JOIN pg_class dependent ON dependent.oid = r.ev_class + JOIN pg_class referenced ON referenced.oid = d.refobjid + JOIN pg_namespace dep_ns ON dep_ns.oid = dependent.relnamespace + JOIN pg_namespace ref_ns ON ref_ns.oid = referenced.relnamespace + WHERE d.classid = 'pg_rewrite'::regclass + AND d.deptype = 'n' + AND dependent.relkind IN ('v', 'm') + AND referenced.relkind IN ('v', 'm', 'r') + AND dependent.relname != referenced.relname + AND dep_ns.nspname NOT IN ('pg_catalog', 'information_schema') + AND ref_ns.nspname NOT IN ('pg_catalog', 'information_schema') + """ + + params: dict[str, str] = {} + if view_name: + query += " AND dependent.relname = :view_name AND dep_ns.nspname = :schema" + params["view_name"] = view_name + params["schema"] = schema + + query += " ORDER BY dep_ns.nspname, dependent.relname" + + result = connection.execute(text(query), params) + return [ + ViewDependency( + dependent_view=row[0], + dependent_schema=row[1], + referenced_view=row[2], + referenced_schema=row[3], + ) + for row in result.fetchall() + ] + + +def get_dependency_order( + connection: Annotated[Connection, Doc("SQLAlchemy connection.")], + schema: Annotated[str | None, Doc("If provided, only consider views in this schema.")] = None, +) -> list[ViewInfo]: + """Get views in dependency order (dependencies first). + + This returns views sorted so that a view appears after all views + it depends on. This is useful for migrations where views need to + be created in the correct order. + """ + views = get_all_views(connection, schema=schema) + dependencies = get_view_dependencies(connection) + + # Build a dependency graph + view_map = {v.fullname: v for v in views} + dependents: dict[str, set[str]] = {v.fullname: set() for v in views} + + for dep in dependencies: + if dep.dependent_fullname in dependents and dep.referenced_fullname in view_map: + dependents[dep.dependent_fullname].add(dep.referenced_fullname) + + # Topological sort using Kahn's algorithm + in_degree = {name: len(deps) for name, deps in dependents.items()} + queue = [name for name, degree in in_degree.items() if degree == 0] + result: list[ViewInfo] = [] + + while queue: + current = queue.pop(0) + if current in view_map: + result.append(view_map[current]) + + for name, deps in dependents.items(): + if current in deps: + in_degree[name] -= 1 + if in_degree[name] == 0: + queue.append(name) + + return result + + +def get_reverse_dependencies( + connection: Annotated[Connection, Doc("SQLAlchemy connection.")], + view_name: Annotated[str, Doc("Name of the view to check.")], + schema: Annotated[str, Doc("Schema name.")] = "public", +) -> list[ViewInfo]: + """Get all views that depend on the given view. + + This is useful when you need to drop or modify a view and need + to know what other views would be affected. + """ + result = connection.execute( + text(""" + WITH RECURSIVE dependent_views AS ( + SELECT DISTINCT + dependent.relname as name, + dep_ns.nspname as schema, + pg_get_viewdef(dependent.oid, true) as definition, + dependent.relkind = 'm' as is_materialized, + 1 as level + FROM pg_depend d + JOIN pg_rewrite r ON r.oid = d.objid + JOIN pg_class dependent ON dependent.oid = r.ev_class + JOIN pg_class referenced ON referenced.oid = d.refobjid + JOIN pg_namespace dep_ns ON dep_ns.oid = dependent.relnamespace + JOIN pg_namespace ref_ns ON ref_ns.oid = referenced.relnamespace + WHERE d.classid = 'pg_rewrite'::regclass + AND d.deptype = 'n' + AND dependent.relkind IN ('v', 'm') + AND referenced.relname = :view_name + AND ref_ns.nspname = :schema + AND dependent.relname != referenced.relname + + UNION + + SELECT DISTINCT + dependent.relname, + dep_ns.nspname, + pg_get_viewdef(dependent.oid, true), + dependent.relkind = 'm', + dv.level + 1 + FROM dependent_views dv + JOIN pg_class referenced ON referenced.relname = dv.name + JOIN pg_namespace ref_ns ON ref_ns.oid = referenced.relnamespace + AND ref_ns.nspname = dv.schema + JOIN pg_depend d ON d.refobjid = referenced.oid + JOIN pg_rewrite r ON r.oid = d.objid + JOIN pg_class dependent ON dependent.oid = r.ev_class + JOIN pg_namespace dep_ns ON dep_ns.oid = dependent.relnamespace + WHERE d.classid = 'pg_rewrite'::regclass + AND d.deptype = 'n' + AND dependent.relkind IN ('v', 'm') + AND dependent.relname != referenced.relname + ) + SELECT DISTINCT name, schema, definition, is_materialized + FROM dependent_views + ORDER BY schema, name + """), + {"view_name": view_name, "schema": schema}, + ) + + return [ + ViewInfo( + name=row[0], + schema=row[1], + definition=row[2], + is_materialized=row[3], + ) + for row in result.fetchall() + ] diff --git a/src/sqlalchemy_pgview/py.typed b/src/sqlalchemy_pgview/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlalchemy_pgview/util.py b/src/sqlalchemy_pgview/util.py new file mode 100644 index 0000000..6b60c2a --- /dev/null +++ b/src/sqlalchemy_pgview/util.py @@ -0,0 +1,8 @@ +"""Util functions.""" + + +def get_table_key(name: str, schema: str | None) -> str: + """Generate a unique key for a view.""" + if schema: + return f"{schema}.{name}" + return name diff --git a/src/sqlalchemy_pgview/view.py b/src/sqlalchemy_pgview/view.py new file mode 100644 index 0000000..b882dd9 --- /dev/null +++ b/src/sqlalchemy_pgview/view.py @@ -0,0 +1,407 @@ +"""Core view classes for PostgreSQL views and materialized views.""" + +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING, Annotated, Any + +from annotated_doc import Doc +from sqlalchemy import Column, MetaData, Table, event +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session # noqa: TC002 + +if TYPE_CHECKING: + from sqlalchemy.engine import Connection + from sqlalchemy.sql import FromClause, Select + + +from sqlalchemy_pgview.util import get_table_key + +_VIEWS_KEY = "pgview_views" +_MATERIALIZED_VIEWS_KEY = "pgview_materialized_views" +_EVENTS_REGISTERED_KEY = "pgview_events_registered" + + +def get_views( + metadata: Annotated[MetaData, Doc("The MetaData instance.")], +) -> dict[str, View]: + """Get all registered views for a metadata instance.""" + return dict(metadata.info.get(_VIEWS_KEY, {})) + + +def get_materialized_views( + metadata: Annotated[MetaData, Doc("The MetaData instance.")], +) -> dict[str, MaterializedView]: + """Get all registered materialized views for a metadata instance.""" + return dict(metadata.info.get(_MATERIALIZED_VIEWS_KEY, {})) + + +def _register_metadata_events(metadata: MetaData) -> None: + """Register create/drop events on metadata (once per metadata instance).""" + if metadata.info.get(_EVENTS_REGISTERED_KEY): + return + + metadata.info[_EVENTS_REGISTERED_KEY] = True + + @event.listens_for(metadata, "after_create") + def _create_views(target: MetaData, connection: Connection, **kw: Any) -> None: + from sqlalchemy_pgview.ddl import CreateMaterializedView, CreateView + + # Create regular views first + for view in get_views(metadata=target).values(): + connection.execute(CreateView(view=view, or_replace=True)) + + # Then materialized views + for mview in get_materialized_views(metadata=target).values(): + connection.execute(CreateMaterializedView(view=mview, if_not_exists=True)) + + @event.listens_for(metadata, "before_drop") + def _drop_views(target: MetaData, connection: Connection, **kw: Any) -> None: + from sqlalchemy_pgview.ddl import DropMaterializedView, DropView + + # Drop materialized views first (they might depend on regular views) + for mview in reversed(list(get_materialized_views(metadata=target).values())): + connection.execute(DropMaterializedView(view=mview, if_exists=True, cascade=True)) + + # Then regular views + for view in reversed(list(get_views(metadata=target).values())): + connection.execute(DropView(view=view, if_exists=True, cascade=True)) + + +class View: + """Represents a PostgreSQL view. + + A view is a virtual table based on a SELECT query. This class provides + SQLAlchemy integration for creating, dropping, and querying views. + + When a MetaData instance is provided, the view is automatically registered + and will be created/dropped with metadata.create_all()/metadata.drop_all(). + + Example: + ```python + from sqlalchemy import select, func, MetaData + from sqlalchemy_pgview import View + + metadata = MetaData() + + # View is auto-registered with metadata + user_stats = View( + name="user_stats", + metadata=metadata, + selectable=select(User.id, func.count(Order.id).label("order_count")) + .join(Order) + .group_by(User.id), + ) + + # Creates tables AND views + metadata.create_all(bind=engine) + ``` + """ + + __slots__ = ("__weakref__", "_columns", "_table", "metadata", "name", "schema", "selectable") + + def __init__( + self, + name: Annotated[str, Doc("The name of the view.")], + selectable: Annotated[Select[Any], Doc("The SELECT statement that defines the view.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + metadata: Annotated[ + MetaData | None, + Doc( + "SQLAlchemy MetaData instance. If provided, the view is automatically " + "registered for create_all/drop_all." + ), + ] = None, + ) -> None: + self.name = name + self.selectable = selectable + self.schema = schema + self.metadata = metadata or MetaData() + self._columns: tuple[Column[Any], ...] | None = None + self._table: Table | None = None + + # Auto-register with metadata if provided + if metadata is not None: + self._register_with_metadata() + + def _register_with_metadata(self) -> None: + """Register this view with its metadata.""" + _register_metadata_events(metadata=self.metadata) + + if _VIEWS_KEY not in self.metadata.info: + self.metadata.info[_VIEWS_KEY] = {} + + key = get_table_key(name=self.name, schema=self.schema) + self.metadata.info[_VIEWS_KEY][key] = self + + @property + def fullname(self) -> str: + """Return the fully qualified name of the view.""" + return get_table_key(name=self.name, schema=self.schema) + + @property + def columns(self) -> tuple[Column[Any], ...]: + """Return the columns derived from the selectable.""" + if self._columns is None: + self._columns = tuple(Column(c.name, c.type) for c in self.selectable.selected_columns) + return self._columns + + def as_table(self) -> Table: + """Return a Table representation for querying the view. + + This allows using the view in SELECT statements as if it were a table. + The Table is cached after first creation. + + Note: The returned Table uses a separate MetaData to avoid conflicts + with the view's MetaData during create_all/drop_all operations. + + Returns: + A SQLAlchemy Table object representing the view. + """ + if self._table is None: + # Use a separate MetaData to avoid the table being included + # in create_all/drop_all operations on the view's metadata + table_metadata = MetaData() + self._table = Table( + self.name, + table_metadata, + *self.columns, + schema=self.schema, + ) + return self._table + + def as_from_clause(self) -> FromClause: + """Return a FromClause for use in queries.""" + return self.as_table() + + def __repr__(self) -> str: + return f"View({self.name!r}, schema={self.schema!r})" + + +class MaterializedView(View): + """Represents a PostgreSQL materialized view. + + A materialized view stores the result of a query physically, unlike + regular views which are computed on each access. Materialized views + can be refreshed to update their contents. + + When a MetaData instance is provided, the view is automatically registered + and will be created/dropped with metadata.create_all()/metadata.drop_all(). + + Auto-refresh can be enabled to automatically refresh the view when + watched tables are modified. + + Example: + ```python + from sqlalchemy import select, func, MetaData + from sqlalchemy_pgview import MaterializedView + + metadata = MetaData() + + monthly_sales = MaterializedView( + name="monthly_sales", + metadata=metadata, + selectable=select( + func.date_trunc('month', Order.created_at).label("month"), + func.sum(Order.total).label("total_sales") + ).group_by(func.date_trunc('month', Order.created_at)), + with_data=True, + ) + + # Enable auto-refresh when orders table changes + monthly_sales.auto_refresh_on(orders_table) + + # Creates tables AND materialized views + metadata.create_all(engine) + ``` + """ + + __slots__ = ("_watched_tables", "indexes", "with_data") + + def __init__( + self, + name: Annotated[str, Doc("The name of the materialized view.")], + selectable: Annotated[Select[Any], Doc("The SELECT statement that defines the view.")], + *, + schema: Annotated[str | None, Doc("Optional schema name.")] = None, + metadata: Annotated[ + MetaData | None, + Doc( + "SQLAlchemy MetaData instance. If provided, the view is automatically " + "registered for create_all/drop_all." + ), + ] = None, + with_data: Annotated[bool, Doc("If True, populate the view with data on creation.")] = True, + indexes: Annotated[ + list[str] | None, Doc("List of column names to create indexes on.") + ] = None, + ) -> None: + # Initialize base attributes without auto-registration + self.name = name + self.selectable = selectable + self.schema = schema + self.metadata = metadata or MetaData() + self._columns: tuple[Column[Any], ...] | None = None + self._table: Table | None = None + + # Materialized view specific attributes + self.with_data = with_data + self.indexes = indexes or [] + self._watched_tables: list[Table] = [] + + # Register with materialized views registry + if metadata is not None: + self._register_with_metadata() + + def _register_with_metadata(self) -> None: + """Register this materialized view with its metadata.""" + _register_metadata_events(self.metadata) + + if _MATERIALIZED_VIEWS_KEY not in self.metadata.info: + self.metadata.info[_MATERIALIZED_VIEWS_KEY] = {} + + key = get_table_key(self.name, self.schema) + self.metadata.info[_MATERIALIZED_VIEWS_KEY][key] = self + + def refresh( + self, + connection: Annotated[Connection, Doc("SQLAlchemy connection to execute the refresh.")], + *, + concurrently: Annotated[ + bool, + Doc( + "If True, refresh without locking out concurrent reads. " + "Requires a unique index on the view." + ), + ] = False, + with_data: Annotated[ + bool, Doc("If True, populate with fresh data. If False, empty the view.") + ] = True, + ) -> None: + """Refresh the materialized view.""" + from sqlalchemy_pgview.ddl import RefreshMaterializedView + + stmt = RefreshMaterializedView(view=self, concurrently=concurrently, with_data=with_data) + connection.execute(statement=stmt) + + def auto_refresh_on( + self, + session_class: Annotated[ + type[Session], Doc("The Session class to attach events to (usually Session).") + ], + *tables: Annotated[Table, Doc("One or more Table objects to watch for changes.")], + concurrently: Annotated[ + bool, + Doc("If True, use CONCURRENTLY when refreshing. Requires a unique index on the view."), + ] = False, + ) -> None: + """Enable automatic refresh when watched tables are modified via ORM. + + Sets up SQLAlchemy ORM event listeners to automatically refresh this + materialized view after INSERT, UPDATE, or DELETE operations on + ORM models mapped to the specified tables. + + Example: + ```python + from sqlalchemy.orm import Session + + order_stats = MaterializedView( + "order_stats", + select(func.count(orders.c.id)), + metadata=metadata, + ) + + # Enable auto-refresh for all sessions + order_stats.auto_refresh_on(Session, orders) + + # Now when you commit changes via ORM, the view refreshes + with Session(engine) as session: + session.add(Order(total=100)) + session.commit() # View is refreshed here + ``` + + Note: + - Works with ORM mapped classes only + - Refresh happens after each commit that modified watched tables + - For high-frequency writes, consider manual refresh instead + - For Core-only usage, use `setup_auto_refresh()` function instead + """ + mview_ref = weakref.ref(self) + table_names = {t.name for t in tables} + + for table in tables: + if table in self._watched_tables: + continue + self._watched_tables.append(table) + + # Key to track pending refresh in session + pending_key = f"_pgview_pending_{self.fullname}" + + @event.listens_for(session_class, "after_flush") + def after_flush(sess: Session, flush_context: Any) -> None: + """Check if watched tables were modified.""" + for obj in list(sess.new) + list(sess.dirty) + list(sess.deleted): + mapper = getattr(obj.__class__, "__mapper__", None) + if mapper is not None: + tbl = mapper.persist_selectable + if tbl.name in table_names: + sess.info[pending_key] = True + return + + @event.listens_for(session_class, "after_commit") + def after_commit(sess: Session) -> None: + """Refresh if needed after commit.""" + if sess.info.pop(pending_key, False): + mview = mview_ref() + if mview is not None: + bind = sess.get_bind() + # Handle both Engine and Connection + if isinstance(bind, Engine): + with bind.connect() as conn: + mview.refresh(connection=conn, concurrently=concurrently) + conn.commit() + else: + # bind is already a Connection + mview.refresh(connection=bind, concurrently=concurrently) + + def __repr__(self) -> str: + return ( + f"MaterializedView({self.name!r}, schema={self.schema!r}, with_data={self.with_data})" + ) + + +class AutoRefreshContext: + """Context manager for auto-refreshing materialized views (Core usage). + + Use this when working with SQLAlchemy Core (without ORM) to automatically + refresh materialized views after modifications to watched tables. + + Example: + ```python + with engine.begin() as conn: + with AutoRefreshContext(conn, order_stats, orders_table): + conn.execute(insert(orders_table).values(total=100)) + # View is refreshed when exiting the context + ``` + """ + + def __init__( + self, + connection: Annotated[Connection, Doc("Active SQLAlchemy Connection.")], + mview: Annotated[MaterializedView, Doc("The MaterializedView to refresh.")], + *tables: Annotated[Table, Doc("Tables to watch (currently informational only).")], + concurrently: Annotated[bool, Doc("If True, use CONCURRENTLY when refreshing.")] = False, + ) -> None: + self.connection = connection + self.mview = mview + self.tables = tables + self.concurrently = concurrently + + def __enter__(self) -> AutoRefreshContext: + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + if exc_type is None: + # No exception - refresh the view + self.mview.refresh(connection=self.connection, concurrently=self.concurrently) diff --git a/test.py b/test.py new file mode 100644 index 0000000..47279c6 --- /dev/null +++ b/test.py @@ -0,0 +1,59 @@ +from sqlalchemy import ( + Column, + DateTime, + ForeignKey, + Integer, + MetaData, + Numeric, + String, + Table, + create_engine, + func, + select, +) + +from sqlalchemy_pgview import MaterializedView, View + +# Create engine and metadata +engine = create_engine("postgresql://postgres:postgres@localhost/postgres") +metadata = MetaData() + +# Define some tables +users = Table( + "users", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100)), + Column("email", String(100)), +) + +orders = Table( + "orders", + metadata, + Column("id", Integer, primary_key=True), + Column("user_id", Integer, ForeignKey("users.id")), + Column("total", Numeric(10, 2)), + Column("created_at", DateTime, server_default=func.now()), +) + +active_users = View( + "active_users", + select(users.c.id, users.c.name, users.c.email), + metadata=metadata, +) + +monthly_sales = MaterializedView( + "monthly_sales", + select( + func.date_trunc("month", orders.c.created_at).label("month"), + func.count(orders.c.id).label("order_count"), + func.sum(orders.c.total).label("revenue"), + ).group_by(func.date_trunc("month", orders.c.created_at)), + with_data=True, + metadata=metadata, +) + +# Create tables +metadata.create_all(engine) + +# metadata.drop_all(engine) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..56bb48d --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for sqlalchemy-pgview.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c589bd5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,270 @@ +"""Pytest configuration and fixtures.""" + +import pytest +from sqlalchemy import ( + Column, + DateTime, + ForeignKey, + Integer, + MetaData, + Numeric, + String, + Table, + create_engine, + func, + insert, +) +from sqlalchemy.engine import Engine + + +@pytest.fixture +def metadata() -> MetaData: + """Create a fresh MetaData instance.""" + return MetaData() + + +@pytest.fixture +def pg_engine() -> Engine | None: + """Create a PostgreSQL engine if available. + + Set the POSTGRES_URL environment variable to run PostgreSQL tests. + Example: postgresql://user:pass@localhost:5432/testdb + """ + import os + + url = os.environ.get("POSTGRES_URL") + if not url: + pytest.skip("POSTGRES_URL not set") + return create_engine(url) + + +@pytest.fixture +def sample_tables(metadata: MetaData) -> tuple[Table, Table]: + """Create sample tables for testing.""" + users = Table( + "users", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100)), + Column("email", String(100)), + ) + + orders = Table( + "orders", + metadata, + Column("id", Integer, primary_key=True), + Column("user_id", Integer), + Column("total", Integer), + ) + + return users, orders + + +@pytest.fixture +def one_to_many_tables(metadata: MetaData) -> dict[str, Table]: + """Create tables with one-to-many relationships. + + Schema: + authors (1) --< (many) books + books (1) --< (many) reviews + """ + authors = Table( + "authors", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100), nullable=False), + Column("country", String(50)), + ) + + books = Table( + "books", + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(200), nullable=False), + Column("author_id", Integer, ForeignKey("authors.id"), nullable=False), + Column("price", Numeric(10, 2)), + Column("published_at", DateTime), + ) + + reviews = Table( + "reviews", + metadata, + Column("id", Integer, primary_key=True), + Column("book_id", Integer, ForeignKey("books.id"), nullable=False), + Column("rating", Integer, nullable=False), + Column("comment", String(500)), + ) + + return {"authors": authors, "books": books, "reviews": reviews} + + +@pytest.fixture +def many_to_many_tables(metadata: MetaData) -> dict[str, Table]: + """Create tables with many-to-many relationships. + + Schema: + students (many) --< student_courses >-- (many) courses + courses (many) --< course_tags >-- (many) tags + """ + students = Table( + "students", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100), nullable=False), + Column("email", String(100), unique=True), + ) + + courses = Table( + "courses", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100), nullable=False), + Column("credits", Integer, default=3), + ) + + student_courses = Table( + "student_courses", + metadata, + Column("student_id", Integer, ForeignKey("students.id"), primary_key=True), + Column("course_id", Integer, ForeignKey("courses.id"), primary_key=True), + Column("grade", Numeric(3, 2)), + Column("enrolled_at", DateTime, server_default=func.now()), + ) + + tags = Table( + "tags", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(50), unique=True, nullable=False), + ) + + course_tags = Table( + "course_tags", + metadata, + Column("course_id", Integer, ForeignKey("courses.id"), primary_key=True), + Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True), + ) + + return { + "students": students, + "courses": courses, + "student_courses": student_courses, + "tags": tags, + "course_tags": course_tags, + } + + +@pytest.fixture +def pg_one_to_many_tables( + pg_engine: Engine, one_to_many_tables: dict[str, Table], metadata: MetaData +) -> dict[str, Table]: + """Create one-to-many tables in PostgreSQL with sample data.""" + metadata.create_all(pg_engine) + + with pg_engine.begin() as conn: + # Insert authors + conn.execute( + insert(one_to_many_tables["authors"]), + [ + {"id": 1, "name": "George Orwell", "country": "UK"}, + {"id": 2, "name": "Gabriel Garcia Marquez", "country": "Colombia"}, + {"id": 3, "name": "Haruki Murakami", "country": "Japan"}, + ], + ) + + # Insert books + conn.execute( + insert(one_to_many_tables["books"]), + [ + {"id": 1, "title": "1984", "author_id": 1, "price": 15.99}, + {"id": 2, "title": "Animal Farm", "author_id": 1, "price": 12.99}, + {"id": 3, "title": "One Hundred Years of Solitude", "author_id": 2, "price": 18.99}, + {"id": 4, "title": "Norwegian Wood", "author_id": 3, "price": 14.99}, + {"id": 5, "title": "Kafka on the Shore", "author_id": 3, "price": 16.99}, + ], + ) + + # Insert reviews + conn.execute( + insert(one_to_many_tables["reviews"]), + [ + {"id": 1, "book_id": 1, "rating": 5, "comment": "A masterpiece"}, + {"id": 2, "book_id": 1, "rating": 4, "comment": "Thought-provoking"}, + {"id": 3, "book_id": 1, "rating": 5, "comment": "Must read"}, + {"id": 4, "book_id": 2, "rating": 4, "comment": "Great allegory"}, + {"id": 5, "book_id": 3, "rating": 5, "comment": "Beautiful prose"}, + {"id": 6, "book_id": 4, "rating": 4, "comment": "Melancholic"}, + {"id": 7, "book_id": 5, "rating": 5, "comment": "Surreal and captivating"}, + ], + ) + + yield one_to_many_tables + + metadata.drop_all(pg_engine) + + +@pytest.fixture +def pg_many_to_many_tables( + pg_engine: Engine, many_to_many_tables: dict[str, Table], metadata: MetaData +) -> dict[str, Table]: + """Create many-to-many tables in PostgreSQL with sample data.""" + metadata.create_all(pg_engine) + + with pg_engine.begin() as conn: + # Insert students + conn.execute( + insert(many_to_many_tables["students"]), + [ + {"id": 1, "name": "Alice", "email": "alice@example.com"}, + {"id": 2, "name": "Bob", "email": "bob@example.com"}, + {"id": 3, "name": "Charlie", "email": "charlie@example.com"}, + ], + ) + + # Insert courses + conn.execute( + insert(many_to_many_tables["courses"]), + [ + {"id": 1, "name": "Database Systems", "credits": 4}, + {"id": 2, "name": "Web Development", "credits": 3}, + {"id": 3, "name": "Machine Learning", "credits": 4}, + ], + ) + + # Insert student_courses (enrollments) + conn.execute( + insert(many_to_many_tables["student_courses"]), + [ + {"student_id": 1, "course_id": 1, "grade": 3.8}, + {"student_id": 1, "course_id": 2, "grade": 4.0}, + {"student_id": 1, "course_id": 3, "grade": 3.5}, + {"student_id": 2, "course_id": 1, "grade": 3.2}, + {"student_id": 2, "course_id": 3, "grade": 3.9}, + {"student_id": 3, "course_id": 2, "grade": 3.7}, + ], + ) + + # Insert tags + conn.execute( + insert(many_to_many_tables["tags"]), + [ + {"id": 1, "name": "programming"}, + {"id": 2, "name": "data"}, + {"id": 3, "name": "ai"}, + ], + ) + + # Insert course_tags + conn.execute( + insert(many_to_many_tables["course_tags"]), + [ + {"course_id": 1, "tag_id": 2}, # Database -> data + {"course_id": 2, "tag_id": 1}, # Web Dev -> programming + {"course_id": 3, "tag_id": 2}, # ML -> data + {"course_id": 3, "tag_id": 3}, # ML -> ai + ], + ) + + yield many_to_many_tables + + metadata.drop_all(pg_engine) diff --git a/tests/test_alembic.py b/tests/test_alembic.py new file mode 100644 index 0000000..47212d1 --- /dev/null +++ b/tests/test_alembic.py @@ -0,0 +1,1061 @@ +"""Tests for Alembic integration.""" + +from unittest.mock import MagicMock + +from sqlalchemy import Column, Integer, MetaData, String, Table, func, select +from sqlalchemy.engine import Engine + +from sqlalchemy_pgview import MaterializedView, View +from sqlalchemy_pgview.alembic import ( + CreateMaterializedViewOp, + CreateViewOp, + DropMaterializedViewOp, + DropViewOp, + RefreshMaterializedViewOp, +) +from sqlalchemy_pgview.alembic.autogenerate import ( + _get_db_views, + _normalize_definition, + compare_views, + render_create_materialized_view, + render_create_view, + render_drop_materialized_view, + render_drop_view, +) +from sqlalchemy_pgview.alembic.ops import ( + _create_materialized_view_impl, + _create_view_impl, + _drop_materialized_view_impl, + _drop_view_impl, + _refresh_materialized_view_impl, +) + + +class TestAlembicOperations: + """Tests for Alembic operation classes.""" + + def test_create_view_op(self) -> None: + """Test CreateViewOp initialization.""" + op = CreateViewOp( + "test_view", + "SELECT id, name FROM users", + schema="public", + or_replace=True, + ) + + assert op.view_name == "test_view" + assert op.select_query == "SELECT id, name FROM users" + assert op.schema == "public" + assert op.or_replace is True + + def test_create_view_op_defaults(self) -> None: + """Test CreateViewOp default values.""" + op = CreateViewOp("test_view", "SELECT 1") + + assert op.schema is None + assert op.or_replace is False + + def test_create_view_op_reverse(self) -> None: + """Test CreateViewOp reverse returns DropViewOp.""" + op = CreateViewOp("test_view", "SELECT 1", schema="analytics") + reverse = op.reverse() + + assert isinstance(reverse, DropViewOp) + assert reverse.view_name == "test_view" + assert reverse.schema == "analytics" + + def test_drop_view_op(self) -> None: + """Test DropViewOp initialization.""" + op = DropViewOp( + "test_view", + schema="public", + if_exists=True, + cascade=True, + ) + + assert op.view_name == "test_view" + assert op.schema == "public" + assert op.if_exists is True + assert op.cascade is True + + def test_drop_view_op_defaults(self) -> None: + """Test DropViewOp default values.""" + op = DropViewOp("test_view") + + assert op.schema is None + assert op.if_exists is True + assert op.cascade is False + + def test_create_materialized_view_op(self) -> None: + """Test CreateMaterializedViewOp initialization.""" + op = CreateMaterializedViewOp( + "test_mview", + "SELECT id FROM users", + schema="analytics", + with_data=True, + if_not_exists=True, + ) + + assert op.view_name == "test_mview" + assert op.schema == "analytics" + assert op.with_data is True + assert op.if_not_exists is True + + def test_create_materialized_view_op_defaults(self) -> None: + """Test CreateMaterializedViewOp default values.""" + op = CreateMaterializedViewOp("test_mview", "SELECT 1") + + assert op.schema is None + assert op.with_data is True + assert op.if_not_exists is False + + def test_create_materialized_view_op_reverse(self) -> None: + """Test CreateMaterializedViewOp reverse returns DropMaterializedViewOp.""" + op = CreateMaterializedViewOp("test_mview", "SELECT 1", schema="public") + reverse = op.reverse() + + assert isinstance(reverse, DropMaterializedViewOp) + assert reverse.view_name == "test_mview" + assert reverse.schema == "public" + + def test_drop_materialized_view_op(self) -> None: + """Test DropMaterializedViewOp initialization.""" + op = DropMaterializedViewOp( + "test_mview", + schema="public", + if_exists=False, + cascade=True, + ) + + assert op.view_name == "test_mview" + assert op.if_exists is False + assert op.cascade is True + + def test_drop_materialized_view_op_defaults(self) -> None: + """Test DropMaterializedViewOp default values.""" + op = DropMaterializedViewOp("test_mview") + + assert op.schema is None + assert op.if_exists is True + assert op.cascade is False + + def test_refresh_materialized_view_op(self) -> None: + """Test RefreshMaterializedViewOp initialization.""" + op = RefreshMaterializedViewOp( + "test_mview", + schema="analytics", + concurrently=True, + with_data=True, + ) + + assert op.view_name == "test_mview" + assert op.schema == "analytics" + assert op.concurrently is True + assert op.with_data is True + + def test_refresh_materialized_view_op_defaults(self) -> None: + """Test RefreshMaterializedViewOp default values.""" + op = RefreshMaterializedViewOp("test_mview") + + assert op.schema is None + assert op.concurrently is False + assert op.with_data is True + + +class TestAlembicImplementations: + """Tests for Alembic operation implementations.""" + + def test_create_view_impl(self) -> None: + """Test _create_view_impl generates correct SQL.""" + mock_ops = MagicMock() + op = CreateViewOp("test_view", "SELECT id FROM users") + + _create_view_impl(mock_ops, op) + + mock_ops.execute.assert_called_once() + sql = mock_ops.execute.call_args[0][0] + assert "CREATE VIEW test_view AS SELECT id FROM users" in sql + + def test_create_view_impl_or_replace(self) -> None: + """Test _create_view_impl with OR REPLACE.""" + mock_ops = MagicMock() + op = CreateViewOp("test_view", "SELECT 1", or_replace=True) + + _create_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "CREATE OR REPLACE VIEW" in sql + + def test_create_view_impl_with_schema(self) -> None: + """Test _create_view_impl with schema.""" + mock_ops = MagicMock() + op = CreateViewOp("test_view", "SELECT 1", schema="analytics") + + _create_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "analytics.test_view" in sql + + def test_drop_view_impl(self) -> None: + """Test _drop_view_impl generates correct SQL.""" + mock_ops = MagicMock() + op = DropViewOp("test_view", if_exists=False) + + _drop_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert sql == "DROP VIEW test_view" + + def test_drop_view_impl_if_exists(self) -> None: + """Test _drop_view_impl with IF EXISTS.""" + mock_ops = MagicMock() + op = DropViewOp("test_view", if_exists=True) + + _drop_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "IF EXISTS" in sql + + def test_drop_view_impl_cascade(self) -> None: + """Test _drop_view_impl with CASCADE.""" + mock_ops = MagicMock() + op = DropViewOp("test_view", cascade=True, if_exists=False) + + _drop_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "CASCADE" in sql + + def test_drop_view_impl_with_schema(self) -> None: + """Test _drop_view_impl with schema.""" + mock_ops = MagicMock() + op = DropViewOp("test_view", schema="analytics", if_exists=False) + + _drop_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "analytics.test_view" in sql + + def test_create_materialized_view_impl(self) -> None: + """Test _create_materialized_view_impl generates correct SQL.""" + mock_ops = MagicMock() + op = CreateMaterializedViewOp("test_mview", "SELECT id FROM users") + + _create_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "CREATE MATERIALIZED VIEW test_mview" in sql + assert "WITH DATA" in sql + + def test_create_materialized_view_impl_without_data(self) -> None: + """Test _create_materialized_view_impl with WITH NO DATA.""" + mock_ops = MagicMock() + op = CreateMaterializedViewOp("test_mview", "SELECT 1", with_data=False) + + _create_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "WITH NO DATA" in sql + + def test_create_materialized_view_impl_if_not_exists(self) -> None: + """Test _create_materialized_view_impl with IF NOT EXISTS.""" + mock_ops = MagicMock() + op = CreateMaterializedViewOp("test_mview", "SELECT 1", if_not_exists=True) + + _create_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "IF NOT EXISTS" in sql + + def test_create_materialized_view_impl_with_schema(self) -> None: + """Test _create_materialized_view_impl with schema.""" + mock_ops = MagicMock() + op = CreateMaterializedViewOp("test_mview", "SELECT 1", schema="analytics") + + _create_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "analytics.test_mview" in sql + + def test_drop_materialized_view_impl(self) -> None: + """Test _drop_materialized_view_impl generates correct SQL.""" + mock_ops = MagicMock() + op = DropMaterializedViewOp("test_mview", if_exists=False) + + _drop_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert sql == "DROP MATERIALIZED VIEW test_mview" + + def test_drop_materialized_view_impl_if_exists(self) -> None: + """Test _drop_materialized_view_impl with IF EXISTS.""" + mock_ops = MagicMock() + op = DropMaterializedViewOp("test_mview", if_exists=True) + + _drop_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "IF EXISTS" in sql + + def test_drop_materialized_view_impl_cascade(self) -> None: + """Test _drop_materialized_view_impl with CASCADE.""" + mock_ops = MagicMock() + op = DropMaterializedViewOp("test_mview", cascade=True, if_exists=False) + + _drop_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "CASCADE" in sql + + def test_drop_materialized_view_impl_with_schema(self) -> None: + """Test _drop_materialized_view_impl with schema.""" + mock_ops = MagicMock() + op = DropMaterializedViewOp("test_mview", schema="analytics", if_exists=False) + + _drop_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "analytics.test_mview" in sql + + def test_refresh_materialized_view_impl(self) -> None: + """Test _refresh_materialized_view_impl generates correct SQL.""" + mock_ops = MagicMock() + op = RefreshMaterializedViewOp("test_mview") + + _refresh_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "REFRESH MATERIALIZED VIEW test_mview" in sql + assert "WITH DATA" in sql + + def test_refresh_materialized_view_impl_concurrently(self) -> None: + """Test _refresh_materialized_view_impl with CONCURRENTLY.""" + mock_ops = MagicMock() + op = RefreshMaterializedViewOp("test_mview", concurrently=True) + + _refresh_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "CONCURRENTLY" in sql + + def test_refresh_materialized_view_impl_without_data(self) -> None: + """Test _refresh_materialized_view_impl with WITH NO DATA.""" + mock_ops = MagicMock() + op = RefreshMaterializedViewOp("test_mview", with_data=False) + + _refresh_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "WITH NO DATA" in sql + + def test_refresh_materialized_view_impl_with_schema(self) -> None: + """Test _refresh_materialized_view_impl with schema.""" + mock_ops = MagicMock() + op = RefreshMaterializedViewOp("test_mview", schema="analytics") + + _refresh_materialized_view_impl(mock_ops, op) + + sql = mock_ops.execute.call_args[0][0] + assert "analytics.test_mview" in sql + + +class TestAlembicClassMethods: + """Tests for Alembic operation classmethods.""" + + def test_create_view_classmethod(self) -> None: + """Test CreateViewOp.create_view classmethod.""" + mock_ops = MagicMock() + + CreateViewOp.create_view( + mock_ops, "test_view", "SELECT 1", schema="public", or_replace=True + ) + + mock_ops.invoke.assert_called_once() + invoked_op = mock_ops.invoke.call_args[0][0] + assert isinstance(invoked_op, CreateViewOp) + assert invoked_op.view_name == "test_view" + assert invoked_op.or_replace is True + + def test_drop_view_classmethod(self) -> None: + """Test DropViewOp.drop_view classmethod.""" + mock_ops = MagicMock() + + DropViewOp.drop_view(mock_ops, "test_view", schema="public", cascade=True) + + mock_ops.invoke.assert_called_once() + invoked_op = mock_ops.invoke.call_args[0][0] + assert isinstance(invoked_op, DropViewOp) + assert invoked_op.cascade is True + + def test_create_materialized_view_classmethod(self) -> None: + """Test CreateMaterializedViewOp.create_materialized_view classmethod.""" + mock_ops = MagicMock() + + CreateMaterializedViewOp.create_materialized_view( + mock_ops, "test_mview", "SELECT 1", with_data=False, if_not_exists=True + ) + + mock_ops.invoke.assert_called_once() + invoked_op = mock_ops.invoke.call_args[0][0] + assert isinstance(invoked_op, CreateMaterializedViewOp) + assert invoked_op.with_data is False + assert invoked_op.if_not_exists is True + + def test_drop_materialized_view_classmethod(self) -> None: + """Test DropMaterializedViewOp.drop_materialized_view classmethod.""" + mock_ops = MagicMock() + + DropMaterializedViewOp.drop_materialized_view( + mock_ops, "test_mview", cascade=True + ) + + mock_ops.invoke.assert_called_once() + invoked_op = mock_ops.invoke.call_args[0][0] + assert isinstance(invoked_op, DropMaterializedViewOp) + assert invoked_op.cascade is True + + def test_refresh_materialized_view_classmethod(self) -> None: + """Test RefreshMaterializedViewOp.refresh_materialized_view classmethod.""" + mock_ops = MagicMock() + + RefreshMaterializedViewOp.refresh_materialized_view( + mock_ops, "test_mview", concurrently=True, with_data=False + ) + + mock_ops.invoke.assert_called_once() + invoked_op = mock_ops.invoke.call_args[0][0] + assert isinstance(invoked_op, RefreshMaterializedViewOp) + assert invoked_op.concurrently is True + assert invoked_op.with_data is False + + +class TestAutogenerateHelpers: + """Tests for autogenerate helper functions.""" + + def test_normalize_definition_none(self) -> None: + """Test _normalize_definition with None.""" + assert _normalize_definition(None) == "" + + def test_normalize_definition_empty(self) -> None: + """Test _normalize_definition with empty string.""" + assert _normalize_definition("") == "" + + def test_normalize_definition_whitespace(self) -> None: + """Test _normalize_definition normalizes whitespace.""" + definition = "SELECT id,\n name FROM users" + normalized = _normalize_definition(definition) + assert normalized == "select id, name from users" + + def test_normalize_definition_semicolon(self) -> None: + """Test _normalize_definition removes trailing semicolon.""" + definition = "SELECT id FROM users;" + normalized = _normalize_definition(definition) + assert not normalized.endswith(";") + + def test_normalize_definition_case(self) -> None: + """Test _normalize_definition converts to lowercase.""" + definition = "SELECT ID FROM USERS" + normalized = _normalize_definition(definition) + assert normalized == "select id from users" + + +class TestAutogenerateGetDbViews: + """Tests for _get_db_views function.""" + + def test_get_db_views(self, pg_engine: Engine) -> None: + """Test _get_db_views retrieves views from database.""" + metadata = MetaData() + users = Table( + "test_users_autogen", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100)), + ) + + view = View( + "test_view_autogen", + select(users.c.id, users.c.name), + ) + + from sqlalchemy_pgview import CreateView, DropView + + try: + metadata.create_all(pg_engine) + + with pg_engine.begin() as conn: + conn.execute(CreateView(view, or_replace=True)) + + db_views = _get_db_views(conn) + + assert "test_view_autogen" in db_views + assert db_views["test_view_autogen"]["is_materialized"] is False + + conn.execute(DropView(view, if_exists=True)) + + finally: + metadata.drop_all(pg_engine) + + def test_get_db_views_with_schema_filter(self, pg_engine: Engine) -> None: + """Test _get_db_views with schema filter.""" + metadata = MetaData() + users = Table( + "test_users_schema", + metadata, + Column("id", Integer, primary_key=True), + ) + + view = View("test_view_schema", select(users.c.id)) + + from sqlalchemy_pgview import CreateView, DropView + + try: + metadata.create_all(pg_engine) + + with pg_engine.begin() as conn: + conn.execute(CreateView(view, or_replace=True)) + + # Filter by public schema + db_views = _get_db_views(conn, schema="public") + assert "test_view_schema" in db_views + + # Filter by non-existent schema + db_views = _get_db_views(conn, schema="nonexistent") + assert "test_view_schema" not in db_views + + conn.execute(DropView(view, if_exists=True)) + + finally: + metadata.drop_all(pg_engine) + + def test_get_db_views_materialized(self, pg_engine: Engine) -> None: + """Test _get_db_views identifies materialized views.""" + metadata = MetaData() + users = Table( + "test_users_mv_autogen", + metadata, + Column("id", Integer, primary_key=True), + ) + + mview = MaterializedView( + "test_mview_autogen", + select(func.count(users.c.id).label("count")), + with_data=True, + ) + + from sqlalchemy_pgview import CreateMaterializedView, DropMaterializedView + + try: + metadata.create_all(pg_engine) + + with pg_engine.begin() as conn: + conn.execute(CreateMaterializedView(mview, if_not_exists=True)) + + db_views = _get_db_views(conn) + + assert "test_mview_autogen" in db_views + assert db_views["test_mview_autogen"]["is_materialized"] is True + + conn.execute(DropMaterializedView(mview, if_exists=True)) + + finally: + metadata.drop_all(pg_engine) + + +class TestAutogenerateRenderers: + """Tests for autogenerate renderer functions.""" + + def test_render_create_view(self) -> None: + """Test render_create_view generates correct Python code.""" + op = CreateViewOp("test_view", "SELECT id FROM users") + mock_context = MagicMock() + + result = render_create_view(mock_context, op) + + assert "op.create_view(" in result + assert "'test_view'" in result + assert "SELECT id FROM users" in result + + def test_render_create_view_with_schema(self) -> None: + """Test render_create_view with schema.""" + op = CreateViewOp("test_view", "SELECT 1", schema="analytics") + mock_context = MagicMock() + + result = render_create_view(mock_context, op) + + assert "schema='analytics'" in result + + def test_render_create_view_or_replace(self) -> None: + """Test render_create_view with or_replace.""" + op = CreateViewOp("test_view", "SELECT 1", or_replace=True) + mock_context = MagicMock() + + result = render_create_view(mock_context, op) + + assert "or_replace=True" in result + + def test_render_drop_view(self) -> None: + """Test render_drop_view generates correct Python code.""" + op = DropViewOp("test_view") + mock_context = MagicMock() + + result = render_drop_view(mock_context, op) + + assert "op.drop_view(" in result + assert "'test_view'" in result + + def test_render_drop_view_with_options(self) -> None: + """Test render_drop_view with options.""" + op = DropViewOp("test_view", schema="analytics", if_exists=False, cascade=True) + mock_context = MagicMock() + + result = render_drop_view(mock_context, op) + + assert "schema='analytics'" in result + assert "if_exists=False" in result + assert "cascade=True" in result + + def test_render_create_materialized_view(self) -> None: + """Test render_create_materialized_view generates correct Python code.""" + op = CreateMaterializedViewOp("test_mview", "SELECT 1") + mock_context = MagicMock() + + result = render_create_materialized_view(mock_context, op) + + assert "op.create_materialized_view(" in result + assert "'test_mview'" in result + + def test_render_create_materialized_view_without_data(self) -> None: + """Test render_create_materialized_view with with_data=False.""" + op = CreateMaterializedViewOp("test_mview", "SELECT 1", with_data=False) + mock_context = MagicMock() + + result = render_create_materialized_view(mock_context, op) + + assert "with_data=False" in result + + def test_render_create_materialized_view_with_schema(self) -> None: + """Test render_create_materialized_view with schema.""" + op = CreateMaterializedViewOp("test_mview", "SELECT 1", schema="analytics") + mock_context = MagicMock() + + result = render_create_materialized_view(mock_context, op) + + assert "schema='analytics'" in result + + def test_render_drop_materialized_view(self) -> None: + """Test render_drop_materialized_view generates correct Python code.""" + op = DropMaterializedViewOp("test_mview") + mock_context = MagicMock() + + result = render_drop_materialized_view(mock_context, op) + + assert "op.drop_materialized_view(" in result + assert "'test_mview'" in result + + def test_render_drop_materialized_view_with_options(self) -> None: + """Test render_drop_materialized_view with options.""" + op = DropMaterializedViewOp( + "test_mview", schema="analytics", if_exists=False, cascade=True + ) + mock_context = MagicMock() + + result = render_drop_materialized_view(mock_context, op) + + assert "schema='analytics'" in result + assert "if_exists=False" in result + assert "cascade=True" in result + + +class TestCompareViews: + """Tests for compare_views autogenerate function.""" + + def test_compare_views_non_postgresql(self) -> None: + """Test compare_views returns early for non-PostgreSQL.""" + mock_context = MagicMock() + mock_context.connection.dialect.name = "sqlite" + mock_context.metadata = MetaData() + + mock_upgrade_ops = MagicMock() + mock_upgrade_ops.ops = [] + + compare_views(mock_context, mock_upgrade_ops, [None]) + + # Should not add any ops for non-PostgreSQL + assert len(mock_upgrade_ops.ops) == 0 + + def test_compare_views_none_connection(self) -> None: + """Test compare_views returns early for None connection.""" + mock_context = MagicMock() + mock_context.connection = None + mock_context.metadata = MetaData() + + mock_upgrade_ops = MagicMock() + mock_upgrade_ops.ops = [] + + compare_views(mock_context, mock_upgrade_ops, [None]) + + # Should not add any ops for None connection + assert len(mock_upgrade_ops.ops) == 0 + + def test_compare_views_creates_new_view(self, pg_engine: Engine) -> None: + """Test compare_views detects view to create.""" + # First create tables only (no views) + table_metadata = MetaData() + Table( + "test_users_compare", + table_metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100)), + ) + + try: + table_metadata.create_all(pg_engine) + + # Now create a separate metadata with a view registered + view_metadata = MetaData() + users_ref = Table( + "test_users_compare", + view_metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100)), + ) + + # Register a view in the new metadata (not in database yet) + View( + "test_view_compare", + select(users_ref.c.id, users_ref.c.name), + metadata=view_metadata, + ) + + with pg_engine.connect() as conn: + mock_context = MagicMock() + mock_context.connection = conn + mock_context.metadata = view_metadata + + mock_upgrade_ops = MagicMock() + mock_upgrade_ops.ops = [] + + compare_views(mock_context, mock_upgrade_ops, [None]) + + # Should detect view to create + create_ops = [ + op for op in mock_upgrade_ops.ops if isinstance(op, CreateViewOp) + ] + assert len(create_ops) == 1 + assert create_ops[0].view_name == "test_view_compare" + + finally: + table_metadata.drop_all(pg_engine) + + def test_compare_views_creates_new_materialized_view( + self, pg_engine: Engine + ) -> None: + """Test compare_views detects materialized view to create.""" + # First create tables only (no views) + table_metadata = MetaData() + Table( + "test_users_compare_mv", + table_metadata, + Column("id", Integer, primary_key=True), + ) + + try: + table_metadata.create_all(pg_engine) + + # Now create a separate metadata with a materialized view registered + view_metadata = MetaData() + users_ref = Table( + "test_users_compare_mv", + view_metadata, + Column("id", Integer, primary_key=True), + ) + + # Register a materialized view in the new metadata (not in database yet) + MaterializedView( + "test_mview_compare", + select(func.count(users_ref.c.id).label("count")), + metadata=view_metadata, + with_data=True, + ) + + with pg_engine.connect() as conn: + mock_context = MagicMock() + mock_context.connection = conn + mock_context.metadata = view_metadata + + mock_upgrade_ops = MagicMock() + mock_upgrade_ops.ops = [] + + compare_views(mock_context, mock_upgrade_ops, [None]) + + # Should detect materialized view to create + create_ops = [ + op + for op in mock_upgrade_ops.ops + if isinstance(op, CreateMaterializedViewOp) + ] + assert len(create_ops) == 1 + assert create_ops[0].view_name == "test_mview_compare" + + finally: + table_metadata.drop_all(pg_engine) + + def test_compare_views_drops_orphan_view(self, pg_engine: Engine) -> None: + """Test compare_views detects view to drop.""" + metadata = MetaData() + users = Table( + "test_users_drop_compare", + metadata, + Column("id", Integer, primary_key=True), + ) + + # Create a view in the database but NOT in metadata + orphan_view = View( + "test_orphan_view", + select(users.c.id), + ) + + from sqlalchemy_pgview import CreateView, DropView + + try: + metadata.create_all(pg_engine) + + with pg_engine.begin() as conn: + conn.execute(CreateView(orphan_view, or_replace=True)) + + # Now compare with empty metadata (no views registered) + empty_metadata = MetaData() + + with pg_engine.connect() as conn: + mock_context = MagicMock() + mock_context.connection = conn + mock_context.metadata = empty_metadata + + mock_upgrade_ops = MagicMock() + mock_upgrade_ops.ops = [] + + compare_views(mock_context, mock_upgrade_ops, [None]) + + # Should detect orphan view to drop + drop_ops = [ + op for op in mock_upgrade_ops.ops if isinstance(op, DropViewOp) + ] + orphan_drop = [ + op for op in drop_ops if op.view_name == "test_orphan_view" + ] + assert len(orphan_drop) == 1 + + # Cleanup + with pg_engine.begin() as conn: + conn.execute(DropView(orphan_view, if_exists=True)) + + finally: + metadata.drop_all(pg_engine) + + def test_compare_views_drops_orphan_materialized_view( + self, pg_engine: Engine + ) -> None: + """Test compare_views detects materialized view to drop.""" + metadata = MetaData() + users = Table( + "test_users_drop_mv_compare", + metadata, + Column("id", Integer, primary_key=True), + ) + + # Create a materialized view in the database but NOT in metadata + orphan_mview = MaterializedView( + "test_orphan_mview", + select(func.count(users.c.id).label("cnt")), + with_data=True, + ) + + from sqlalchemy_pgview import CreateMaterializedView, DropMaterializedView + + try: + metadata.create_all(pg_engine) + + with pg_engine.begin() as conn: + conn.execute(CreateMaterializedView(orphan_mview, if_not_exists=True)) + + # Now compare with empty metadata (no views registered) + empty_metadata = MetaData() + + with pg_engine.connect() as conn: + mock_context = MagicMock() + mock_context.connection = conn + mock_context.metadata = empty_metadata + + mock_upgrade_ops = MagicMock() + mock_upgrade_ops.ops = [] + + compare_views(mock_context, mock_upgrade_ops, [None]) + + # Should detect orphan materialized view to drop + drop_ops = [ + op + for op in mock_upgrade_ops.ops + if isinstance(op, DropMaterializedViewOp) + ] + orphan_drop = [ + op for op in drop_ops if op.view_name == "test_orphan_mview" + ] + assert len(orphan_drop) == 1 + + # Cleanup + with pg_engine.begin() as conn: + conn.execute(DropMaterializedView(orphan_mview, if_exists=True)) + + finally: + metadata.drop_all(pg_engine) + + def test_compare_views_no_changes(self, pg_engine: Engine) -> None: + """Test compare_views when views are in sync.""" + metadata = MetaData() + users = Table( + "test_users_sync", + metadata, + Column("id", Integer, primary_key=True), + ) + + # Register a view in metadata + view = View( + "test_view_sync", + select(users.c.id), + metadata=metadata, + ) + + from sqlalchemy_pgview import CreateView, DropView + + try: + metadata.create_all(pg_engine) + + # Create the same view in database + with pg_engine.begin() as conn: + conn.execute(CreateView(view, or_replace=True)) + + with pg_engine.connect() as conn: + mock_context = MagicMock() + mock_context.connection = conn + mock_context.metadata = metadata + + mock_upgrade_ops = MagicMock() + mock_upgrade_ops.ops = [] + + compare_views(mock_context, mock_upgrade_ops, [None]) + + # Should not detect any changes for this specific view + # (there might be other system views to drop) + create_ops = [ + op + for op in mock_upgrade_ops.ops + if isinstance(op, CreateViewOp) + and op.view_name == "test_view_sync" + ] + assert len(create_ops) == 0 + + # Cleanup + with pg_engine.begin() as conn: + conn.execute(DropView(view, if_exists=True)) + + finally: + metadata.drop_all(pg_engine) + + +class TestAlembicIntegration: + """Integration tests for Alembic operations with real database.""" + + def test_create_and_drop_view_integration(self, pg_engine: Engine) -> None: + """Test creating and dropping a view using Alembic ops.""" + metadata = MetaData() + Table( + "test_users_alembic_int", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100)), + ) + + try: + metadata.create_all(pg_engine) + + with pg_engine.begin() as conn: + # Create view using raw SQL (simulating Alembic implementation) + conn.exec_driver_sql( + "CREATE VIEW test_view_alembic_int AS SELECT id, name FROM test_users_alembic_int" + ) + + # Verify view exists + from sqlalchemy import text + + result = conn.execute( + text( + "SELECT COUNT(*) FROM pg_class WHERE relname = 'test_view_alembic_int'" + ) + ).scalar() + assert result == 1 + + # Drop view + conn.exec_driver_sql("DROP VIEW IF EXISTS test_view_alembic_int") + + # Verify view is gone + result = conn.execute( + text( + "SELECT COUNT(*) FROM pg_class WHERE relname = 'test_view_alembic_int'" + ) + ).scalar() + assert result == 0 + + finally: + metadata.drop_all(pg_engine) + + def test_create_and_drop_materialized_view_integration( + self, pg_engine: Engine + ) -> None: + """Test creating and dropping a materialized view using Alembic ops.""" + metadata = MetaData() + Table( + "test_users_mview_int", + metadata, + Column("id", Integer, primary_key=True), + ) + + try: + metadata.create_all(pg_engine) + + with pg_engine.begin() as conn: + # Create materialized view + conn.exec_driver_sql( + "CREATE MATERIALIZED VIEW test_mview_alembic_int AS " + "SELECT COUNT(*) as cnt FROM test_users_mview_int WITH DATA" + ) + + # Verify materialized view exists + from sqlalchemy import text + + result = conn.execute( + text( + "SELECT COUNT(*) FROM pg_class WHERE relname = 'test_mview_alembic_int' AND relkind = 'm'" + ) + ).scalar() + assert result == 1 + + # Refresh materialized view + conn.exec_driver_sql( + "REFRESH MATERIALIZED VIEW test_mview_alembic_int WITH DATA" + ) + + # Drop materialized view + conn.exec_driver_sql( + "DROP MATERIALIZED VIEW IF EXISTS test_mview_alembic_int" + ) + + # Verify materialized view is gone + result = conn.execute( + text( + "SELECT COUNT(*) FROM pg_class WHERE relname = 'test_mview_alembic_int'" + ) + ).scalar() + assert result == 0 + + finally: + metadata.drop_all(pg_engine) diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 0000000..ddbdb51 --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,396 @@ +"""Tests for async engine support with asyncpg.""" + +import os +from decimal import Decimal + +import pytest +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + MetaData, + Numeric, + String, + Table, + func, + insert, + select, +) +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from sqlalchemy_pgview import ( + CreateMaterializedView, + CreateView, + DropMaterializedView, + DropView, + MaterializedView, + RefreshMaterializedView, + View, +) + + +@pytest.fixture +async def async_engine() -> AsyncEngine: + """Create an async PostgreSQL engine.""" + url = os.environ.get("POSTGRES_URL") + if not url: + pytest.skip("POSTGRES_URL not set") + + # Convert postgresql:// to postgresql+asyncpg:// + async_url = url.replace("postgresql://", "postgresql+asyncpg://") + engine = create_async_engine(async_url) + + yield engine + + await engine.dispose() + + +@pytest.fixture +async def async_tables(async_engine: AsyncEngine) -> dict[str, Table]: + """Create test tables with async engine.""" + metadata = MetaData() + + users = Table( + "async_users", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100)), + Column("email", String(100)), + ) + + orders = Table( + "async_orders", + metadata, + Column("id", Integer, primary_key=True), + Column("user_id", Integer, ForeignKey("async_users.id")), + Column("total", Numeric(10, 2)), + ) + + async with async_engine.begin() as conn: + await conn.run_sync(metadata.create_all) + + # Insert test data + await conn.execute( + insert(users), + [ + {"id": 1, "name": "Alice", "email": "alice@example.com"}, + {"id": 2, "name": "Bob", "email": "bob@example.com"}, + ], + ) + + await conn.execute( + insert(orders), + [ + {"id": 1, "user_id": 1, "total": Decimal("100.00")}, + {"id": 2, "user_id": 1, "total": Decimal("200.00")}, + {"id": 3, "user_id": 2, "total": Decimal("150.00")}, + ], + ) + + yield {"users": users, "orders": orders, "metadata": metadata} + + async with async_engine.begin() as conn: + await conn.run_sync(metadata.drop_all) + + +class TestAsyncView: + """Tests for View with async engine.""" + + @pytest.mark.asyncio + async def test_create_view_async( + self, async_engine: AsyncEngine, async_tables: dict + ) -> None: + """Test creating a view with async engine.""" + users = async_tables["users"] + orders = async_tables["orders"] + + user_stats = View( + "async_user_stats", + select( + users.c.id, + users.c.name, + func.count(orders.c.id).label("order_count"), + func.coalesce(func.sum(orders.c.total), 0).label("total_spent"), + ) + .select_from(users.outerjoin(orders, users.c.id == orders.c.user_id)) + .group_by(users.c.id, users.c.name), + ) + + async with async_engine.begin() as conn: + # Create view + await conn.execute(CreateView(user_stats, or_replace=True)) + + # Query view + result = await conn.execute( + select(user_stats.as_table()).order_by(user_stats.as_table().c.name) + ) + rows = result.fetchall() + + assert len(rows) == 2 + assert rows[0].name == "Alice" + assert rows[0].order_count == 2 + assert rows[0].total_spent == Decimal("300.00") + + assert rows[1].name == "Bob" + assert rows[1].order_count == 1 + assert rows[1].total_spent == Decimal("150.00") + + # Drop view + await conn.execute(DropView(user_stats, if_exists=True)) + + @pytest.mark.asyncio + async def test_view_reflects_changes_async( + self, async_engine: AsyncEngine, async_tables: dict + ) -> None: + """Test that view reflects changes immediately with async engine.""" + orders = async_tables["orders"] + + order_count_view = View( + "async_order_count", + select(func.count(orders.c.id).label("total_orders")), + ) + + async with async_engine.begin() as conn: + await conn.execute(CreateView(order_count_view, or_replace=True)) + + # Check initial count + result = await conn.execute(select(order_count_view.as_table())) + row = result.fetchone() + assert row.total_orders == 3 + + # Insert new order + await conn.execute( + insert(orders).values(id=100, user_id=1, total=Decimal("50.00")) + ) + + # View should show updated count + result = await conn.execute(select(order_count_view.as_table())) + row = result.fetchone() + assert row.total_orders == 4 + + await conn.execute(DropView(order_count_view, if_exists=True)) + + +class TestAsyncMaterializedView: + """Tests for MaterializedView with async engine.""" + + @pytest.mark.asyncio + async def test_create_materialized_view_async( + self, async_engine: AsyncEngine, async_tables: dict + ) -> None: + """Test creating a materialized view with async engine.""" + users = async_tables["users"] + orders = async_tables["orders"] + + user_summary_mv = MaterializedView( + "async_user_summary_mv", + select( + users.c.id, + users.c.name, + func.count(orders.c.id).label("order_count"), + ) + .select_from(users.outerjoin(orders, users.c.id == orders.c.user_id)) + .group_by(users.c.id, users.c.name), + with_data=True, + ) + + async with async_engine.begin() as conn: + # Create materialized view + await conn.execute(CreateMaterializedView(user_summary_mv)) + + # Query materialized view + result = await conn.execute( + select(user_summary_mv.as_table()).order_by( + user_summary_mv.as_table().c.name + ) + ) + rows = result.fetchall() + + assert len(rows) == 2 + assert rows[0].name == "Alice" + assert rows[0].order_count == 2 + + # Drop materialized view + await conn.execute(DropMaterializedView(user_summary_mv, if_exists=True)) + + @pytest.mark.asyncio + async def test_materialized_view_stale_until_refresh_async( + self, async_engine: AsyncEngine, async_tables: dict + ) -> None: + """Test that materialized view is stale until refreshed with async engine.""" + orders = async_tables["orders"] + + order_count_mv = MaterializedView( + "async_order_count_mv", + select(func.count(orders.c.id).label("total_orders")), + with_data=True, + ) + + async with async_engine.begin() as conn: + await conn.execute(CreateMaterializedView(order_count_mv)) + + # Check initial count + result = await conn.execute(select(order_count_mv.as_table())) + row = result.fetchone() + assert row.total_orders == 3 + + # Insert new order + await conn.execute( + insert(orders).values(id=101, user_id=1, total=Decimal("75.00")) + ) + + # Materialized view still shows old count (stale) + result = await conn.execute(select(order_count_mv.as_table())) + row = result.fetchone() + assert row.total_orders == 3 # Still 3! + + # Refresh materialized view + await conn.execute(RefreshMaterializedView(order_count_mv)) + + # Now shows updated count + result = await conn.execute(select(order_count_mv.as_table())) + row = result.fetchone() + assert row.total_orders == 4 + + await conn.execute(DropMaterializedView(order_count_mv, if_exists=True)) + + @pytest.mark.asyncio + async def test_materialized_view_refresh_method_async( + self, async_engine: AsyncEngine, async_tables: dict + ) -> None: + """Test MaterializedView.refresh() method with async engine.""" + orders = async_tables["orders"] + + count_mv = MaterializedView( + "async_count_mv", + select(func.count(orders.c.id).label("cnt")), + with_data=True, + ) + + async with async_engine.begin() as conn: + await conn.execute(CreateMaterializedView(count_mv)) + + result = await conn.execute(select(count_mv.as_table())) + assert result.fetchone().cnt == 3 + + # Insert data + await conn.execute( + insert(orders).values(id=102, user_id=2, total=Decimal("25.00")) + ) + + # Use run_sync to call the synchronous refresh method + def refresh_sync(sync_conn): + count_mv.refresh(sync_conn) + + await conn.run_sync(refresh_sync) + + result = await conn.execute(select(count_mv.as_table())) + assert result.fetchone().cnt == 4 + + await conn.execute(DropMaterializedView(count_mv, if_exists=True)) + + +class TestAsyncDependencies: + """Tests for dependency functions with async engine.""" + + @pytest.mark.asyncio + async def test_get_all_views_async( + self, async_engine: AsyncEngine, async_tables: dict + ) -> None: + """Test get_all_views with async engine using run_sync.""" + from sqlalchemy_pgview import get_all_views + + users = async_tables["users"] + + test_view = View( + "async_test_view_deps", + select(users.c.id, users.c.name), + ) + + async with async_engine.begin() as conn: + await conn.execute(CreateView(test_view, or_replace=True)) + + # Use run_sync for dependency functions + def get_views_sync(sync_conn): + return get_all_views(sync_conn) + + views = await conn.run_sync(get_views_sync) + view_names = [v.name for v in views] + + assert "async_test_view_deps" in view_names + + await conn.execute(DropView(test_view, if_exists=True)) + + @pytest.mark.asyncio + async def test_get_view_definition_async( + self, async_engine: AsyncEngine, async_tables: dict + ) -> None: + """Test get_view_definition with async engine.""" + from sqlalchemy_pgview import get_view_definition + + users = async_tables["users"] + + test_view = View( + "async_def_test_view", + select(users.c.id, users.c.name).where(users.c.id > 0), + ) + + async with async_engine.begin() as conn: + await conn.execute(CreateView(test_view, or_replace=True)) + + def get_def_sync(sync_conn): + return get_view_definition(sync_conn, "async_def_test_view") + + definition = await conn.run_sync(get_def_sync) + + assert definition is not None + assert "async_users" in definition.lower() + + await conn.execute(DropView(test_view, if_exists=True)) + + +class TestAsyncViewWithJoins: + """Tests for views with complex joins using async engine.""" + + @pytest.mark.asyncio + async def test_view_with_aggregation_async( + self, async_engine: AsyncEngine, async_tables: dict + ) -> None: + """Test view with GROUP BY and aggregation functions.""" + users = async_tables["users"] + orders = async_tables["orders"] + + stats_view = View( + "async_stats_view", + select( + users.c.name, + func.count(orders.c.id).label("orders"), + func.sum(orders.c.total).label("total"), + func.avg(orders.c.total).label("avg_order"), + ) + .select_from(users.join(orders, users.c.id == orders.c.user_id)) + .group_by(users.c.name), + ) + + async with async_engine.begin() as conn: + await conn.execute(CreateView(stats_view, or_replace=True)) + + result = await conn.execute( + select(stats_view.as_table()).order_by(stats_view.as_table().c.total.desc()) + ) + rows = result.fetchall() + + assert len(rows) == 2 + + # Alice: 2 orders, total 300 + alice = next(r for r in rows if r.name == "Alice") + assert alice.orders == 2 + assert alice.total == Decimal("300.00") + assert alice.avg_order == Decimal("150.00") + + # Bob: 1 order, total 150 + bob = next(r for r in rows if r.name == "Bob") + assert bob.orders == 1 + assert bob.total == Decimal("150.00") + + await conn.execute(DropView(stats_view, if_exists=True)) diff --git a/tests/test_auto_refresh.py b/tests/test_auto_refresh.py new file mode 100644 index 0000000..993d09b --- /dev/null +++ b/tests/test_auto_refresh.py @@ -0,0 +1,369 @@ +"""Tests for auto-refresh functionality.""" + +from __future__ import annotations + +import os +from decimal import Decimal +from typing import TYPE_CHECKING + +import pytest +from sqlalchemy import ( + Column, + Integer, + MetaData, + Numeric, + String, + Table, + create_engine, + func, + insert, + select, + text, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column + +from sqlalchemy_pgview import AutoRefreshContext, MaterializedView +from sqlalchemy_pgview.ddl import DropMaterializedView + +if TYPE_CHECKING: + from sqlalchemy.engine import Engine + + +@pytest.fixture +def pg_engine() -> Engine: + """Create a PostgreSQL engine for testing.""" + url = os.environ.get("POSTGRES_URL", "postgresql://test:test@localhost:5432/testdb") + return create_engine(url) + + +class TestAutoRefreshContext: + """Tests for AutoRefreshContext (Core usage).""" + + def test_auto_refresh_context_refreshes_on_exit(self, pg_engine: Engine) -> None: + """Test that AutoRefreshContext refreshes view on successful exit.""" + metadata = MetaData() + + orders = Table( + "test_orders_arc", + metadata, + Column("id", Integer, primary_key=True), + Column("total", Numeric(10, 2)), + ) + + order_stats = MaterializedView( + "test_order_stats_arc", + select( + func.count(orders.c.id).label("order_count"), + func.sum(orders.c.total).label("total_revenue"), + ), + metadata=metadata, + ) + + try: + metadata.create_all(pg_engine) + + with pg_engine.begin() as conn: + # Initial state - empty + result = conn.execute(select(order_stats.as_table())).fetchone() + assert result.order_count == 0 + + # Use AutoRefreshContext to auto-refresh after changes + with AutoRefreshContext(conn, order_stats, orders): + conn.execute(insert(orders).values(id=1, total=100)) + conn.execute(insert(orders).values(id=2, total=200)) + # View is refreshed here + + # Check the view was refreshed + result = conn.execute(select(order_stats.as_table())).fetchone() + assert result.order_count == 2 + assert result.total_revenue == Decimal("300") + + finally: + with pg_engine.begin() as conn: + conn.execute(DropMaterializedView(order_stats, if_exists=True)) + metadata.drop_all(pg_engine) + + def test_auto_refresh_context_no_refresh_on_exception( + self, pg_engine: Engine + ) -> None: + """Test that AutoRefreshContext doesn't refresh on exception.""" + metadata = MetaData() + + items = Table( + "test_items_arc", + metadata, + Column("id", Integer, primary_key=True), + Column("value", Integer), + ) + + item_stats = MaterializedView( + "test_item_stats_arc", + select(func.count(items.c.id).label("item_count")), + metadata=metadata, + ) + + try: + metadata.create_all(pg_engine) + + with pg_engine.begin() as conn: + # Insert initial data and refresh + conn.execute(insert(items).values(id=1, value=10)) + item_stats.refresh(conn) + + result = conn.execute(select(item_stats.as_table())).fetchone() + assert result.item_count == 1 + + # Try with exception - should not refresh + try: + with pg_engine.begin() as conn, AutoRefreshContext(conn, item_stats, items): + conn.execute(insert(items).values(id=2, value=20)) + raise ValueError("Simulated error") + except ValueError: + pass + + # View should still show old data (not refreshed due to exception) + with pg_engine.connect() as conn: + result = conn.execute(select(item_stats.as_table())).fetchone() + # Note: The insert was rolled back, so count is still 1 + assert result.item_count == 1 + + finally: + with pg_engine.begin() as conn: + conn.execute(DropMaterializedView(item_stats, if_exists=True)) + metadata.drop_all(pg_engine) + + +class TestAutoRefreshORM: + """Tests for auto_refresh_on with ORM.""" + + def test_auto_refresh_on_commit(self, pg_engine: Engine) -> None: + """Test that materialized view refreshes after ORM commit.""" + + class Base(DeclarativeBase): + pass + + class Product(Base): + __tablename__ = "test_products_ar" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + price: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + + metadata = Base.metadata + + # Create MV based on products table + product_stats = MaterializedView( + "test_product_stats_ar", + select( + func.count(Product.id).label("product_count"), + func.avg(Product.price).label("avg_price"), + ), + metadata=metadata, + ) + + # Create a custom Session class for this test + class TestSession(Session): + pass + + # Enable auto-refresh + product_stats.auto_refresh_on(TestSession, Product.__table__) + + try: + metadata.create_all(pg_engine) + + # Initial state + with pg_engine.connect() as conn: + result = conn.execute(select(product_stats.as_table())).fetchone() + assert result.product_count == 0 + + # Add product via ORM + with TestSession(pg_engine) as session: + session.add(Product(id=1, name="Widget", price=Decimal("19.99"))) + session.commit() # Should trigger refresh + + # Check view was refreshed + with pg_engine.connect() as conn: + result = conn.execute(select(product_stats.as_table())).fetchone() + assert result.product_count == 1 + assert result.avg_price == Decimal("19.99") + + # Add more products + with TestSession(pg_engine) as session: + session.add(Product(id=2, name="Gadget", price=Decimal("29.99"))) + session.add(Product(id=3, name="Gizmo", price=Decimal("9.99"))) + session.commit() + + # Check view updated + with pg_engine.connect() as conn: + result = conn.execute(select(product_stats.as_table())).fetchone() + assert result.product_count == 3 + + finally: + with pg_engine.begin() as conn: + conn.execute(DropMaterializedView(product_stats, if_exists=True)) + metadata.drop_all(pg_engine) + + def test_auto_refresh_on_update(self, pg_engine: Engine) -> None: + """Test that materialized view refreshes after ORM update.""" + + class Base(DeclarativeBase): + pass + + class Counter(Base): + __tablename__ = "test_counters_ar" + id: Mapped[int] = mapped_column(primary_key=True) + value: Mapped[int] = mapped_column(Integer) + + metadata = Base.metadata + + counter_sum = MaterializedView( + "test_counter_sum_ar", + select(func.sum(Counter.value).label("total")), + metadata=metadata, + ) + + class TestSession(Session): + pass + + counter_sum.auto_refresh_on(TestSession, Counter.__table__) + + try: + metadata.create_all(pg_engine) + + # Add initial data + with TestSession(pg_engine) as session: + session.add(Counter(id=1, value=10)) + session.add(Counter(id=2, value=20)) + session.commit() + + with pg_engine.connect() as conn: + result = conn.execute(select(counter_sum.as_table())).fetchone() + assert result.total == 30 + + # Update via ORM + with TestSession(pg_engine) as session: + counter = session.get(Counter, 1) + counter.value = 50 + session.commit() + + with pg_engine.connect() as conn: + result = conn.execute(select(counter_sum.as_table())).fetchone() + assert result.total == 70 # 50 + 20 + + finally: + with pg_engine.begin() as conn: + conn.execute(DropMaterializedView(counter_sum, if_exists=True)) + metadata.drop_all(pg_engine) + + def test_auto_refresh_on_delete(self, pg_engine: Engine) -> None: + """Test that materialized view refreshes after ORM delete.""" + + class Base(DeclarativeBase): + pass + + class Item(Base): + __tablename__ = "test_items_ar_del" + id: Mapped[int] = mapped_column(primary_key=True) + + metadata = Base.metadata + + item_count = MaterializedView( + "test_item_count_ar", + select(func.count(Item.id).label("count")), + metadata=metadata, + ) + + class TestSession(Session): + pass + + item_count.auto_refresh_on(TestSession, Item.__table__) + + try: + metadata.create_all(pg_engine) + + # Add initial data + with TestSession(pg_engine) as session: + session.add(Item(id=1)) + session.add(Item(id=2)) + session.add(Item(id=3)) + session.commit() + + with pg_engine.connect() as conn: + result = conn.execute(select(item_count.as_table())).fetchone() + assert result.count == 3 + + # Delete via ORM + with TestSession(pg_engine) as session: + item = session.get(Item, 2) + session.delete(item) + session.commit() + + with pg_engine.connect() as conn: + result = conn.execute(select(item_count.as_table())).fetchone() + assert result.count == 2 + + finally: + with pg_engine.begin() as conn: + conn.execute(DropMaterializedView(item_count, if_exists=True)) + metadata.drop_all(pg_engine) + + def test_no_refresh_on_unrelated_table(self, pg_engine: Engine) -> None: + """Test that unrelated table changes don't trigger refresh.""" + + class Base(DeclarativeBase): + pass + + class TableA(Base): + __tablename__ = "test_table_a_ar" + id: Mapped[int] = mapped_column(primary_key=True) + value: Mapped[int] = mapped_column(Integer) + + class TableB(Base): + __tablename__ = "test_table_b_ar" + id: Mapped[int] = mapped_column(primary_key=True) + + metadata = Base.metadata + + # MV only watches TableA + table_a_sum = MaterializedView( + "test_table_a_sum_ar", + select(func.sum(TableA.value).label("total")), + metadata=metadata, + ) + + class TestSession(Session): + pass + + # Only watch TableA + table_a_sum.auto_refresh_on(TestSession, TableA.__table__) + + try: + metadata.create_all(pg_engine) + + # Add to TableA + with TestSession(pg_engine) as session: + session.add(TableA(id=1, value=100)) + session.commit() + + with pg_engine.connect() as conn: + result = conn.execute(select(table_a_sum.as_table())).fetchone() + assert result.total == 100 + + # Add to TableB (should not trigger refresh) + # First, manually make the view stale + with pg_engine.begin() as conn: + conn.execute(text("INSERT INTO test_table_a_ar (id, value) VALUES (2, 200)")) + + with TestSession(pg_engine) as session: + session.add(TableB(id=1)) + session.commit() + + # View should still show stale data (100, not 300) + # because TableB changes don't trigger refresh + with pg_engine.connect() as conn: + result = conn.execute(select(table_a_sum.as_table())).fetchone() + assert result.total == 100 # Still stale + + finally: + with pg_engine.begin() as conn: + conn.execute(DropMaterializedView(table_a_sum, if_exists=True)) + metadata.drop_all(pg_engine) diff --git a/tests/test_ddl.py b/tests/test_ddl.py new file mode 100644 index 0000000..5246900 --- /dev/null +++ b/tests/test_ddl.py @@ -0,0 +1,182 @@ +"""Tests for DDL operations.""" + +from sqlalchemy import select +from sqlalchemy.engine import Engine + +from sqlalchemy_pgview import ( + CreateMaterializedView, + CreateView, + DropMaterializedView, + DropView, + MaterializedView, + RefreshMaterializedView, + View, +) + + +class TestCreateView: + """Tests for CreateView DDL.""" + + def test_create_view_sql(self, pg_engine: Engine, sample_tables: tuple) -> None: + """Test CREATE VIEW SQL generation.""" + users, _ = sample_tables + view = View( + "active_users", + select(users.c.id, users.c.name), + ) + + stmt = CreateView(view) + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert "CREATE VIEW active_users AS" in sql + assert "users.id" in sql + assert "users.name" in sql + + def test_create_or_replace_view_sql( + self, pg_engine: Engine, sample_tables: tuple + ) -> None: + """Test CREATE OR REPLACE VIEW SQL generation.""" + users, _ = sample_tables + view = View( + "active_users", + select(users.c.id), + ) + + stmt = CreateView(view, or_replace=True) + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert "CREATE OR REPLACE VIEW" in sql + + def test_create_view_with_schema( + self, pg_engine: Engine, sample_tables: tuple + ) -> None: + """Test CREATE VIEW with schema.""" + users, _ = sample_tables + view = View( + "active_users", + select(users.c.id), + schema="analytics", + ) + + stmt = CreateView(view) + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert "analytics.active_users" in sql + + +class TestDropView: + """Tests for DropView DDL.""" + + def test_drop_view_sql(self, pg_engine: Engine) -> None: + """Test DROP VIEW SQL generation.""" + stmt = DropView("test_view") + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert sql == "DROP VIEW test_view" + + def test_drop_view_if_exists(self, pg_engine: Engine) -> None: + """Test DROP VIEW IF EXISTS.""" + stmt = DropView("test_view", if_exists=True) + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert sql == "DROP VIEW IF EXISTS test_view" + + def test_drop_view_cascade(self, pg_engine: Engine) -> None: + """Test DROP VIEW CASCADE.""" + stmt = DropView("test_view", cascade=True) + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert sql == "DROP VIEW test_view CASCADE" + + def test_drop_view_with_schema(self, pg_engine: Engine) -> None: + """Test DROP VIEW with schema.""" + stmt = DropView("test_view", schema="analytics", if_exists=True) + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert sql == "DROP VIEW IF EXISTS analytics.test_view" + + +class TestCreateMaterializedView: + """Tests for CreateMaterializedView DDL.""" + + def test_create_materialized_view_sql( + self, pg_engine: Engine, sample_tables: tuple + ) -> None: + """Test CREATE MATERIALIZED VIEW SQL generation.""" + users, _ = sample_tables + mview = MaterializedView( + "user_cache", + select(users.c.id, users.c.name), + with_data=True, + ) + + stmt = CreateMaterializedView(mview) + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert "CREATE MATERIALIZED VIEW user_cache AS" in sql + assert "WITH DATA" in sql + + def test_create_materialized_view_without_data( + self, pg_engine: Engine, sample_tables: tuple + ) -> None: + """Test CREATE MATERIALIZED VIEW WITH NO DATA.""" + users, _ = sample_tables + mview = MaterializedView( + "user_cache", + select(users.c.id), + with_data=False, + ) + + stmt = CreateMaterializedView(mview) + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert "WITH NO DATA" in sql + + +class TestDropMaterializedView: + """Tests for DropMaterializedView DDL.""" + + def test_drop_materialized_view_sql(self, pg_engine: Engine) -> None: + """Test DROP MATERIALIZED VIEW SQL generation.""" + stmt = DropMaterializedView("test_mview", if_exists=True, cascade=True) + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert sql == "DROP MATERIALIZED VIEW IF EXISTS test_mview CASCADE" + + +class TestRefreshMaterializedView: + """Tests for RefreshMaterializedView DDL.""" + + def test_refresh_materialized_view_sql(self, pg_engine: Engine) -> None: + """Test REFRESH MATERIALIZED VIEW SQL generation.""" + stmt = RefreshMaterializedView("test_mview") + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert sql == "REFRESH MATERIALIZED VIEW test_mview WITH DATA" + + def test_refresh_concurrently(self, pg_engine: Engine) -> None: + """Test REFRESH MATERIALIZED VIEW CONCURRENTLY.""" + stmt = RefreshMaterializedView("test_mview", concurrently=True) + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert "CONCURRENTLY" in sql + + def test_refresh_without_data(self, pg_engine: Engine) -> None: + """Test REFRESH MATERIALIZED VIEW WITH NO DATA.""" + stmt = RefreshMaterializedView("test_mview", with_data=False) + compiled = stmt.compile(dialect=pg_engine.dialect) + sql = str(compiled) + + assert "WITH NO DATA" in sql diff --git a/tests/test_declarative.py b/tests/test_declarative.py new file mode 100644 index 0000000..d8f293a --- /dev/null +++ b/tests/test_declarative.py @@ -0,0 +1,395 @@ +"""Tests for declarative view classes.""" + +from __future__ import annotations + +import os +from decimal import Decimal +from typing import TYPE_CHECKING + +import pytest +from sqlalchemy import ( + Integer, + Numeric, + String, + func, + select, +) +from sqlalchemy.engine import create_engine +from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column + +from sqlalchemy_pgview import ( + MaterializedViewBase, + ViewBase, +) +from sqlalchemy_pgview.ddl import ( + DropMaterializedView, + DropView, +) + +if TYPE_CHECKING: + from sqlalchemy.engine import Engine + + +@pytest.fixture +def pg_engine() -> Engine: + """Create a PostgreSQL engine for testing.""" + url = os.environ.get("POSTGRES_URL", "postgresql://test:test@localhost:5432/testdb") + return create_engine(url) + + +class TestViewBase: + """Tests for ViewBase declarative views.""" + + def test_basic_view_class(self, pg_engine: Engine) -> None: + """Test basic ViewBase subclass creation and querying.""" + + class Base(DeclarativeBase): + pass + + class User(Base): + __tablename__ = "test_users_vb" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + is_active: Mapped[int] = mapped_column(Integer) + + class ActiveUsers(ViewBase, Base): + __tablename__ = "test_active_users_vb" + __select__ = select(User.id, User.name).where(User.is_active == 1) + + try: + Base.metadata.create_all(pg_engine) + + with Session(pg_engine) as session: + session.add(User(id=1, name="Alice", is_active=1)) + session.add(User(id=2, name="Bob", is_active=0)) + session.add(User(id=3, name="Charlie", is_active=1)) + session.commit() + + with pg_engine.connect() as conn: + result = conn.execute(select(ActiveUsers.as_table())).fetchall() + assert len(result) == 2 + names = {row.name for row in result} + assert names == {"Alice", "Charlie"} + + finally: + with pg_engine.begin() as conn: + conn.execute(DropView(ActiveUsers._view, if_exists=True)) + Base.metadata.drop_all(pg_engine) + + def test_view_class_columns_shorthand(self, pg_engine: Engine) -> None: + """Test that .c shorthand works for columns.""" + + class Base(DeclarativeBase): + pass + + class Product(Base): + __tablename__ = "test_products_vb" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + price: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + + class ProductView(ViewBase, Base): + __tablename__ = "test_product_view" + __select__ = select(Product.id, Product.name, Product.price) + + try: + Base.metadata.create_all(pg_engine) + + with Session(pg_engine) as session: + session.add(Product(id=1, name="Widget", price=Decimal("9.99"))) + session.commit() + + with pg_engine.connect() as conn: + result = conn.execute( + select(ProductView.c.name, ProductView.c.price) + ).fetchone() + assert result.name == "Widget" + assert result.price == Decimal("9.99") + + finally: + with pg_engine.begin() as conn: + conn.execute(DropView(ProductView._view, if_exists=True)) + Base.metadata.drop_all(pg_engine) + + def test_view_class_missing_tablename_raises(self) -> None: + """Test that missing __tablename__ raises TypeError.""" + + class Base(DeclarativeBase): + pass + + with pytest.raises(TypeError, match="must define __tablename__"): + + class BadView(ViewBase, Base): + __select__ = select() + + def test_view_class_missing_select_raises(self) -> None: + """Test that missing __select__ raises TypeError.""" + + class Base(DeclarativeBase): + pass + + with pytest.raises(TypeError, match="must define __select__"): + + class BadView(ViewBase, Base): + __tablename__ = "bad_view" + + +class TestMaterializedViewBase: + """Tests for MaterializedViewBase declarative views.""" + + def test_basic_materialized_view_class(self, pg_engine: Engine) -> None: + """Test basic MaterializedViewBase subclass.""" + + class Base(DeclarativeBase): + pass + + class Order(Base): + __tablename__ = "test_orders_mvb" + id: Mapped[int] = mapped_column(primary_key=True) + total: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + + class OrderStats(MaterializedViewBase, Base): + __tablename__ = "test_order_stats_mvb" + __select__ = select( + func.count(Order.id).label("order_count"), + func.sum(Order.total).label("total_revenue"), + ) + + try: + Base.metadata.create_all(pg_engine) + + # Initial state - empty + with pg_engine.connect() as conn: + result = conn.execute(select(OrderStats.as_table())).fetchone() + assert result.order_count == 0 + + with Session(pg_engine) as session: + session.add(Order(id=1, total=Decimal("100.00"))) + session.add(Order(id=2, total=Decimal("200.00"))) + session.commit() + + # MV is stale until refresh + with pg_engine.connect() as conn: + result = conn.execute(select(OrderStats.as_table())).fetchone() + assert result.order_count == 0 + + # Refresh using class method + with pg_engine.begin() as conn: + OrderStats.refresh(conn) + + with pg_engine.connect() as conn: + result = conn.execute(select(OrderStats.as_table())).fetchone() + assert result.order_count == 2 + assert result.total_revenue == Decimal("300.00") + + finally: + with pg_engine.begin() as conn: + conn.execute(DropMaterializedView(OrderStats._view, if_exists=True)) + Base.metadata.drop_all(pg_engine) + + def test_materialized_view_with_data_false(self, pg_engine: Engine) -> None: + """Test MaterializedViewBase with __with_data__ = False.""" + + class Base(DeclarativeBase): + pass + + class Item(Base): + __tablename__ = "test_items_mvb_nodata" + id: Mapped[int] = mapped_column(primary_key=True) + + class ItemCount(MaterializedViewBase, Base): + __tablename__ = "test_item_count_mvb" + __select__ = select(func.count(Item.id).label("count")) + __with_data__ = False + + try: + Base.metadata.create_all(pg_engine) + + with Session(pg_engine) as session: + session.add(Item(id=1)) + session.commit() + + with pg_engine.begin() as conn: + ItemCount.refresh(conn) + + with pg_engine.connect() as conn: + result = conn.execute(select(ItemCount.as_table())).fetchone() + assert result.count == 1 + + finally: + with pg_engine.begin() as conn: + conn.execute(DropMaterializedView(ItemCount._view, if_exists=True)) + Base.metadata.drop_all(pg_engine) + + def test_materialized_view_auto_refresh(self, pg_engine: Engine) -> None: + """Test MaterializedViewBase auto-refresh functionality.""" + + class Base(DeclarativeBase): + pass + + class Counter(Base): + __tablename__ = "test_counter_mvb_ar" + id: Mapped[int] = mapped_column(primary_key=True) + value: Mapped[int] = mapped_column(Integer) + + class CounterSum(MaterializedViewBase, Base): + __tablename__ = "test_counter_sum_mvb" + __select__ = select(func.sum(Counter.value).label("total")) + + class TestSession(Session): + pass + + CounterSum.auto_refresh_on(TestSession, Counter.__table__) + + try: + Base.metadata.create_all(pg_engine) + + with TestSession(pg_engine) as session: + session.add(Counter(id=1, value=10)) + session.add(Counter(id=2, value=20)) + session.commit() + + with pg_engine.connect() as conn: + result = conn.execute(select(CounterSum.as_table())).fetchone() + assert result.total == 30 + + finally: + with pg_engine.begin() as conn: + conn.execute(DropMaterializedView(CounterSum._view, if_exists=True)) + Base.metadata.drop_all(pg_engine) + + +class TestMultipleInheritance: + """Tests for multiple inheritance with DeclarativeBase.""" + + def test_view_inherits_metadata(self, pg_engine: Engine) -> None: + """Test that ViewBase inherits metadata from DeclarativeBase.""" + + class Base(DeclarativeBase): + pass + + class Product(Base): + __tablename__ = "test_product_mi" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + price: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + + class ExpensiveProducts(ViewBase, Base): + __tablename__ = "test_expensive_products_mi" + __select__ = select(Product.id, Product.name, Product.price).where( + Product.price > 50 + ) + + try: + Base.metadata.create_all(pg_engine) + + with Session(pg_engine) as session: + session.add(Product(id=1, name="Cheap", price=Decimal("10.00"))) + session.add(Product(id=2, name="Expensive", price=Decimal("100.00"))) + session.add(Product(id=3, name="Premium", price=Decimal("200.00"))) + session.commit() + + with pg_engine.connect() as conn: + result = conn.execute(select(ExpensiveProducts.as_table())).fetchall() + assert len(result) == 2 + names = {row.name for row in result} + assert names == {"Expensive", "Premium"} + + finally: + with pg_engine.begin() as conn: + conn.execute(DropView(ExpensiveProducts._view, if_exists=True)) + Base.metadata.drop_all(pg_engine) + + def test_materialized_view_inherits_metadata(self, pg_engine: Engine) -> None: + """Test that MaterializedViewBase inherits metadata from DeclarativeBase.""" + + class Base(DeclarativeBase): + pass + + class Sale(Base): + __tablename__ = "test_sale_mi" + id: Mapped[int] = mapped_column(primary_key=True) + amount: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + + class SaleStats(MaterializedViewBase, Base): + __tablename__ = "test_sale_stats_mi" + __select__ = select( + func.count(Sale.id).label("sale_count"), + func.sum(Sale.amount).label("total_amount"), + ) + + try: + Base.metadata.create_all(pg_engine) + + with Session(pg_engine) as session: + session.add(Sale(id=1, amount=Decimal("100.00"))) + session.add(Sale(id=2, amount=Decimal("200.00"))) + session.commit() + + with pg_engine.begin() as conn: + SaleStats.refresh(conn) + + with pg_engine.connect() as conn: + result = conn.execute(select(SaleStats.as_table())).fetchone() + assert result.sale_count == 2 + assert result.total_amount == Decimal("300.00") + + finally: + with pg_engine.begin() as conn: + conn.execute(DropMaterializedView(SaleStats._view, if_exists=True)) + Base.metadata.drop_all(pg_engine) + + def test_multiple_views_same_base(self, pg_engine: Engine) -> None: + """Test multiple views sharing the same DeclarativeBase.""" + + class Base(DeclarativeBase): + pass + + class User(Base): + __tablename__ = "test_user_multi" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + role: Mapped[str] = mapped_column(String(50)) + + class AdminUsers(ViewBase, Base): + __tablename__ = "test_admin_users" + __select__ = select(User.id, User.name).where(User.role == "admin") + + class RegularUsers(ViewBase, Base): + __tablename__ = "test_regular_users" + __select__ = select(User.id, User.name).where(User.role == "user") + + class UserStats(MaterializedViewBase, Base): + __tablename__ = "test_user_stats_multi" + __select__ = select(func.count(User.id).label("count")) + + try: + Base.metadata.create_all(pg_engine) + + with Session(pg_engine) as session: + session.add(User(id=1, name="Alice", role="admin")) + session.add(User(id=2, name="Bob", role="user")) + session.add(User(id=3, name="Charlie", role="user")) + session.commit() + + with pg_engine.connect() as conn: + admins = conn.execute(select(AdminUsers.as_table())).fetchall() + assert len(admins) == 1 + assert admins[0].name == "Alice" + + regulars = conn.execute(select(RegularUsers.as_table())).fetchall() + assert len(regulars) == 2 + + with pg_engine.begin() as conn: + UserStats.refresh(conn) + + with pg_engine.connect() as conn: + stats = conn.execute(select(UserStats.as_table())).fetchone() + assert stats.count == 3 + + finally: + with pg_engine.begin() as conn: + conn.execute(DropView(AdminUsers._view, if_exists=True)) + conn.execute(DropView(RegularUsers._view, if_exists=True)) + conn.execute(DropMaterializedView(UserStats._view, if_exists=True)) + Base.metadata.drop_all(pg_engine) diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 0000000..53ce7dd --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,327 @@ +"""Tests for view dependency tracking.""" + +from sqlalchemy import Table, func, select +from sqlalchemy.engine import Engine + +from sqlalchemy_pgview import ( + CreateMaterializedView, + CreateView, + DropMaterializedView, + DropView, + MaterializedView, + View, + get_all_views, + get_view_definition, +) +from sqlalchemy_pgview.dependencies import ( + ViewDependency, + ViewInfo, + get_dependency_order, + get_reverse_dependencies, + get_view_dependencies, +) + + +class TestViewInfo: + """Tests for ViewInfo dataclass.""" + + def test_view_info_fullname_without_schema(self) -> None: + """Test ViewInfo.fullname without schema.""" + info = ViewInfo( + name="my_view", + schema=None, + definition="SELECT 1", + is_materialized=False, + ) + assert info.fullname == "my_view" + + def test_view_info_fullname_with_schema(self) -> None: + """Test ViewInfo.fullname with schema.""" + info = ViewInfo( + name="my_view", + schema="analytics", + definition="SELECT 1", + is_materialized=False, + ) + assert info.fullname == "analytics.my_view" + + +class TestViewDependency: + """Tests for ViewDependency dataclass.""" + + def test_view_dependency_fullnames(self) -> None: + """Test ViewDependency fullname properties.""" + dep = ViewDependency( + dependent_view="child_view", + dependent_schema="public", + referenced_view="parent_view", + referenced_schema="analytics", + ) + assert dep.dependent_fullname == "public.child_view" + assert dep.referenced_fullname == "analytics.parent_view" + + +class TestGetAllViews: + """Tests for get_all_views function.""" + + def test_get_all_views( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test getting all views from database.""" + authors = pg_one_to_many_tables["authors"] + books = pg_one_to_many_tables["books"] + + view1 = View( + "test_view_deps_1", + select(authors.c.id, authors.c.name), + ) + + mview1 = MaterializedView( + "test_mview_deps_1", + select(func.count(books.c.id).label("count")), + with_data=True, + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(view1, or_replace=True)) + conn.execute(CreateMaterializedView(mview1, if_not_exists=True)) + + views = get_all_views(conn) + view_names = [v.name for v in views] + + assert "test_view_deps_1" in view_names + assert "test_mview_deps_1" in view_names + + # Check materialized flag + mview = next(v for v in views if v.name == "test_mview_deps_1") + assert mview.is_materialized is True + + regular = next(v for v in views if v.name == "test_view_deps_1") + assert regular.is_materialized is False + + conn.execute(DropView(view1, if_exists=True)) + conn.execute(DropMaterializedView(mview1, if_exists=True)) + + def test_get_all_views_with_schema_filter( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test getting views filtered by schema.""" + authors = pg_one_to_many_tables["authors"] + + view = View( + "test_view_schema_filter", + select(authors.c.id), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(view, or_replace=True)) + + # Filter by public schema + views = get_all_views(conn, schema="public") + view_names = [v.name for v in views] + + assert "test_view_schema_filter" in view_names + + # Filter by non-existent schema + views = get_all_views(conn, schema="nonexistent") + assert len(views) == 0 + + conn.execute(DropView(view, if_exists=True)) + + def test_get_all_views_exclude_materialized( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test getting views excluding materialized views.""" + authors = pg_one_to_many_tables["authors"] + + view = View("test_view_excl", select(authors.c.id)) + mview = MaterializedView( + "test_mview_excl", + select(func.count(authors.c.id).label("cnt")), + with_data=True, + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(view, or_replace=True)) + conn.execute(CreateMaterializedView(mview, if_not_exists=True)) + + # Include materialized + views = get_all_views(conn, include_materialized=True) + names = [v.name for v in views] + assert "test_view_excl" in names + assert "test_mview_excl" in names + + # Exclude materialized + views = get_all_views(conn, include_materialized=False) + names = [v.name for v in views] + assert "test_view_excl" in names + assert "test_mview_excl" not in names + + conn.execute(DropView(view, if_exists=True)) + conn.execute(DropMaterializedView(mview, if_exists=True)) + + +class TestGetViewDefinition: + """Tests for get_view_definition function.""" + + def test_get_view_definition( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test getting view definition.""" + authors = pg_one_to_many_tables["authors"] + + view = View( + "test_view_def", + select(authors.c.id, authors.c.name), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(view, or_replace=True)) + + definition = get_view_definition(conn, "test_view_def") + + assert definition is not None + assert "authors" in definition.lower() + + conn.execute(DropView(view, if_exists=True)) + + def test_get_view_definition_not_found(self, pg_engine: Engine) -> None: + """Test getting definition for non-existent view.""" + with pg_engine.connect() as conn: + definition = get_view_definition(conn, "nonexistent_view_xyz") + assert definition is None + + +class TestGetViewDependencies: + """Tests for get_view_dependencies function.""" + + def test_get_view_dependencies( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test getting view dependencies.""" + authors = pg_one_to_many_tables["authors"] + + # Create base view + base_view = View( + "test_base_view_deps", + select(authors.c.id, authors.c.name), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(base_view, or_replace=True)) + + # Create dependent view that references base view + dependent_view = View( + "test_dependent_view_deps", + select(base_view.as_table().c.id), + ) + conn.execute(CreateView(dependent_view, or_replace=True)) + + # Get dependencies + deps = get_view_dependencies(conn) + + # Find our dependency + our_deps = [ + d for d in deps if d.dependent_view == "test_dependent_view_deps" + ] + + assert len(our_deps) > 0 + # Should reference the base view + ref_names = [d.referenced_view for d in our_deps] + assert "test_base_view_deps" in ref_names + + conn.execute(DropView(dependent_view, if_exists=True, cascade=True)) + conn.execute(DropView(base_view, if_exists=True, cascade=True)) + + +class TestGetDependencyOrder: + """Tests for get_dependency_order function.""" + + def test_get_dependency_order( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test getting views in dependency order.""" + authors = pg_one_to_many_tables["authors"] + + # Create views with dependencies + view_a = View( + "test_order_a", + select(authors.c.id, authors.c.name), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(view_a, or_replace=True)) + + view_b = View( + "test_order_b", + select(view_a.as_table().c.id), + ) + conn.execute(CreateView(view_b, or_replace=True)) + + # Get dependency order + ordered = get_dependency_order(conn, schema="public") + names = [v.name for v in ordered] + + # view_a should come before view_b (dependencies first) + if "test_order_a" in names and "test_order_b" in names: + idx_a = names.index("test_order_a") + idx_b = names.index("test_order_b") + assert idx_a < idx_b + + conn.execute(DropView(view_b, if_exists=True, cascade=True)) + conn.execute(DropView(view_a, if_exists=True, cascade=True)) + + +class TestGetReverseDependencies: + """Tests for get_reverse_dependencies function.""" + + def test_get_reverse_dependencies( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test getting reverse dependencies (views that depend on a view).""" + authors = pg_one_to_many_tables["authors"] + + # Create base view + base_view = View( + "test_reverse_base", + select(authors.c.id, authors.c.name), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(base_view, or_replace=True)) + + # Create dependent view + dep_view = View( + "test_reverse_dep", + select(base_view.as_table().c.id), + ) + conn.execute(CreateView(dep_view, or_replace=True)) + + # Get reverse dependencies of base view + dependents = get_reverse_dependencies(conn, "test_reverse_base") + dep_names = [v.name for v in dependents] + + assert "test_reverse_dep" in dep_names + + conn.execute(DropView(dep_view, if_exists=True, cascade=True)) + conn.execute(DropView(base_view, if_exists=True, cascade=True)) + + def test_get_reverse_dependencies_none( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test getting reverse dependencies when there are none.""" + authors = pg_one_to_many_tables["authors"] + + # Create standalone view with no dependents + standalone = View( + "test_standalone_view", + select(authors.c.id), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(standalone, or_replace=True)) + + dependents = get_reverse_dependencies(conn, "test_standalone_view") + assert len(dependents) == 0 + + conn.execute(DropView(standalone, if_exists=True)) diff --git a/tests/test_metadata_integration.py b/tests/test_metadata_integration.py new file mode 100644 index 0000000..dcfed07 --- /dev/null +++ b/tests/test_metadata_integration.py @@ -0,0 +1,290 @@ +"""Tests for metadata integration and auto-registration.""" + +from decimal import Decimal + +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + MetaData, + Numeric, + String, + Table, + func, + insert, + select, +) +from sqlalchemy.engine import Engine + +from sqlalchemy_pgview import ( + MaterializedView, + View, + get_materialized_views, + get_views, +) + + +class TestAutoRegistration: + """Tests for auto-registration of views with metadata.""" + + def test_view_auto_registers_with_metadata(self) -> None: + """Test that View is auto-registered when metadata is provided.""" + metadata = MetaData() + + # Dummy table for selectable + users = Table("users", metadata, Column("id", Integer, primary_key=True)) + + # View should be auto-registered + user_view = View( + "user_view", + select(users.c.id), + metadata=metadata, + ) + + registered = get_views(metadata) + assert "user_view" in registered + assert registered["user_view"] is user_view + + def test_materialized_view_auto_registers_with_metadata(self) -> None: + """Test that MaterializedView is auto-registered when metadata is provided.""" + metadata = MetaData() + + users = Table("users", metadata, Column("id", Integer, primary_key=True)) + + mv = MaterializedView( + "user_mv", + select(users.c.id), + metadata=metadata, + with_data=True, + ) + + registered = get_materialized_views(metadata) + assert "user_mv" in registered + assert registered["user_mv"] is mv + + def test_view_with_schema_registers_correctly(self) -> None: + """Test that views with schema are registered with correct key.""" + metadata = MetaData() + + users = Table("users", metadata, Column("id", Integer, primary_key=True)) + + view = View( + "stats_view", + select(users.c.id), + schema="analytics", + metadata=metadata, + ) + + registered = get_views(metadata) + assert "analytics.stats_view" in registered + assert registered["analytics.stats_view"] is view + + def test_view_without_metadata_not_registered(self) -> None: + """Test that views without metadata are not auto-registered.""" + metadata = MetaData() + + users = Table("users", metadata, Column("id", Integer, primary_key=True)) + + # No metadata provided - should not be registered + View("orphan_view", select(users.c.id)) + + registered = get_views(metadata) + assert "orphan_view" not in registered + + def test_multiple_views_registered(self) -> None: + """Test that multiple views can be registered.""" + metadata = MetaData() + + users = Table("users", metadata, Column("id", Integer, primary_key=True)) + + View("view1", select(users.c.id), metadata=metadata) + View("view2", select(users.c.id), metadata=metadata) + MaterializedView("mv1", select(users.c.id), metadata=metadata) + + views = get_views(metadata) + mviews = get_materialized_views(metadata) + + assert len(views) == 2 + assert "view1" in views + assert "view2" in views + assert len(mviews) == 1 + assert "mv1" in mviews + + +class TestMetadataCreateAll: + """Tests for metadata.create_all() with views.""" + + def test_create_all_creates_views(self, pg_engine: Engine) -> None: + """Test that metadata.create_all() creates registered views.""" + metadata = MetaData() + + users = Table( + "test_users_ca", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100)), + ) + + orders = Table( + "test_orders_ca", + metadata, + Column("id", Integer, primary_key=True), + Column("user_id", Integer, ForeignKey("test_users_ca.id")), + Column("total", Numeric(10, 2)), + ) + + # Define view - auto-registered + user_stats = View( + "test_user_stats_ca", + select( + users.c.id, + users.c.name, + func.count(orders.c.id).label("order_count"), + ) + .select_from(users.outerjoin(orders, users.c.id == orders.c.user_id)) + .group_by(users.c.id, users.c.name), + metadata=metadata, + ) + + try: + # Create all - should create tables AND views + metadata.create_all(pg_engine) + + # Insert test data + with pg_engine.begin() as conn: + conn.execute(insert(users).values(id=1, name="Alice")) + conn.execute(insert(users).values(id=2, name="Bob")) + conn.execute(insert(orders).values(id=1, user_id=1, total=100)) + conn.execute(insert(orders).values(id=2, user_id=1, total=200)) + + # Query the view + with pg_engine.connect() as conn: + result = conn.execute( + select(user_stats.as_table()).order_by(user_stats.as_table().c.name) + ).fetchall() + + assert len(result) == 2 + assert result[0].name == "Alice" + assert result[0].order_count == 2 + assert result[1].name == "Bob" + assert result[1].order_count == 0 + + finally: + metadata.drop_all(pg_engine) + + def test_create_all_creates_materialized_views(self, pg_engine: Engine) -> None: + """Test that metadata.create_all() creates materialized views.""" + metadata = MetaData() + + orders = Table( + "test_orders_mv", + metadata, + Column("id", Integer, primary_key=True), + Column("total", Numeric(10, 2)), + ) + + # Define materialized view - auto-registered + order_summary = MaterializedView( + "test_order_summary_mv", + select( + func.count(orders.c.id).label("order_count"), + func.sum(orders.c.total).label("total_revenue"), + ), + metadata=metadata, + with_data=True, + ) + + try: + metadata.create_all(pg_engine) + + # Insert test data + with pg_engine.begin() as conn: + conn.execute(insert(orders).values(id=1, total=100)) + conn.execute(insert(orders).values(id=2, total=200)) + + # Query the materialized view (shows data at creation time = 0) + with pg_engine.connect() as conn: + result = conn.execute(select(order_summary.as_table())).fetchone() + # MV was created before data, so shows 0 + assert result.order_count == 0 + + # Refresh to see actual data + order_summary.refresh(conn) + + result = conn.execute(select(order_summary.as_table())).fetchone() + assert result.order_count == 2 + assert result.total_revenue == Decimal("300") + + finally: + metadata.drop_all(pg_engine) + + def test_drop_all_drops_views(self, pg_engine: Engine) -> None: + """Test that metadata.drop_all() drops registered views.""" + from sqlalchemy import text + + metadata = MetaData() + + users = Table( + "test_users_drop", + metadata, + Column("id", Integer, primary_key=True), + ) + + View( + "test_view_drop", + select(users.c.id), + metadata=metadata, + ) + + MaterializedView( + "test_mv_drop", + select(users.c.id), + metadata=metadata, + ) + + # Create all + metadata.create_all(pg_engine) + + # Verify views exist + with pg_engine.connect() as conn: + result = conn.execute( + text( + "SELECT COUNT(*) FROM pg_class WHERE relname IN ('test_view_drop', 'test_mv_drop')" + ) + ).scalar() + assert result == 2 + + # Drop all + metadata.drop_all(pg_engine) + + # Verify views are gone + with pg_engine.connect() as conn: + result = conn.execute( + text( + "SELECT COUNT(*) FROM pg_class WHERE relname IN ('test_view_drop', 'test_mv_drop')" + ) + ).scalar() + assert result == 0 + + def test_views_created_after_tables(self, pg_engine: Engine) -> None: + """Test that views are created after tables (dependencies work).""" + metadata = MetaData() + + # Table + users = Table( + "test_users_order", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(100)), + ) + + # View that depends on table + View( + "test_users_view_order", + select(users.c.id, users.c.name), + metadata=metadata, + ) + + # This should not raise - tables created before views + metadata.create_all(pg_engine) + metadata.drop_all(pg_engine) diff --git a/tests/test_relationships.py b/tests/test_relationships.py new file mode 100644 index 0000000..c448214 --- /dev/null +++ b/tests/test_relationships.py @@ -0,0 +1,448 @@ +"""Tests for views with one-to-many and many-to-many relationships.""" + +from decimal import Decimal + +from sqlalchemy import Table, func, select +from sqlalchemy.engine import Engine + +from sqlalchemy_pgview import ( + CreateMaterializedView, + CreateView, + DropMaterializedView, + DropView, + MaterializedView, + View, +) + + +class TestOneToManyViews: + """Tests for views based on one-to-many relationships.""" + + def test_author_book_count_view( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test view that counts books per author (1:N aggregation).""" + authors = pg_one_to_many_tables["authors"] + books = pg_one_to_many_tables["books"] + + # Create view: author with book count + author_stats = View( + "author_book_stats", + select( + authors.c.id, + authors.c.name, + authors.c.country, + func.count(books.c.id).label("book_count"), + func.coalesce(func.sum(books.c.price), 0).label("total_price"), + ) + .select_from(authors.outerjoin(books, authors.c.id == books.c.author_id)) + .group_by(authors.c.id, authors.c.name, authors.c.country), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(author_stats, or_replace=True)) + + # Query the view + result = conn.execute( + select(author_stats.as_table()).order_by(author_stats.as_table().c.name) + ).fetchall() + + assert len(result) == 3 + + # Gabriel Garcia Marquez - 1 book + assert result[0].name == "Gabriel Garcia Marquez" + assert result[0].book_count == 1 + + # George Orwell - 2 books + assert result[1].name == "George Orwell" + assert result[1].book_count == 2 + + # Haruki Murakami - 2 books + assert result[2].name == "Haruki Murakami" + assert result[2].book_count == 2 + + conn.execute(DropView(author_stats, if_exists=True)) + + def test_book_review_stats_view( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test view with nested 1:N (books -> reviews) aggregation.""" + books = pg_one_to_many_tables["books"] + reviews = pg_one_to_many_tables["reviews"] + + # Create view: book with review stats + book_review_stats = View( + "book_review_stats", + select( + books.c.id, + books.c.title, + books.c.price, + func.count(reviews.c.id).label("review_count"), + func.coalesce(func.avg(reviews.c.rating), 0).label("avg_rating"), + ) + .select_from(books.outerjoin(reviews, books.c.id == reviews.c.book_id)) + .group_by(books.c.id, books.c.title, books.c.price), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(book_review_stats, or_replace=True)) + + result = conn.execute( + select(book_review_stats.as_table()).order_by( + book_review_stats.as_table().c.review_count.desc() + ) + ).fetchall() + + assert len(result) == 5 + + # 1984 has 3 reviews + assert result[0].title == "1984" + assert result[0].review_count == 3 + assert float(result[0].avg_rating) > 4.0 + + conn.execute(DropView(book_review_stats, if_exists=True)) + + def test_chained_one_to_many_view( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test view spanning multiple 1:N relationships (author -> books -> reviews).""" + authors = pg_one_to_many_tables["authors"] + books = pg_one_to_many_tables["books"] + reviews = pg_one_to_many_tables["reviews"] + + # Create view: author with aggregated review stats across all their books + author_review_summary = View( + "author_review_summary", + select( + authors.c.id, + authors.c.name, + func.count(reviews.c.id).label("total_reviews"), + func.coalesce(func.avg(reviews.c.rating), 0).label("avg_rating"), + ) + .select_from( + authors.outerjoin(books, authors.c.id == books.c.author_id).outerjoin( + reviews, books.c.id == reviews.c.book_id + ) + ) + .group_by(authors.c.id, authors.c.name), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(author_review_summary, or_replace=True)) + + result = conn.execute( + select(author_review_summary.as_table()).order_by( + author_review_summary.as_table().c.total_reviews.desc() + ) + ).fetchall() + + assert len(result) == 3 + + # George Orwell has most reviews (1984: 3 + Animal Farm: 1 = 4) + assert result[0].name == "George Orwell" + assert result[0].total_reviews == 4 + + conn.execute(DropView(author_review_summary, if_exists=True)) + + def test_one_to_many_materialized_view( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test materialized view with 1:N relationship.""" + authors = pg_one_to_many_tables["authors"] + books = pg_one_to_many_tables["books"] + + # Create materialized view for author statistics + author_stats_mv = MaterializedView( + "author_stats_mv", + select( + authors.c.id, + authors.c.name, + func.count(books.c.id).label("book_count"), + ) + .select_from(authors.outerjoin(books, authors.c.id == books.c.author_id)) + .group_by(authors.c.id, authors.c.name), + with_data=True, + ) + + with pg_engine.begin() as conn: + conn.execute(CreateMaterializedView(author_stats_mv)) + + result = conn.execute( + select(author_stats_mv.as_table()).order_by( + author_stats_mv.as_table().c.book_count.desc() + ) + ).fetchall() + + assert len(result) == 3 + assert result[0].book_count == 2 # Orwell or Murakami + + conn.execute(DropMaterializedView(author_stats_mv, if_exists=True)) + + +class TestManyToManyViews: + """Tests for views based on many-to-many relationships.""" + + def test_student_course_count_view( + self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table] + ) -> None: + """Test view counting courses per student (M:N aggregation).""" + students = pg_many_to_many_tables["students"] + student_courses = pg_many_to_many_tables["student_courses"] + + # Create view: student with course count and GPA + student_stats = View( + "student_stats", + select( + students.c.id, + students.c.name, + students.c.email, + func.count(student_courses.c.course_id).label("course_count"), + func.coalesce(func.avg(student_courses.c.grade), 0).label("gpa"), + ) + .select_from( + students.outerjoin( + student_courses, students.c.id == student_courses.c.student_id + ) + ) + .group_by(students.c.id, students.c.name, students.c.email), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(student_stats, or_replace=True)) + + result = conn.execute( + select(student_stats.as_table()).order_by( + student_stats.as_table().c.course_count.desc() + ) + ).fetchall() + + assert len(result) == 3 + + # Alice is enrolled in 3 courses + assert result[0].name == "Alice" + assert result[0].course_count == 3 + + # Bob is enrolled in 2 courses + assert result[1].name == "Bob" + assert result[1].course_count == 2 + + # Charlie is enrolled in 1 course + assert result[2].name == "Charlie" + assert result[2].course_count == 1 + + conn.execute(DropView(student_stats, if_exists=True)) + + def test_course_enrollment_view( + self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table] + ) -> None: + """Test view counting students per course (reverse M:N).""" + courses = pg_many_to_many_tables["courses"] + student_courses = pg_many_to_many_tables["student_courses"] + + # Create view: course with enrollment count + course_enrollment = View( + "course_enrollment", + select( + courses.c.id, + courses.c.name, + courses.c.credits, + func.count(student_courses.c.student_id).label("student_count"), + func.coalesce(func.avg(student_courses.c.grade), 0).label("avg_grade"), + ) + .select_from( + courses.outerjoin( + student_courses, courses.c.id == student_courses.c.course_id + ) + ) + .group_by(courses.c.id, courses.c.name, courses.c.credits), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(course_enrollment, or_replace=True)) + + result = conn.execute( + select(course_enrollment.as_table()).order_by( + course_enrollment.as_table().c.name + ) + ).fetchall() + + assert len(result) == 3 + + # Database Systems - 2 students (Alice, Bob) + assert result[0].name == "Database Systems" + assert result[0].student_count == 2 + + # Machine Learning - 2 students (Alice, Bob) + assert result[1].name == "Machine Learning" + assert result[1].student_count == 2 + + # Web Development - 2 students (Alice, Charlie) + assert result[2].name == "Web Development" + assert result[2].student_count == 2 + + conn.execute(DropView(course_enrollment, if_exists=True)) + + def test_course_tags_view( + self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table] + ) -> None: + """Test view joining through M:N to aggregate tags.""" + courses = pg_many_to_many_tables["courses"] + course_tags = pg_many_to_many_tables["course_tags"] + tags = pg_many_to_many_tables["tags"] + + # Create view: course with concatenated tag names + course_with_tags = View( + "course_with_tags", + select( + courses.c.id, + courses.c.name, + func.count(tags.c.id).label("tag_count"), + func.string_agg(tags.c.name, ", ").label("tag_names"), + ) + .select_from( + courses.outerjoin(course_tags, courses.c.id == course_tags.c.course_id).outerjoin( + tags, course_tags.c.tag_id == tags.c.id + ) + ) + .group_by(courses.c.id, courses.c.name), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(course_with_tags, or_replace=True)) + + result = conn.execute( + select(course_with_tags.as_table()).order_by( + course_with_tags.as_table().c.tag_count.desc() + ) + ).fetchall() + + assert len(result) == 3 + + # Machine Learning has 2 tags (data, ai) + assert result[0].name == "Machine Learning" + assert result[0].tag_count == 2 + + conn.execute(DropView(course_with_tags, if_exists=True)) + + def test_student_courses_detailed_view( + self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table] + ) -> None: + """Test flattened view of M:N relationship (no aggregation).""" + students = pg_many_to_many_tables["students"] + courses = pg_many_to_many_tables["courses"] + student_courses = pg_many_to_many_tables["student_courses"] + + # Create view: detailed enrollment records + enrollment_details = View( + "enrollment_details", + select( + students.c.name.label("student_name"), + students.c.email, + courses.c.name.label("course_name"), + courses.c.credits, + student_courses.c.grade, + ).select_from( + student_courses.join(students, student_courses.c.student_id == students.c.id).join( + courses, student_courses.c.course_id == courses.c.id + ) + ), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(enrollment_details, or_replace=True)) + + result = conn.execute(select(enrollment_details.as_table())).fetchall() + + # Total enrollments: 6 + assert len(result) == 6 + + # Check Alice's Web Development grade + alice_web = [ + r for r in result if r.student_name == "Alice" and r.course_name == "Web Development" + ] + assert len(alice_web) == 1 + assert alice_web[0].grade == Decimal("4.00") + + conn.execute(DropView(enrollment_details, if_exists=True)) + + def test_many_to_many_materialized_view( + self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table] + ) -> None: + """Test materialized view with M:N relationship.""" + students = pg_many_to_many_tables["students"] + student_courses = pg_many_to_many_tables["student_courses"] + + # Create materialized view for student GPA + student_gpa_mv = MaterializedView( + "student_gpa_mv", + select( + students.c.id, + students.c.name, + func.round(func.avg(student_courses.c.grade), 2).label("gpa"), + ) + .select_from( + students.join(student_courses, students.c.id == student_courses.c.student_id) + ) + .group_by(students.c.id, students.c.name), + with_data=True, + ) + + with pg_engine.begin() as conn: + conn.execute(CreateMaterializedView(student_gpa_mv)) + + result = conn.execute( + select(student_gpa_mv.as_table()).order_by( + student_gpa_mv.as_table().c.gpa.desc() + ) + ).fetchall() + + assert len(result) == 3 + + # Alice has highest GPA (3.8 + 4.0 + 3.5) / 3 = 3.77 + assert result[0].name == "Alice" + + conn.execute(DropMaterializedView(student_gpa_mv, if_exists=True)) + + def test_double_many_to_many_view( + self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table] + ) -> None: + """Test view joining two M:N relationships.""" + students = pg_many_to_many_tables["students"] + courses = pg_many_to_many_tables["courses"] + student_courses = pg_many_to_many_tables["student_courses"] + course_tags = pg_many_to_many_tables["course_tags"] + tags = pg_many_to_many_tables["tags"] + + # Create view: students with their course tags + student_tags = View( + "student_tags", + select( + students.c.id.label("student_id"), + students.c.name.label("student_name"), + func.count(func.distinct(tags.c.id)).label("unique_tags"), + ) + .select_from( + students.join(student_courses, students.c.id == student_courses.c.student_id) + .join(courses, student_courses.c.course_id == courses.c.id) + .join(course_tags, courses.c.id == course_tags.c.course_id) + .join(tags, course_tags.c.tag_id == tags.c.id) + ) + .group_by(students.c.id, students.c.name), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(student_tags, or_replace=True)) + + result = conn.execute( + select(student_tags.as_table()).order_by( + student_tags.as_table().c.unique_tags.desc() + ) + ).fetchall() + + assert len(result) == 3 + + # Alice takes all 3 courses, which have tags: data, programming, ai + assert result[0].student_name == "Alice" + assert result[0].unique_tags == 3 + + conn.execute(DropView(student_tags, if_exists=True)) diff --git a/tests/test_view.py b/tests/test_view.py new file mode 100644 index 0000000..7ea54df --- /dev/null +++ b/tests/test_view.py @@ -0,0 +1,113 @@ +"""Tests for View and MaterializedView classes.""" + +from sqlalchemy import func, select + +from sqlalchemy_pgview import MaterializedView, View + + +class TestView: + """Tests for the View class.""" + + def test_view_creation(self, sample_tables: tuple) -> None: + """Test basic view creation.""" + users, orders = sample_tables + view = View( + "user_order_count", + select(users.c.id, func.count(orders.c.id).label("order_count")) + .select_from(users.join(orders, users.c.id == orders.c.user_id)) + .group_by(users.c.id), + ) + + assert view.name == "user_order_count" + assert view.schema is None + assert view.fullname == "user_order_count" + + def test_view_with_schema(self, sample_tables: tuple) -> None: + """Test view creation with schema.""" + users, _ = sample_tables + view = View( + "active_users", + select(users.c.id, users.c.name), + schema="analytics", + ) + + assert view.name == "active_users" + assert view.schema == "analytics" + assert view.fullname == "analytics.active_users" + + def test_view_columns(self, sample_tables: tuple) -> None: + """Test view column derivation.""" + users, _ = sample_tables + view = View( + "user_names", + select(users.c.id, users.c.name.label("user_name")), + ) + + columns = view.columns + assert len(columns) == 2 + assert columns[0].name == "id" + assert columns[1].name == "user_name" + + def test_view_as_table(self, sample_tables: tuple) -> None: + """Test converting view to table for querying.""" + users, _ = sample_tables + view = View( + "user_names", + select(users.c.id, users.c.name), + ) + + table = view.as_table() + assert table.name == "user_names" + assert len(table.columns) == 2 + + def test_view_repr(self, sample_tables: tuple) -> None: + """Test view string representation.""" + users, _ = sample_tables + view = View( + "test_view", + select(users.c.id), + schema="public", + ) + + assert repr(view) == "View('test_view', schema='public')" + + +class TestMaterializedView: + """Tests for the MaterializedView class.""" + + def test_materialized_view_creation(self, sample_tables: tuple) -> None: + """Test basic materialized view creation.""" + users, orders = sample_tables + mview = MaterializedView( + "monthly_totals", + select(users.c.id, func.sum(orders.c.total).label("total")) + .select_from(users.join(orders, users.c.id == orders.c.user_id)) + .group_by(users.c.id), + with_data=True, + ) + + assert mview.name == "monthly_totals" + assert mview.with_data is True + + def test_materialized_view_without_data(self, sample_tables: tuple) -> None: + """Test materialized view creation without data.""" + users, _ = sample_tables + mview = MaterializedView( + "empty_view", + select(users.c.id), + with_data=False, + ) + + assert mview.with_data is False + + def test_materialized_view_repr(self, sample_tables: tuple) -> None: + """Test materialized view string representation.""" + users, _ = sample_tables + mview = MaterializedView( + "test_mview", + select(users.c.id), + schema="public", + with_data=True, + ) + + assert repr(mview) == "MaterializedView('test_mview', schema='public', with_data=True)" diff --git a/tests/test_view_updates.py b/tests/test_view_updates.py new file mode 100644 index 0000000..7bd4426 --- /dev/null +++ b/tests/test_view_updates.py @@ -0,0 +1,341 @@ +"""Tests for view behavior after INSERT/UPDATE/DELETE on underlying tables.""" + +from decimal import Decimal + +from sqlalchemy import Table, delete, func, insert, select, update +from sqlalchemy.engine import Engine + +from sqlalchemy_pgview import ( + CreateMaterializedView, + CreateView, + DropMaterializedView, + DropView, + MaterializedView, + RefreshMaterializedView, + View, +) + + +class TestRegularViewUpdates: + """Tests that regular views reflect changes immediately.""" + + def test_view_reflects_insert( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test that view shows new rows after INSERT.""" + authors = pg_one_to_many_tables["authors"] + books = pg_one_to_many_tables["books"] + + author_book_count = View( + "author_book_count", + select( + authors.c.id, + authors.c.name, + func.count(books.c.id).label("book_count"), + ) + .select_from(authors.outerjoin(books, authors.c.id == books.c.author_id)) + .group_by(authors.c.id, authors.c.name), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(author_book_count, or_replace=True)) + + # Check initial count for Orwell (has 2 books) + result = conn.execute( + select(author_book_count.as_table()).where( + author_book_count.as_table().c.name == "George Orwell" + ) + ).fetchone() + assert result.book_count == 2 + + # INSERT a new book for Orwell + conn.execute( + insert(books).values(id=100, title="Homage to Catalonia", author_id=1, price=13.99) + ) + + # View should immediately show 3 books + result = conn.execute( + select(author_book_count.as_table()).where( + author_book_count.as_table().c.name == "George Orwell" + ) + ).fetchone() + assert result.book_count == 3 + + conn.execute(DropView(author_book_count, if_exists=True)) + + def test_view_reflects_update( + self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table] + ) -> None: + """Test that view shows updated values after UPDATE.""" + students = pg_many_to_many_tables["students"] + student_courses = pg_many_to_many_tables["student_courses"] + + student_gpa = View( + "student_gpa", + select( + students.c.id, + students.c.name, + func.round(func.avg(student_courses.c.grade), 2).label("gpa"), + ) + .select_from( + students.join(student_courses, students.c.id == student_courses.c.student_id) + ) + .group_by(students.c.id, students.c.name), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(student_gpa, or_replace=True)) + + # Check Alice's initial GPA + result = conn.execute( + select(student_gpa.as_table()).where(student_gpa.as_table().c.name == "Alice") + ).fetchone() + initial_gpa = result.gpa + + # UPDATE Alice's grade in Database Systems from 3.8 to 4.0 + conn.execute( + update(student_courses) + .where(student_courses.c.student_id == 1) + .where(student_courses.c.course_id == 1) + .values(grade=Decimal("4.0")) + ) + + # View should show updated GPA + result = conn.execute( + select(student_gpa.as_table()).where(student_gpa.as_table().c.name == "Alice") + ).fetchone() + assert result.gpa > initial_gpa + + conn.execute(DropView(student_gpa, if_exists=True)) + + def test_view_reflects_delete( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test that view excludes deleted rows after DELETE.""" + books = pg_one_to_many_tables["books"] + reviews = pg_one_to_many_tables["reviews"] + + book_review_count = View( + "book_review_count", + select( + books.c.id, + books.c.title, + func.count(reviews.c.id).label("review_count"), + ) + .select_from(books.outerjoin(reviews, books.c.id == reviews.c.book_id)) + .group_by(books.c.id, books.c.title), + ) + + with pg_engine.begin() as conn: + conn.execute(CreateView(book_review_count, or_replace=True)) + + # Check initial review count for "1984" (has 3 reviews) + result = conn.execute( + select(book_review_count.as_table()).where( + book_review_count.as_table().c.title == "1984" + ) + ).fetchone() + assert result.review_count == 3 + + # DELETE one review for "1984" + conn.execute(delete(reviews).where(reviews.c.id == 1)) + + # View should immediately show 2 reviews + result = conn.execute( + select(book_review_count.as_table()).where( + book_review_count.as_table().c.title == "1984" + ) + ).fetchone() + assert result.review_count == 2 + + conn.execute(DropView(book_review_count, if_exists=True)) + + +class TestMaterializedViewUpdates: + """Tests that materialized views require explicit refresh.""" + + def test_materialized_view_stale_after_insert( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test that materialized view shows stale data after INSERT until refreshed.""" + authors = pg_one_to_many_tables["authors"] + books = pg_one_to_many_tables["books"] + + author_book_count_mv = MaterializedView( + "author_book_count_mv", + select( + authors.c.id, + authors.c.name, + func.count(books.c.id).label("book_count"), + ) + .select_from(authors.outerjoin(books, authors.c.id == books.c.author_id)) + .group_by(authors.c.id, authors.c.name), + with_data=True, + ) + + with pg_engine.begin() as conn: + conn.execute(CreateMaterializedView(author_book_count_mv)) + + # Check initial count for Orwell (has 2 books) + result = conn.execute( + select(author_book_count_mv.as_table()).where( + author_book_count_mv.as_table().c.name == "George Orwell" + ) + ).fetchone() + assert result.book_count == 2 + + # INSERT a new book for Orwell + conn.execute( + insert(books).values(id=101, title="Down and Out in Paris", author_id=1, price=11.99) + ) + + # Materialized view still shows OLD count (stale data) + result = conn.execute( + select(author_book_count_mv.as_table()).where( + author_book_count_mv.as_table().c.name == "George Orwell" + ) + ).fetchone() + assert result.book_count == 2 # Still 2, not 3! + + # REFRESH the materialized view + conn.execute(RefreshMaterializedView(author_book_count_mv)) + + # Now it shows the updated count + result = conn.execute( + select(author_book_count_mv.as_table()).where( + author_book_count_mv.as_table().c.name == "George Orwell" + ) + ).fetchone() + assert result.book_count == 3 # Now shows 3 + + conn.execute(DropMaterializedView(author_book_count_mv, if_exists=True)) + + def test_materialized_view_stale_after_update( + self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table] + ) -> None: + """Test that materialized view shows stale data after UPDATE until refreshed.""" + students = pg_many_to_many_tables["students"] + student_courses = pg_many_to_many_tables["student_courses"] + + student_gpa_mv = MaterializedView( + "student_gpa_mv_test", + select( + students.c.id, + students.c.name, + func.round(func.avg(student_courses.c.grade), 2).label("gpa"), + ) + .select_from( + students.join(student_courses, students.c.id == student_courses.c.student_id) + ) + .group_by(students.c.id, students.c.name), + with_data=True, + ) + + with pg_engine.begin() as conn: + conn.execute(CreateMaterializedView(student_gpa_mv)) + + # Get Bob's initial GPA + result = conn.execute( + select(student_gpa_mv.as_table()).where(student_gpa_mv.as_table().c.name == "Bob") + ).fetchone() + initial_gpa = result.gpa + + # UPDATE Bob's grades to all 4.0 + conn.execute( + update(student_courses) + .where(student_courses.c.student_id == 2) + .values(grade=Decimal("4.0")) + ) + + # Materialized view still shows old GPA + result = conn.execute( + select(student_gpa_mv.as_table()).where(student_gpa_mv.as_table().c.name == "Bob") + ).fetchone() + assert result.gpa == initial_gpa # Unchanged + + # REFRESH + conn.execute(RefreshMaterializedView(student_gpa_mv)) + + # Now shows 4.0 + result = conn.execute( + select(student_gpa_mv.as_table()).where(student_gpa_mv.as_table().c.name == "Bob") + ).fetchone() + assert result.gpa == Decimal("4.00") + + conn.execute(DropMaterializedView(student_gpa_mv, if_exists=True)) + + def test_materialized_view_stale_after_delete( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test that materialized view shows stale data after DELETE until refreshed.""" + authors = pg_one_to_many_tables["authors"] + books = pg_one_to_many_tables["books"] + reviews = pg_one_to_many_tables["reviews"] + + author_count_mv = MaterializedView( + "author_count_mv", + select(func.count(authors.c.id).label("total_authors")), + with_data=True, + ) + + with pg_engine.begin() as conn: + conn.execute(CreateMaterializedView(author_count_mv)) + + # Initial count + result = conn.execute(select(author_count_mv.as_table())).fetchone() + assert result.total_authors == 3 + + # DELETE an author (need to delete reviews -> books -> author due to FK) + # Author 2 has book 3 which has review 5 + conn.execute(delete(reviews).where(reviews.c.book_id == 3)) + conn.execute(delete(books).where(books.c.author_id == 2)) + conn.execute(delete(authors).where(authors.c.id == 2)) + + # Materialized view still shows 3 + result = conn.execute(select(author_count_mv.as_table())).fetchone() + assert result.total_authors == 3 # Stale! + + # REFRESH + conn.execute(RefreshMaterializedView(author_count_mv)) + + # Now shows 2 + result = conn.execute(select(author_count_mv.as_table())).fetchone() + assert result.total_authors == 2 + + conn.execute(DropMaterializedView(author_count_mv, if_exists=True)) + + def test_materialized_view_refresh_via_method( + self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table] + ) -> None: + """Test refreshing materialized view using the .refresh() method.""" + books = pg_one_to_many_tables["books"] + + book_count_mv = MaterializedView( + "book_count_mv", + select(func.count(books.c.id).label("total_books")), + with_data=True, + ) + + with pg_engine.begin() as conn: + conn.execute(CreateMaterializedView(book_count_mv)) + + result = conn.execute(select(book_count_mv.as_table())).fetchone() + assert result.total_books == 5 + + # Add a new book + conn.execute( + insert(books).values(id=102, title="New Book", author_id=1, price=9.99) + ) + + # Still shows 5 + result = conn.execute(select(book_count_mv.as_table())).fetchone() + assert result.total_books == 5 + + # Use the .refresh() method + book_count_mv.refresh(conn) + + # Now shows 6 + result = conn.execute(select(book_count_mv.as_table())).fetchone() + assert result.total_books == 6 + + conn.execute(DropMaterializedView(book_count_mv, if_exists=True)) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..cf569d5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1275 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "alembic" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" }, +] + +[[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 = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11'" }, +] +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/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" }, + { url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" }, + { url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" }, + { 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 = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "backrefs" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, +] + +[[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 = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[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/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" }, + { url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" }, + { url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" }, + { url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" }, + { url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" }, + { 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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +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/32/6a/33d1702184d94106d3cdd7bfb788e19723206fce152e303473ca3b946c7b/greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d", size = 273658, upload-time = "2025-12-04T14:23:37.494Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b7/2b5805bbf1907c26e434f4e448cd8b696a0b71725204fa21a211ff0c04a7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb", size = 574810, upload-time = "2025-12-04T14:50:04.154Z" }, + { url = "https://files.pythonhosted.org/packages/94/38/343242ec12eddf3d8458c73f555c084359883d4ddc674240d9e61ec51fd6/greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd", size = 586248, upload-time = "2025-12-04T14:57:39.35Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a8/15d0aa26c0036a15d2659175af00954aaaa5d0d66ba538345bd88013b4d7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5", size = 586910, upload-time = "2025-12-04T14:25:59.705Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9b/68d5e3b7ccaba3907e5532cf8b9bf16f9ef5056a008f195a367db0ff32db/greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9", size = 1547206, upload-time = "2025-12-04T15:04:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/66/bd/e3086ccedc61e49f91e2cfb5ffad9d8d62e5dc85e512a6200f096875b60c/greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d", size = 1613359, upload-time = "2025-12-04T14:27:26.548Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/d4e73f5dfa888364bbf02efa85616c6714ae7c631c201349782e5b428925/greenlet-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b49e7ed51876b459bd645d83db257f0180e345d3f768a35a85437a24d5a49082", size = 300740, upload-time = "2025-12-04T14:47:52.773Z" }, + { 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/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/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/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/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/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 = "griffe" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, +] + +[[package]] +name = "griffe-typingdoc" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/77/d5e5fa0a8391bc2890ae45255847197299739833108dd76ee3c9b2ff0bba/griffe_typingdoc-0.3.0.tar.gz", hash = "sha256:59d9ef98d02caa7aed88d8df1119c9e48c02ed049ea50ce4018ace9331d20f8b", size = 33169, upload-time = "2025-10-23T12:01:39.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/af/aa32c13f753e2625ec895b1f56eee3c9380a2088a88a2c028955e223856e/griffe_typingdoc-0.3.0-py3-none-any.whl", hash = "sha256:4f6483fff7733a679d1dce142fb029f314125f3caaf0d620eb82e7390c8564bb", size = 9923, upload-time = "2025-10-23T12:01:37.601Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/62/0dfc5719514115bf1781f44b1d7f2a0923fcc01e9c5d7990e48a05c9ae5d/mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434", size = 100946, upload-time = "2026-02-07T14:31:40.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/41/1cf02e3df279d2dd846a1bf235a928254eba9006dd22b4a14caa71aed0f7/mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046", size = 35523, upload-time = "2026-02-07T14:31:39.27Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/b9/6eb731b52f132181a9144bbe77ff82117f6b2d2fbfba49aaab2c014c4760/pathspec-1.0.2.tar.gz", hash = "sha256:fa32b1eb775ed9ba8d599b22c5f906dc098113989da2c00bf8b210078ca7fb92", size = 130502, upload-time = "2026-01-08T04:33:27.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/6b/14fc9049d78435fd29e82846c777bd7ed9c470013dc8d0260fff3ff1c11e/pathspec-1.0.2-py3-none-any.whl", hash = "sha256:62f8558917908d237d399b9b338ef455a814801a4688bc41074b25feefd93472", size = 54844, upload-time = "2026-01-08T04:33:26.4Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[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 = "prek" +version = "0.2.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/0b/2a0509d2d8881811e4505227df9ca31b3a4482497689b5c2b7f38faab1e5/prek-0.2.27.tar.gz", hash = "sha256:dfd2a1b040f55402c2449ae36ea28e8c1bb05ca900490d5c0996b1b72297cc0e", size = 283076, upload-time = "2026-01-07T14:23:17.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/03/01dd50c89aa38bc194bb14073468bcbd1fec1621150967b7d424d2f043a7/prek-0.2.27-py3-none-linux_armv6l.whl", hash = "sha256:3c7ce590289e4fc0119524d0f0f187133a883d6784279b6a3a4080f5851f1612", size = 4799872, upload-time = "2026-01-07T14:23:15.5Z" }, + { url = "https://files.pythonhosted.org/packages/51/86/807267659e4775c384e755274a214a45461266d6a1117ec059fbd245731b/prek-0.2.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:df35dee5dcf09a9613c8b9c6f3d79a3ec894eb13172f569773d529a5458887f8", size = 4903805, upload-time = "2026-01-07T14:23:35.199Z" }, + { url = "https://files.pythonhosted.org/packages/1b/5b/cc3c13ed43e7523f27a2f9b14d18c9b557fb1090e7a74689f934cb24d721/prek-0.2.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:772d84ebe19b70eba1da0f347d7d486b9b03c0a33fe19c2d1bf008e72faa13b3", size = 4629083, upload-time = "2026-01-07T14:23:12.204Z" }, + { url = "https://files.pythonhosted.org/packages/34/d9/86eafc1d7bddf9236263d4428acca76b7bfc7564ccc2dc5e539d1be22b5e/prek-0.2.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:571aab2e9c0eace30a51b0667533862f4bdc0a81334d342f6f516796a63fd1e4", size = 4825005, upload-time = "2026-01-07T14:23:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/44/cf/83004be0a9e8ac3c8c927afab5948d9e31760e15442a0fff273f158cae51/prek-0.2.27-py3-none-manylinux_2_24_armv7l.whl", hash = "sha256:cc7a47f40f36c503e77eb6209f7ad5979772f9c7c5e88ba95cf20f0d24ece926", size = 4724850, upload-time = "2026-01-07T14:23:18.276Z" }, + { url = "https://files.pythonhosted.org/packages/73/8c/5c754f4787fc07e7fa6d2c25ac90931cd3692b51f03c45259aca2ea6fd3f/prek-0.2.27-py3-none-manylinux_2_24_i686.whl", hash = "sha256:cd87b034e56f610f9cafd3b7d554dca69f1269a511ad330544d696f08c656eb3", size = 5042584, upload-time = "2026-01-07T14:23:37.892Z" }, + { url = "https://files.pythonhosted.org/packages/4d/80/762283280ae3d2aa35385ed2db76c39518ed789fbaa0b6fb52352764d41c/prek-0.2.27-py3-none-manylinux_2_24_s390x.whl", hash = "sha256:638b4e942dd1cea6fc0ddf4ce5b877e5aa97c6c142b7bf28e9ce6db8f0d06a4a", size = 5511089, upload-time = "2026-01-07T14:23:23.121Z" }, + { url = "https://files.pythonhosted.org/packages/e0/78/1b53b604c188f4054346b237ec1652489718fedc0d465baadecf7907dc42/prek-0.2.27-py3-none-manylinux_2_24_x86_64.whl", hash = "sha256:769b13d7bd11fbb4a5fc5fffd2158aea728518ec9aca7b36723b10ad8b189810", size = 5100175, upload-time = "2026-01-07T14:23:19.643Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/a9dc29598e664e6e663da316338e1e980e885072107876a3ca8d697f4d65/prek-0.2.27-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6c0bc38806caf14d47d44980d936ee0cb153bccea703fb141c16bb9be49fb778", size = 4833004, upload-time = "2026-01-07T14:23:36.467Z" }, + { url = "https://files.pythonhosted.org/packages/04/b7/56ca9226f20375519d84a2728a985cc491536f0b872f10cb62bcc55ccea0/prek-0.2.27-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:77c8ac95a0bb1156159edcb3c52b5f852910a7d2ed53d6136ecc1d9d6dc39fe1", size = 4842559, upload-time = "2026-01-07T14:23:31.691Z" }, + { url = "https://files.pythonhosted.org/packages/87/20/71ef2c558daabbe2a4cfe6567597f7942dbbad1a3caca0d786b4ec1304cb/prek-0.2.27-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:5e8d56b386660266c2a31e12af8b52a0901fe21fb71ab05768fdd41b405794ac", size = 4709053, upload-time = "2026-01-07T14:23:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/e8/14/7376117d0e91e35ce0f6581d4427280f634b9564c86615f74b79f242fa79/prek-0.2.27-py3-none-musllinux_1_1_i686.whl", hash = "sha256:3fdeaa1b9f97e21d870ba091914bc7ccf85106a9ef74d81f362a92cdbfe33569", size = 4927803, upload-time = "2026-01-07T14:23:30Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/87f36898ec2ac1439468b20e9e7061b4956ce0cf518c7cc15ac0457f2971/prek-0.2.27-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:20dd04fe33b9fcfbc2069f4e523ec8d9b4813c1ca4ac9784fe2154dcab42dacb", size = 5210701, upload-time = "2026-01-07T14:23:24.87Z" }, + { url = "https://files.pythonhosted.org/packages/50/5a/53f7828543c09cb70ed35291818ec145a42ef04246fa4f82c128b26abd4f/prek-0.2.27-py3-none-win32.whl", hash = "sha256:15948cacbbccd935f57ca164b36c4c5d7b03c58cd5a335a6113cdbd149b6e50d", size = 4623511, upload-time = "2026-01-07T14:23:33.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/21/3a079075a4d4db58f909eedfd7a79517ba90bb12f7b61f6e84c3c29d4d61/prek-0.2.27-py3-none-win_amd64.whl", hash = "sha256:8225dc8523e7a0e95767b3d3e8cfb3bc160fe6af0ee5115fc16c68428c4e0779", size = 5312713, upload-time = "2026-01-07T14:23:21.116Z" }, + { url = "https://files.pythonhosted.org/packages/39/79/d1c3d96ed4f7dff37ed11101d8336131e8108315c3078246007534dcdd27/prek-0.2.27-py3-none-win_arm64.whl", hash = "sha256:f9192bfb6710db2be10f0e28ff31706a2648c1eb8a450b20b2f55f70ba05e769", size = 4978272, upload-time = "2026-01-07T14:23:13.681Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/f2/8e377d29c2ecf99f6062d35ea606b036e8800720eccfec5fe3dd672c2b24/psycopg2_binary-2.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6fe6b47d0b42ce1c9f1fa3e35bb365011ca22e39db37074458f27921dca40f2", size = 3756506, upload-time = "2025-10-10T11:10:30.144Z" }, + { url = "https://files.pythonhosted.org/packages/24/cc/dc143ea88e4ec9d386106cac05023b69668bd0be20794c613446eaefafe5/psycopg2_binary-2.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6c0e4262e089516603a09474ee13eabf09cb65c332277e39af68f6233911087", size = 3863943, upload-time = "2025-10-10T11:10:34.586Z" }, + { url = "https://files.pythonhosted.org/packages/8c/df/16848771155e7c419c60afeb24950b8aaa3ab09c0a091ec3ccca26a574d0/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c47676e5b485393f069b4d7a811267d3168ce46f988fa602658b8bb901e9e64d", size = 4410873, upload-time = "2025-10-10T11:10:38.951Z" }, + { url = "https://files.pythonhosted.org/packages/43/79/5ef5f32621abd5a541b89b04231fe959a9b327c874a1d41156041c75494b/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a28d8c01a7b27a1e3265b11250ba7557e5f72b5ee9e5f3a2fa8d2949c29bf5d2", size = 4468016, upload-time = "2025-10-10T11:10:43.319Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9b/d7542d0f7ad78f57385971f426704776d7b310f5219ed58da5d605b1892e/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f3f2732cf504a1aa9e9609d02f79bea1067d99edf844ab92c247bbca143303b", size = 4164996, upload-time = "2025-10-10T11:10:46.705Z" }, + { url = "https://files.pythonhosted.org/packages/14/ed/e409388b537fa7414330687936917c522f6a77a13474e4238219fcfd9a84/psycopg2_binary-2.9.11-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:865f9945ed1b3950d968ec4690ce68c55019d79e4497366d36e090327ce7db14", size = 3981881, upload-time = "2025-10-30T02:54:57.182Z" }, + { url = "https://files.pythonhosted.org/packages/bf/30/50e330e63bb05efc6fa7c1447df3e08954894025ca3dcb396ecc6739bc26/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91537a8df2bde69b1c1db01d6d944c831ca793952e4f57892600e96cee95f2cd", size = 3650857, upload-time = "2025-10-10T11:10:50.112Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e0/4026e4c12bb49dd028756c5b0bc4c572319f2d8f1c9008e0dad8cc9addd7/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4dca1f356a67ecb68c81a7bc7809f1569ad9e152ce7fd02c2f2036862ca9f66b", size = 3296063, upload-time = "2025-10-10T11:10:54.089Z" }, + { url = "https://files.pythonhosted.org/packages/2c/34/eb172be293c886fef5299fe5c3fcf180a05478be89856067881007934a7c/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0da4de5c1ac69d94ed4364b6cbe7190c1a70d325f112ba783d83f8440285f152", size = 3043464, upload-time = "2025-10-30T02:55:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/18/1c/532c5d2cb11986372f14b798a95f2eaafe5779334f6a80589a68b5fcf769/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37d8412565a7267f7d79e29ab66876e55cb5e8e7b3bbf94f8206f6795f8f7e7e", size = 3345378, upload-time = "2025-10-10T11:11:01.039Z" }, + { url = "https://files.pythonhosted.org/packages/70/e7/de420e1cf16f838e1fa17b1120e83afff374c7c0130d088dba6286fcf8ea/psycopg2_binary-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:c665f01ec8ab273a61c62beeb8cce3014c214429ced8a308ca1fc410ecac3a39", size = 2713904, upload-time = "2025-10-10T11:11:04.81Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" }, + { url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" }, + { url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" }, + { url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" }, + { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, + { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + +[[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 = "pymdown-extensions" +version = "10.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/35/e3814a5b7df295df69d035cfb8aab78b2967cdf11fcfae7faed726b66664/pymdown_extensions-10.20.tar.gz", hash = "sha256:5c73566ab0cf38c6ba084cb7c5ea64a119ae0500cce754ccb682761dfea13a52", size = 852774, upload-time = "2025-12-31T19:59:42.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/10/47caf89cbb52e5bb764696fd52a8c591a2f0e851a93270c05a17f36000b5/pymdown_extensions-10.20-py3-none-any.whl", hash = "sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f", size = 268733, upload-time = "2025-12-31T19:59:40.652Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +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-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[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 = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +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/fe/70/75b1387d72e2847220441166c5eb4e9846dd753895208c13e6d66523b2d9/sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c64772786d9eee72d4d3784c28f0a636af5b0a29f3fe26ff11f55efe90c0bd85", size = 2154148, upload-time = "2025-12-10T20:03:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/7805e02323c49cb9d1ae5cd4913b28c97103079765f520043f914fca4cb3/sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae64ebf7657395824a19bca98ab10eb9a3ecb026bf09524014f1bb81cb598d4", size = 3233051, upload-time = "2025-12-09T22:06:04.768Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/32ae09139f61bef3de3142e85c47abdee8db9a55af2bb438da54a4549263/sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f02325709d1b1a1489f23a39b318e175a171497374149eae74d612634b234c0", size = 3232781, upload-time = "2025-12-09T22:09:54.435Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/bf7b869b6f5585eac34222e1cf4405f4ba8c3b85dd6b1af5d4ce8bca695f/sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2c3684fca8a05f0ac1d9a21c1f4a266983a7ea9180efb80ffeb03861ecd01a0", size = 3182096, upload-time = "2025-12-09T22:06:06.169Z" }, + { url = "https://files.pythonhosted.org/packages/21/6a/c219720a241bb8f35c88815ccc27761f5af7fdef04b987b0e8a2c1a6dcaa/sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040f6f0545b3b7da6b9317fc3e922c9a98fc7243b2a1b39f78390fc0942f7826", size = 3205109, upload-time = "2025-12-09T22:09:55.969Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c4/6ccf31b2bc925d5d95fab403ffd50d20d7c82b858cf1a4855664ca054dce/sqlalchemy-2.0.45-cp310-cp310-win32.whl", hash = "sha256:830d434d609fe7bfa47c425c445a8b37929f140a7a44cdaf77f6d34df3a7296a", size = 2114240, upload-time = "2025-12-09T21:29:54.007Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/a27a31fca07316def418db6f7c70ab14010506616a2decef1906050a0587/sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl", hash = "sha256:0209d9753671b0da74da2cfbb9ecf9c02f72a759e4b018b3ab35f244c91842c7", size = 2137615, upload-time = "2025-12-09T21:29:55.85Z" }, + { 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]] +name = "sqlalchemy-pgview" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "annotated-doc" }, + { name = "sqlalchemy" }, +] + +[package.optional-dependencies] +alembic = [ + { name = "alembic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "asyncpg" }, + { name = "griffe-typingdoc" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "prek" }, + { name = "psycopg2-binary" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", marker = "extra == 'alembic'", specifier = ">=1.10" }, + { name = "annotated-doc", specifier = ">=0.0.1" }, + { name = "sqlalchemy", specifier = ">=2.0" }, +] +provides-extras = ["alembic"] + +[package.metadata.requires-dev] +dev = [ + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "griffe-typingdoc", specifier = ">=0.2" }, + { name = "mkdocs-material", specifier = ">=9.7.1" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.28" }, + { name = "prek", specifier = ">=0.2.27" }, + { name = "psycopg2-binary", specifier = ">=2.9.11" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.8" }, + { name = "ty", specifier = ">=0.0.1a6" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "ty" +version = "0.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/85/97b5276baa217e05db2fe3d5c61e4dfd35d1d3d0ec95bfca1986820114e0/ty-0.0.10.tar.gz", hash = "sha256:0a1f9f7577e56cd508a8f93d0be2a502fdf33de6a7d65a328a4c80b784f4ac5f", size = 4892892, upload-time = "2026-01-07T23:00:23.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/7a/5a7147ce5231c3ccc55d6f945dabd7412e233e755d28093bfdec988ba595/ty-0.0.10-py3-none-linux_armv6l.whl", hash = "sha256:406a8ea4e648551f885629b75dc3f070427de6ed099af45e52051d4c68224829", size = 9835881, upload-time = "2026-01-07T22:08:17.492Z" }, + { url = "https://files.pythonhosted.org/packages/3e/7d/89f4d2277c938332d047237b47b11b82a330dbff4fff0de8574cba992128/ty-0.0.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d6e0a733e3d6d3bce56d6766bc61923e8b130241088dc2c05e3c549487190096", size = 9696404, upload-time = "2026-01-07T22:08:37.965Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cd/9dd49e6d40e54d4b7d563f9e2a432c4ec002c0673a81266e269c4bc194ce/ty-0.0.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e4832f8879cb95fc725f7e7fcab4f22be0cf2550f3a50641d5f4409ee04176d4", size = 9181195, upload-time = "2026-01-07T22:59:07.187Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b8/3e7c556654ba0569ed5207138d318faf8633d87e194760fc030543817c26/ty-0.0.10-py3-none-manylinux_2_24_aarch64.whl", hash = "sha256:6b58cc78e5865bc908f053559a80bb77cab0dc168aaad2e88f2b47955694b138", size = 9665002, upload-time = "2026-01-07T22:08:30.782Z" }, + { url = "https://files.pythonhosted.org/packages/98/96/410a483321406c932c4e3aa1581d1072b72cdcde3ae83cd0664a65c7b254/ty-0.0.10-py3-none-manylinux_2_24_armv7l.whl", hash = "sha256:83c6a514bb86f05005fa93e3b173ae3fde94d291d994bed6fe1f1d2e5c7331cf", size = 9664948, upload-time = "2026-01-07T23:04:14.655Z" }, + { url = "https://files.pythonhosted.org/packages/1f/5d/cba2ab3e2f660763a72ad12620d0739db012e047eaa0ceaa252bf5e94ebb/ty-0.0.10-py3-none-manylinux_2_24_i686.whl", hash = "sha256:2e43f71e357f8a4f7fc75e4753b37beb2d0f297498055b1673a9306aa3e21897", size = 10125401, upload-time = "2026-01-07T22:08:28.171Z" }, + { url = "https://files.pythonhosted.org/packages/a7/67/29536e0d97f204a2933122239298e754db4564f4ed7f34e2153012b954be/ty-0.0.10-py3-none-manylinux_2_24_ppc64le.whl", hash = "sha256:18be3c679965c23944c8e574be0635504398c64c55f3f0c46259464e10c0a1c7", size = 10714052, upload-time = "2026-01-07T22:08:20.098Z" }, + { url = "https://files.pythonhosted.org/packages/63/c8/82ac83b79a71c940c5dcacb644f526f0c8fdf4b5e9664065ab7ee7c0e4ec/ty-0.0.10-py3-none-manylinux_2_24_s390x.whl", hash = "sha256:5477981681440a35acdf9b95c3097410c547abaa32b893f61553dbc3b0096fff", size = 10395924, upload-time = "2026-01-07T22:08:22.839Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4c/2f9ac5edbd0e67bf82f5cd04275c4e87cbbf69a78f43e5dcf90c1573d44e/ty-0.0.10-py3-none-manylinux_2_24_x86_64.whl", hash = "sha256:e206a23bd887574302138b33383ae1edfcc39d33a06a12a5a00803b3f0287a45", size = 10220096, upload-time = "2026-01-07T22:08:13.171Z" }, + { url = "https://files.pythonhosted.org/packages/04/13/3be2b7bfd53b9952b39b6f2c2ef55edeb1a2fea3bf0285962736ee26731c/ty-0.0.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4e09ddb0d3396bd59f645b85eab20f9a72989aa8b736b34338dcb5ffecfe77b6", size = 9649120, upload-time = "2026-01-07T22:08:34.003Z" }, + { url = "https://files.pythonhosted.org/packages/93/e3/edd58547d9fd01e4e584cec9dced4f6f283506b422cdd953e946f6a8e9f0/ty-0.0.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:139d2a741579ad86a044233b5d7e189bb81f427eebce3464202f49c3ec0eba3b", size = 9686033, upload-time = "2026-01-07T22:08:40.967Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bc/9d2f5fec925977446d577fb9b322d0e7b1b1758709f23a6cfc10231e9b84/ty-0.0.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6bae10420c0abfe4601fbbc6ce637b67d0b87a44fa520283131a26da98f2e74c", size = 9841905, upload-time = "2026-01-07T23:04:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b8/5acd3492b6a4ef255ace24fcff0d4b1471a05b7f3758d8910a681543f899/ty-0.0.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7358bbc5d037b9c59c3a48895206058bcd583985316c4125a74dd87fd1767adb", size = 10320058, upload-time = "2026-01-07T22:08:25.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/67/5b6906fccef654c7e801d6ac8dcbe0d493e1f04c38127f82a5e6d7e0aa0e/ty-0.0.10-py3-none-win32.whl", hash = "sha256:f51b6fd485bc695d0fdf555e69e6a87d1c50f14daef6cb980c9c941e12d6bcba", size = 9271806, upload-time = "2026-01-07T22:08:10.08Z" }, + { url = "https://files.pythonhosted.org/packages/42/36/82e66b9753a76964d26fd9bc3514ea0abce0a5ba5ad7d5f084070c6981da/ty-0.0.10-py3-none-win_amd64.whl", hash = "sha256:16deb77a72cf93b89b4d29577829613eda535fbe030513dfd9fba70fe38bc9f5", size = 10130520, upload-time = "2026-01-07T23:04:11.759Z" }, + { url = "https://files.pythonhosted.org/packages/63/52/89da123f370e80b587d2db8551ff31562c882d87b32b0e92b59504b709ae/ty-0.0.10-py3-none-win_arm64.whl", hash = "sha256:7495288bca7afba9a4488c9906466d648ffd3ccb6902bc3578a6dbd91a8f05f0", size = 9626026, upload-time = "2026-01-07T23:04:17.91Z" }, +] + +[[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 = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +]