From 9cb315452fcb0ff1ea78e0fa3e76daaafa68a4ee Mon Sep 17 00:00:00 2001 From: Cody Fincher <204685+cofin@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:19:13 -0600 Subject: [PATCH] feat(flask): Auto extend Flask CLI and add session integration (#111) The Advanced Alchemy alembic CLI is now auto-extended to your Flask application. The Flask extension now also has a session handling middleware for handling auto-commits. Last, but not least, there's an experimental async portal that integrates a long running asyncio loop for running async operations in Flask. Using `foo = portal.call()` you can get the result of an asynchronous function from a sync context. --- .pre-commit-config.yaml | 2 +- advanced_alchemy/extensions/flask/__init__.py | 86 +++ advanced_alchemy/extensions/flask/alembic.py | 72 +++ advanced_alchemy/extensions/flask/cli.py | 69 ++ advanced_alchemy/extensions/flask/config.py | 259 ++++++++ .../extensions/flask/extension.py | 182 ++++++ advanced_alchemy/extensions/flask/service.py | 62 ++ advanced_alchemy/utils/portals.py | 169 +++++ docs/usage/frameworks/flask.rst | 207 ++++++ docs/usage/index.rst | 1 + examples/flask.py | 47 -- examples/flask/__init__.py | 0 examples/flask/flask_services.py | 107 ++++ pyproject.toml | 24 +- tests/docker_service_fixtures.py | 3 + tests/helpers.py | 4 +- tests/unit/test_extensions/test_flask.py | 599 ++++++++++++++++++ .../test_extensions/test_litestar/test_dto.py | 4 +- .../test_litestar/test_dto_integration.py | 42 +- .../test_litestar/test_litestar_re_exports.py | 15 +- tests/unit/test_repository.py | 23 +- tests/unit/test_utils/test_portals.py | 58 ++ uv.lock | 297 +++++---- 23 files changed, 2128 insertions(+), 204 deletions(-) create mode 100644 advanced_alchemy/extensions/flask/__init__.py create mode 100644 advanced_alchemy/extensions/flask/alembic.py create mode 100644 advanced_alchemy/extensions/flask/cli.py create mode 100644 advanced_alchemy/extensions/flask/config.py create mode 100644 advanced_alchemy/extensions/flask/extension.py create mode 100644 advanced_alchemy/extensions/flask/service.py create mode 100644 advanced_alchemy/utils/portals.py create mode 100644 docs/usage/frameworks/flask.rst delete mode 100644 examples/flask.py create mode 100644 examples/flask/__init__.py create mode 100644 examples/flask/flask_services.py create mode 100644 tests/unit/test_extensions/test_flask.py create mode 100644 tests/unit/test_utils/test_portals.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c048ee97..6591065c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: - id: unasyncd additional_dependencies: ["ruff"] - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.9.1" + rev: "v0.9.2" hooks: # Run the linter. - id: ruff diff --git a/advanced_alchemy/extensions/flask/__init__.py b/advanced_alchemy/extensions/flask/__init__.py new file mode 100644 index 00000000..f551f947 --- /dev/null +++ b/advanced_alchemy/extensions/flask/__init__.py @@ -0,0 +1,86 @@ +"""Flask extension for Advanced Alchemy. + +This module provides Flask integration for Advanced Alchemy, including session management, +database migrations, and service utilities. + +Example: + Basic usage with synchronous SQLAlchemy: + + ```python + from flask import Flask + from advanced_alchemy.extensions.flask import ( + AdvancedAlchemy, + SQLAlchemySyncConfig, + EngineConfig, + ) + + app = Flask(__name__) + + db_config = SQLAlchemySyncConfig( + engine_config=EngineConfig(url="sqlite:///db.sqlite3"), + create_all=True, # Create tables on startup + ) + + db = AdvancedAlchemy(config=db_config) + db.init_app(app) + + + # Get a session in your route + @app.route("/") + def index(): + session = db.get_session() + # Use session... + ``` + + Using async SQLAlchemy: + + ```python + from advanced_alchemy.extensions.flask import ( + AdvancedAlchemy, + SQLAlchemyAsyncConfig, + ) + + app = Flask(__name__) + + db_config = SQLAlchemyAsyncConfig( + engine_config=EngineConfig( + url="postgresql+asyncpg://user:pass@localhost/db" + ), + create_all=True, + ) + + db = AdvancedAlchemy(config=db_config) + db.init_app(app) + ``` +""" + +from advanced_alchemy import base, exceptions, filters, mixins, operations, repository, service, types, utils +from advanced_alchemy.alembic.commands import AlembicCommands +from advanced_alchemy.config import AlembicAsyncConfig, AlembicSyncConfig, AsyncSessionConfig, SyncSessionConfig +from advanced_alchemy.extensions.flask.cli import get_database_migration_plugin +from advanced_alchemy.extensions.flask.config import EngineConfig, SQLAlchemyAsyncConfig, SQLAlchemySyncConfig +from advanced_alchemy.extensions.flask.extension import AdvancedAlchemy +from advanced_alchemy.extensions.flask.service import FlaskServiceMixin + +__all__ = ( + "AdvancedAlchemy", + "AlembicAsyncConfig", + "AlembicCommands", + "AlembicSyncConfig", + "AsyncSessionConfig", + "EngineConfig", + "FlaskServiceMixin", + "SQLAlchemyAsyncConfig", + "SQLAlchemySyncConfig", + "SyncSessionConfig", + "base", + "exceptions", + "filters", + "get_database_migration_plugin", + "mixins", + "operations", + "repository", + "service", + "types", + "utils", +) diff --git a/advanced_alchemy/extensions/flask/alembic.py b/advanced_alchemy/extensions/flask/alembic.py new file mode 100644 index 00000000..a58c13e1 --- /dev/null +++ b/advanced_alchemy/extensions/flask/alembic.py @@ -0,0 +1,72 @@ +"""Alembic integration for Flask applications.""" + +from __future__ import annotations + +from contextlib import suppress +from typing import TYPE_CHECKING, Any, cast + +from advanced_alchemy.alembic.commands import AlembicCommandConfig +from advanced_alchemy.alembic.commands import AlembicCommands as _AlembicCommands +from advanced_alchemy.exceptions import ImproperConfigurationError + +if TYPE_CHECKING: + from flask import Flask + + from advanced_alchemy.extensions.flask.extension import AdvancedAlchemy + + +def get_sqlalchemy_extension(app: Flask) -> AdvancedAlchemy: + """Retrieve Advanced Alchemy extension from the Flask application. + + Args: + app: The :class:`flask.Flask` application instance. + + Returns: + :class:`AdvancedAlchemy`: The Advanced Alchemy extension instance. + + Raises: + :exc:`advanced_alchemy.exceptions.ImproperConfigurationError`: If the extension is not found. + """ + with suppress(KeyError): + return cast("AdvancedAlchemy", app.extensions["advanced_alchemy"]) + msg = "Failed to initialize database migrations. The Advanced Alchemy extension is not properly configured." + raise ImproperConfigurationError(msg) + + +class AlembicCommands(_AlembicCommands): + """Flask-specific implementation of Alembic commands. + + Args: + app: The :class:`flask.Flask` application instance. + """ + + def __init__(self, app: Flask) -> None: + """Initialize the Alembic commands. + + Args: + app: The Flask application instance. + """ + self._app = app + self.db = get_sqlalchemy_extension(self._app) + self.config = self._get_alembic_command_config() + + def _get_alembic_command_config(self) -> AlembicCommandConfig: + """Get the Alembic command configuration. + + Returns: + :class:`AlembicCommandConfig`: The command configuration instance. + """ + kwargs: dict[str, Any] = {} + if self.sqlalchemy_config.alembic_config.script_config: + kwargs["file_"] = self.sqlalchemy_config.alembic_config.script_config + if self.sqlalchemy_config.alembic_config.template_path: + kwargs["template_directory"] = self.sqlalchemy_config.alembic_config.template_path + kwargs.update( + { + "engine": self.sqlalchemy_config.get_engine(), + "version_table_name": self.sqlalchemy_config.alembic_config.version_table_name, + }, + ) + self.config = AlembicCommandConfig(**kwargs) + self.config.set_main_option("script_location", self.sqlalchemy_config.alembic_config.script_location) + return self.config diff --git a/advanced_alchemy/extensions/flask/cli.py b/advanced_alchemy/extensions/flask/cli.py new file mode 100644 index 00000000..ffe60eca --- /dev/null +++ b/advanced_alchemy/extensions/flask/cli.py @@ -0,0 +1,69 @@ +"""Command-line interface utilities for Flask integration. + +This module provides CLI commands for database management in Flask applications. +""" + +from __future__ import annotations + +from contextlib import suppress +from typing import TYPE_CHECKING, cast + +from flask import current_app + +from advanced_alchemy.cli import add_migration_commands + +try: + import rich_click as click +except ImportError: + import click # type: ignore[no-redef] + + +if TYPE_CHECKING: + from flask import Flask + + from advanced_alchemy.extensions.flask.extension import AdvancedAlchemy + + +def get_database_migration_plugin(app: Flask) -> AdvancedAlchemy: + """Retrieve the Advanced Alchemy extension from the Flask application. + + Args: + app: The :class:`flask.Flask` application instance. + + Returns: + :class:`AdvancedAlchemy`: The Advanced Alchemy extension instance. + + Raises: + :exc:`advanced_alchemy.exceptions.ImproperConfigurationError`: If the extension is not found. + + Example: + ```python + from flask import Flask + from advanced_alchemy.extensions.flask import ( + get_database_migration_plugin, + ) + + app = Flask(__name__) + db = get_database_migration_plugin(app) + ``` + """ + from advanced_alchemy.exceptions import ImproperConfigurationError + + with suppress(KeyError): + return cast("AdvancedAlchemy", app.extensions["advanced_alchemy"]) + msg = "Failed to initialize database migrations. The Advanced Alchemy extension is not properly configured." + raise ImproperConfigurationError(msg) + + +@click.group(name="database") +@click.pass_context +def database_group(ctx: click.Context) -> None: + """Manage SQLAlchemy database components. + + This command group provides database management commands like migrations. + """ + ctx.ensure_object(dict) + ctx.obj["configs"] = get_database_migration_plugin(current_app).config + + +add_migration_commands(database_group) diff --git a/advanced_alchemy/extensions/flask/config.py b/advanced_alchemy/extensions/flask/config.py new file mode 100644 index 00000000..bbb32bf5 --- /dev/null +++ b/advanced_alchemy/extensions/flask/config.py @@ -0,0 +1,259 @@ +"""Configuration classes for Flask integration. + +This module provides configuration classes for integrating SQLAlchemy with Flask applications, +including both synchronous and asynchronous database configurations. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, cast + +from click import echo +from flask import g, has_request_context +from litestar.serialization import decode_json, encode_json +from sqlalchemy.exc import OperationalError +from typing_extensions import Literal + +from advanced_alchemy.base import metadata_registry +from advanced_alchemy.config import EngineConfig as _EngineConfig +from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig as _SQLAlchemyAsyncConfig +from advanced_alchemy.config.sync import SQLAlchemySyncConfig as _SQLAlchemySyncConfig +from advanced_alchemy.exceptions import ImproperConfigurationError + +if TYPE_CHECKING: + from typing import Any + + from flask import Flask, Response + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.orm import Session + + from advanced_alchemy.utils.portals import Portal + + +__all__ = ("EngineConfig", "SQLAlchemyAsyncConfig", "SQLAlchemySyncConfig") + +ConfigT = TypeVar("ConfigT", bound="Union[SQLAlchemySyncConfig, SQLAlchemyAsyncConfig]") + + +def serializer(value: Any) -> str: + """Serialize JSON field values. + + Args: + value: Any JSON serializable value. + + Returns: + str: JSON string representation of the value. + """ + return encode_json(value).decode("utf-8") + + +@dataclass +class EngineConfig(_EngineConfig): + """Configuration for SQLAlchemy's Engine. + + This class extends the base EngineConfig with Flask-specific JSON serialization options. + + For details see: https://docs.sqlalchemy.org/en/20/core/engines.html + + Attributes: + json_deserializer: Callable for converting JSON strings to Python objects. + json_serializer: Callable for converting Python objects to JSON strings. + """ + + json_deserializer: Callable[[str], Any] = decode_json + """For dialects that support the :class:`~sqlalchemy.types.JSON` datatype, this is a Python callable that will + convert a JSON string to a Python object. By default, this is set to Litestar's decode_json function.""" + json_serializer: Callable[[Any], str] = serializer + """For dialects that support the JSON datatype, this is a Python callable that will render a given object as JSON. + By default, Litestar's encode_json function is used.""" + + +@dataclass +class SQLAlchemySyncConfig(_SQLAlchemySyncConfig): + """Flask-specific synchronous SQLAlchemy configuration. + + Attributes: + app: The Flask application instance. + commit_mode: The commit mode to use for database sessions. + """ + + app: Flask | None = None + """The Flask application instance.""" + commit_mode: Literal["manual", "autocommit", "autocommit_with_redirect"] = "manual" + """The commit mode to use for database sessions.""" + + def create_session_maker(self) -> Callable[[], Session]: + """Get a session maker. If none exists yet, create one. + + Returns: + Callable[[], Session]: Session factory used by the plugin. + """ + if self.session_maker: + return self.session_maker + + session_kws = self.session_config_dict + if self.engine_instance is None: + self.engine_instance = self.get_engine() + if session_kws.get("bind") is None: + session_kws["bind"] = self.engine_instance + self.session_maker = self.session_maker_class(**session_kws) + return self.session_maker + + def init_app(self, app: Flask, portal: Portal | None = None) -> None: + """Initialize the Flask application with this configuration. + + Args: + app: The Flask application instance. + portal: The portal to use for thread-safe communication. Unused in synchronous configurations. + """ + self.app = app + self.bind_key = self.bind_key or "default" + if self.create_all: + self.create_all_metadata() + if self.commit_mode != "manual": + self._setup_session_handling(app) + + def _setup_session_handling(self, app: Flask) -> None: + """Set up the session handling for the Flask application. + + Args: + app: The Flask application instance. + """ + + @app.after_request + def handle_db_session(response: Response) -> Response: # pyright: ignore[reportUnusedFunction] + """Commit the session if the response meets the commit criteria.""" + if not has_request_context(): + return response + + db_session = cast("Optional[Session]", g.pop(f"advanced_alchemy_session_{self.bind_key}", None)) + if db_session is not None: + if (self.commit_mode == "autocommit" and 200 <= response.status_code < 300) or ( # noqa: PLR2004 + self.commit_mode == "autocommit_with_redirect" and 200 <= response.status_code < 400 # noqa: PLR2004 + ): + db_session.commit() + db_session.close() + return response + + def close_engines(self, portal: Portal) -> None: + """Close the engines. + + Args: + portal: The portal to use for thread-safe communication. + """ + if self.engine_instance is not None: + self.engine_instance.dispose() + + def create_all_metadata(self) -> None: # pragma: no cover + """Create all metadata tables in the database.""" + if self.engine_instance is None: + self.engine_instance = self.get_engine() + with self.engine_instance.begin() as conn: + try: + metadata_registry.get(self.bind_key).create_all(conn) + except OperationalError as exc: + echo(f" * Could not create target metadata. Reason: {exc}") + + +@dataclass +class SQLAlchemyAsyncConfig(_SQLAlchemyAsyncConfig): + """Flask-specific asynchronous SQLAlchemy configuration. + + Attributes: + app: The Flask application instance. + commit_mode: The commit mode to use for database sessions. + """ + + app: Flask | None = None + """The Flask application instance.""" + commit_mode: Literal["manual", "autocommit", "autocommit_with_redirect"] = "manual" + """The commit mode to use for database sessions.""" + + def create_session_maker(self) -> Callable[[], AsyncSession]: + """Get a session maker. If none exists yet, create one. + + Returns: + Callable[[], AsyncSession]: Session factory used by the plugin. + """ + if self.session_maker: + return self.session_maker + + session_kws = self.session_config_dict + if self.engine_instance is None: + self.engine_instance = self.get_engine() + if session_kws.get("bind") is None: + session_kws["bind"] = self.engine_instance + self.session_maker = self.session_maker_class(**session_kws) + return self.session_maker + + def init_app(self, app: Flask, portal: Portal | None = None) -> None: + """Initialize the Flask application with this configuration. + + Args: + app: The Flask application instance. + portal: The portal to use for thread-safe communication. + + Raises: + ImproperConfigurationError: If portal is not provided for async configuration. + """ + self.app = app + self.bind_key = self.bind_key or "default" + if portal is None: + msg = "Portal is required for asynchronous configurations" + raise ImproperConfigurationError(msg) + if self.create_all: + _ = portal.call(self.create_all_metadata) + self._setup_session_handling(app, portal) + + def _setup_session_handling(self, app: Flask, portal: Portal) -> None: + """Set up the session handling for the Flask application. + + Args: + app: The Flask application instance. + portal: The portal to use for thread-safe communication. + """ + + @app.after_request + def handle_db_session(response: Response) -> Response: # pyright: ignore[reportUnusedFunction] + """Commit the session if the response meets the commit criteria.""" + if not has_request_context(): + return response + + db_session = cast("Optional[AsyncSession]", g.pop(f"advanced_alchemy_session_{self.bind_key}", None)) + if db_session is not None: + p = getattr(db_session, "_session_portal", None) or portal + if (self.commit_mode == "autocommit" and 200 <= response.status_code < 300) or ( # noqa: PLR2004 + self.commit_mode == "autocommit_with_redirect" and 200 <= response.status_code < 400 # noqa: PLR2004 + ): + _ = p.call(db_session.commit) + _ = p.call(db_session.close) + return response + + @app.teardown_appcontext + def close_db_session(_: BaseException | None) -> None: # pyright: ignore[reportUnusedFunction] + """Close the session at the end of the request.""" + db_session = cast("Optional[AsyncSession]", g.pop(f"advanced_alchemy_session_{self.bind_key}", None)) + if db_session is not None: + p = getattr(db_session, "_session_portal", None) or portal + _ = p.call(db_session.close) + + def close_engines(self, portal: Portal) -> None: + """Close the engines. + + Args: + portal: The portal to use for thread-safe communication. + """ + if self.engine_instance is not None: + _ = portal.call(self.engine_instance.dispose) + + async def create_all_metadata(self) -> None: # pragma: no cover + """Create all metadata tables in the database.""" + if self.engine_instance is None: + self.engine_instance = self.get_engine() + async with self.engine_instance.begin() as conn: + try: + await conn.run_sync(metadata_registry.get(self.bind_key).create_all) + await conn.commit() + except OperationalError as exc: + echo(f" * Could not create target metadata. Reason: {exc}") diff --git a/advanced_alchemy/extensions/flask/extension.py b/advanced_alchemy/extensions/flask/extension.py new file mode 100644 index 00000000..f4506ca5 --- /dev/null +++ b/advanced_alchemy/extensions/flask/extension.py @@ -0,0 +1,182 @@ +# ruff: noqa: UP007, SLF001, UP006, ARG001 +"""Flask extension for Advanced Alchemy.""" + +from __future__ import annotations + +from contextlib import contextmanager, suppress +from typing import TYPE_CHECKING, Callable, Generator, Sequence, Union, cast + +from flask import g +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session + +from advanced_alchemy.exceptions import ImproperConfigurationError +from advanced_alchemy.extensions.flask.cli import database_group +from advanced_alchemy.extensions.flask.config import SQLAlchemyAsyncConfig, SQLAlchemySyncConfig +from advanced_alchemy.utils.portals import PortalProvider + +if TYPE_CHECKING: + from flask import Flask + + +class AdvancedAlchemy: + """Flask extension for Advanced Alchemy.""" + + __slots__ = ( + "_config", + "_has_async_config", + "_session_makers", + "portal_provider", + ) + + def __init__( + self, + config: SQLAlchemySyncConfig | SQLAlchemyAsyncConfig | Sequence[SQLAlchemySyncConfig | SQLAlchemyAsyncConfig], + app: Flask | None = None, + *, + portal_provider: PortalProvider | None = None, + ) -> None: + """Initialize the extension.""" + self.portal_provider = portal_provider if portal_provider is not None else PortalProvider() + self._config = config if isinstance(config, Sequence) else [config] + self._has_async_config = any(isinstance(c, SQLAlchemyAsyncConfig) for c in self.config) + self._session_makers: dict[str, Callable[..., Union[AsyncSession, Session]]] = {} + + if app is not None: + self.init_app(app) + + @property + def config(self) -> Sequence[SQLAlchemyAsyncConfig | SQLAlchemySyncConfig]: + """Get the SQLAlchemy configuration(s).""" + return self._config + + @property + def is_async_enabled(self) -> bool: + """Return True if any of the database configs are async.""" + return self._has_async_config + + def init_app(self, app: Flask) -> None: + """Initialize the Flask application. + + Args: + app: The Flask application to initialize. + + Raises: + ImproperConfigurationError: If the extension is already registered on the Flask application. + """ + if "advanced_alchemy" in app.extensions: + msg = "Advanced Alchemy extension is already registered on this Flask application." + raise ImproperConfigurationError(msg) + + if self._has_async_config: + self.portal_provider.start() + + # Create tables for async configs + for cfg in self._config: + if isinstance(cfg, SQLAlchemyAsyncConfig): + self.portal_provider.portal.call(cfg.create_all_metadata) + + # Register shutdown handler for the portal + @app.teardown_appcontext + def shutdown_portal(exception: BaseException | None = None) -> None: # pyright: ignore[reportUnusedFunction] + """Stop the portal when the application shuts down.""" + if not app.debug: # Don't stop portal in debug mode + with suppress(Exception): + self.portal_provider.stop() + + # Initialize each config with the app + for config in self.config: + config.init_app(app, self.portal_provider.portal) + bind_key = config.bind_key if config.bind_key is not None else "default" + session_maker = config.create_session_maker() + self._session_makers[bind_key] = session_maker + + # Register session cleanup only + app.teardown_appcontext(self._teardown_appcontext) + + app.extensions["advanced_alchemy"] = self + app.cli.add_command(database_group) + + def _teardown_appcontext(self, exception: BaseException | None = None) -> None: + """Clean up resources when the application context ends.""" + for key in list(g): + if key.startswith("advanced_alchemy_session_"): + session = getattr(g, key) + if isinstance(session, AsyncSession): + # Close async sessions through the portal + with suppress(ImproperConfigurationError): + self.portal_provider.portal.call(session.close) + else: + session.close() + delattr(g, key) + + def get_session(self, bind_key: str = "default") -> Session | AsyncSession: + """Get a new session from the configured session factory. + + Args: + bind_key: The bind key to use for the session. + + Returns: + A new session from the configured session factory. + + Raises: + ImproperConfigurationError: If no session maker is found for the bind key. + """ + if bind_key == "default" and len(self.config) == 1: + bind_key = self.config[0].bind_key if self.config[0].bind_key is not None else "default" + + session_key = f"advanced_alchemy_session_{bind_key}" + if hasattr(g, session_key): + return cast("Union[AsyncSession, Session]", getattr(g, session_key)) + + session_maker = self._session_makers.get(bind_key) + if session_maker is None: + msg = f'No session maker found for bind key "{bind_key}"' + raise ImproperConfigurationError(msg) + + session = session_maker() + if self._has_async_config: + # Ensure portal is started + if not self.portal_provider.is_running: + self.portal_provider.start() + setattr(session, "_session_portal", self.portal_provider.portal) + setattr(g, session_key, session) + return session + + def get_async_session(self, bind_key: str = "default") -> AsyncSession: + """Get an async session from the configured session factory.""" + session = self.get_session(bind_key) + if not isinstance(session, AsyncSession): + msg = f"Expected async session for bind key {bind_key}, but got {type(session)}" + raise ImproperConfigurationError(msg) + return session + + def get_sync_session(self, bind_key: str = "default") -> Session: + """Get a sync session from the configured session factory.""" + session = self.get_session(bind_key) + if not isinstance(session, Session): + msg = f"Expected sync session for bind key {bind_key}, but got {type(session)}" + raise ImproperConfigurationError(msg) + return session + + @contextmanager + def with_session( # pragma: no cover (more on this later) + self, bind_key: str = "default" + ) -> Generator[Union[AsyncSession, Session], None, None]: + """Provide a transactional scope around a series of operations. + + Args: + bind_key: The bind key to use for the session. + + Yields: + A session. + """ + session = self.get_session(bind_key) + try: + yield session + finally: + if isinstance(session, AsyncSession): + with suppress(ImproperConfigurationError): + self.portal_provider.portal.call(session.close) + else: + session.close() diff --git a/advanced_alchemy/extensions/flask/service.py b/advanced_alchemy/extensions/flask/service.py new file mode 100644 index 00000000..80ef3993 --- /dev/null +++ b/advanced_alchemy/extensions/flask/service.py @@ -0,0 +1,62 @@ +"""Flask-specific service classes.""" + +from __future__ import annotations + +from typing import Any + +from flask import Response, current_app + +from advanced_alchemy.extensions.flask.config import serializer + + +class FlaskServiceMixin: + """Mixin to add Flask-specific functionality to services. + + Example: + .. code-block:: python + + from advanced_alchemy.service import ( + SQLAlchemyAsyncRepositoryService, + ) + from advanced_alchemy.extensions.flask import ( + FlaskServiceMixin, + ) + + + class UserService( + FlaskServiceMixin, + SQLAlchemyAsyncRepositoryService[User], + ): + class Repo(repository.SQLAlchemySyncRepository[User]): + model_type = User + + repository_type = Repo + + def get_user_response(self, user_id: int) -> Response: + user = self.get(user_id) + return self.jsonify(user.dict()) + """ + + def jsonify( + self, + data: Any, + *args: Any, + status_code: int = 200, + **kwargs: Any, + ) -> Response: + """Convert data to a Flask JSON response. + + Args: + data: Data to serialize to JSON. + *args: Additional positional arguments passed to Flask's response class. + status_code: HTTP status code for the response. Defaults to 200. + **kwargs: Additional keyword arguments passed to Flask's response class. + + Returns: + :class:`flask.Response`: A Flask response with JSON content type. + """ + return current_app.response_class( + serializer(data), + status=status_code, + mimetype="application/json", + ) diff --git a/advanced_alchemy/utils/portals.py b/advanced_alchemy/utils/portals.py new file mode 100644 index 00000000..28dec54c --- /dev/null +++ b/advanced_alchemy/utils/portals.py @@ -0,0 +1,169 @@ +# ruff: noqa: UP007 +"""This module provides a portal provider and portal for calling async functions from synchronous code.""" + +from __future__ import annotations + +import asyncio +import functools +import queue +import threading +from typing import Any, Callable, Coroutine, Optional, TypeVar, cast +from warnings import warn + +from advanced_alchemy.exceptions import ImproperConfigurationError + +__all__ = ("Portal", "PortalProvider", "PortalProviderSingleton") + +_R = TypeVar("_R") + + +class PortalProviderSingleton(type): + _instances: dict[type, PortalProvider] = {} + + def __call__(cls, *args: Any, **kwargs: Any) -> PortalProvider: + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType] + + +class PortalProvider(metaclass=PortalProviderSingleton): + def __init__(self) -> None: + self._request_queue: queue.Queue[ + tuple[ + Callable[..., Coroutine[Any, Any, Any]], + tuple[Any, ...], + dict[str, Any], + queue.Queue[tuple[Optional[Any], Optional[Exception]]], + ] + ] = queue.Queue() + self._result_queue: queue.Queue[tuple[Optional[Any], Optional[Exception]]] = queue.Queue() + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + self._ready_event: threading.Event = threading.Event() + + @property + def portal(self) -> Portal: + """The portal instance.""" + return Portal(self) + + @property + def is_running(self) -> bool: + """Whether the portal provider is running.""" + return self._thread is not None and self._thread.is_alive() + + @property + def is_ready(self) -> bool: + """Whether the portal provider is ready.""" + return self._ready_event.is_set() + + @property + def loop(self) -> asyncio.AbstractEventLoop: # pragma: no cover + """The event loop.""" + if self._loop is None: + msg = "The PortalProvider is not started. Did you forget to call .start()?" + raise ImproperConfigurationError(msg) + return self._loop + + def start(self) -> None: + """Starts the background thread and event loop.""" + if self._thread is not None: # pragma: no cover + warn("PortalProvider already started", stacklevel=2) + return + self._thread = threading.Thread(target=self._run_event_loop, daemon=True) + self._thread.start() + self._ready_event.wait() # Wait for the loop to be ready + + def stop(self) -> None: + """Stops the background thread and event loop.""" + if self._loop is None or self._thread is None: + return + + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join() + self._loop.close() + self._loop = None + self._thread = None + self._ready_event.clear() + + def _run_event_loop(self) -> None: # pragma: no cover + """The main function of the background thread.""" + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._ready_event.set() # Signal that the loop is ready + + self._loop.run_forever() + + async def _async_caller( + self, func: Callable[..., Coroutine[Any, Any, _R]], args: tuple[Any, ...], kwargs: dict[str, Any] + ) -> _R: + """Wrapper to run the async function and send the result to the result queue.""" + result: _R = await func(*args, **kwargs) + return result + + def call(self, func: Callable[..., Coroutine[Any, Any, _R]], *args: Any, **kwargs: Any) -> _R: + """Calls an async function from a synchronous context. + + Args: + func: The async function to call. + *args: Positional arguments to the function. + **kwargs: Keyword arguments to the function. + + Returns: + The result of the async function. + + Raises: + Exception: If the async function raises an exception. + """ + if self._loop is None: + msg = "The PortalProvider is not started. Did you forget to call .start()?" + raise ImproperConfigurationError(msg) + + # Create a new result queue + local_result_queue: queue.Queue[tuple[Optional[_R], Optional[Exception]]] = queue.Queue() + + # Send the request to the background thread + self._request_queue.put((func, args, kwargs, local_result_queue)) + + # Trigger the execution in the event loop + _handle = self._loop.call_soon_threadsafe(self._process_request) + + # Wait for the result from the background thread + result, exception = local_result_queue.get() + + if exception: + raise exception + return cast(_R, result) + + def _process_request(self) -> None: # pragma: no cover + """Processes a request from the request queue in the event loop.""" + assert self._loop is not None # noqa: S101 + + if not self._request_queue.empty(): + func, args, kwargs, local_result_queue = self._request_queue.get() + future = asyncio.run_coroutine_threadsafe(self._async_caller(func, args, kwargs), self._loop) + + # Attach a callback to handle the result/exception + future.add_done_callback( + functools.partial(self._handle_future_result, local_result_queue=local_result_queue) # pyright: ignore[reportArgumentType] + ) + + def _handle_future_result( + self, + future: asyncio.Future[Any], + local_result_queue: queue.Queue[tuple[Optional[Any], Optional[Exception]]], + ) -> None: # pragma: no cover + """Handles the result or exception from the completed future.""" + try: + result = future.result() + local_result_queue.put((result, None)) + except Exception as e: # noqa: BLE001 + local_result_queue.put((None, e)) + + +class Portal: + def __init__(self, provider: PortalProvider) -> None: + self._provider = provider + + def call(self, func: Callable[..., Coroutine[Any, Any, _R]], *args: Any, **kwargs: Any) -> _R: + """Calls an async function using the associated PortalProvider.""" + return self._provider.call(func, *args, **kwargs) diff --git a/docs/usage/frameworks/flask.rst b/docs/usage/frameworks/flask.rst new file mode 100644 index 00000000..582c14fe --- /dev/null +++ b/docs/usage/frameworks/flask.rst @@ -0,0 +1,207 @@ +Flask Integration +================= + +Advanced Alchemy provides seamless integration with Flask applications through its Flask extension. + +Installation +------------ + +The Flask extension is included with Advanced Alchemy by default. No additional installation is required. + +Basic Usage +----------- + +Here's a basic example of using Advanced Alchemy with Flask: + +.. code-block:: python + + from flask import Flask + from advanced_alchemy.extensions.flask import ( + AdvancedAlchemy, + SQLAlchemySyncConfig, + EngineConfig, + ) + + app = Flask(__name__) + + db_config = SQLAlchemySyncConfig( + engine_config=EngineConfig( + url="sqlite:///db.sqlite3", + ), + commit_mode="autocommit", + ) + + db = AdvancedAlchemy(config=db_config) + db.init_app(app) + + # Use in your routes + @app.route("/users") + def list_users(): + session = db.get_session() + users = session.query(User).all() + return {"users": [user.dict() for user in users]} + +Multiple Databases +------------------ + +Advanced Alchemy supports multiple database configurations: + +.. note:: + + The ``bind_key`` option is used to specify the database to use for a given session. + + When using multiple databases and you do not have at least one database with a ``bind_key`` of ``default``, and exception will be raised when calling ``db.get_session()`` without a bind key. + + This only applies when using multiple configuration. If you are using a single configuration, the engine will be returned even if the ``bind_key`` is not ``default``. + +.. code-block:: python + + configs = [ + SQLAlchemySyncConfig( + engine_config=EngineConfig(url="sqlite:///users.db"), + bind_key="users", + ), + SQLAlchemySyncConfig( + engine_config=EngineConfig(url="sqlite:///products.db"), + bind_key="products", + ), + ] + + db = AdvancedAlchemy(config=configs) + db.init_app(app) + + # Get session for specific database + users_session = db.get_session("users") + products_session = db.get_session("products") + +Async Support +------------- + +Advanced Alchemy supports async SQLAlchemy with Flask: + +.. code-block:: python + + from advanced_alchemy.extensions.flask import ( + AdvancedAlchemy, + SQLAlchemyAsyncConfig, + ) + + db_config = SQLAlchemyAsyncConfig( + engine_config=EngineConfig( + url="postgresql+asyncpg://user:pass@localhost/db", + ), + create_all=True, + ) + + db = AdvancedAlchemy(config=db_config) + db.init_app(app) + + # Use async session in your routes + @app.route("/users") + async def list_users(): + session = db.get_session() + users = await session.execute(select(User)) + return {"users": [user.dict() for user in users.scalars()]} + +You can also safely use an AsyncSession in your routes within a sync context: + +.. code-block:: python + + @app.route("/users") + def list_users(): + session = db.get_session() + users = session.execute(select(User)) + return {"users": [user.dict() for user in users.scalars()]} + +Configuration +------------- + +SQLAlchemy Configuration +~~~~~~~~~~~~~~~~~~~~~~~~ + +Both sync and async configurations support these options: + +.. list-table:: + :header-rows: 1 + + * - Option + - Type + - Description + - Default + * - ``engine_config`` + - ``EngineConfig`` + - SQLAlchemy engine configuration + - Required + * - ``bind_key`` + - ``str`` + - Key for multiple database support + - "default" + * - ``create_all`` + - ``bool`` + - Create tables on startup + - ``False`` + * - ``commit_mode`` + - ``"autocommit", "autocommit_with_redirect", "manual"`` + - Session commit behavior + - ``"manual"`` + +Commit Modes +~~~~~~~~~~~~ + +The ``commit_mode`` option controls how database sessions are committed: + +- ``"manual"`` (default): No automatic commits +- ``"autocommit"``: Commit on successful responses (2xx status codes) +- ``"autocommit_with_redirect"``: Commit on successful responses and redirects (2xx and 3xx status codes) + +Services +-------- + +The ``FlaskServiceMixin`` adds Flask-specific functionality to services: + +.. code-block:: python + + from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService + from advanced_alchemy.extensions.flask import FlaskServiceMixin + + class UserService( + FlaskServiceMixin, + SQLAlchemyAsyncRepositoryService[User], + ): + class Repo(repository.SQLAlchemySyncRepository[User]): + model_type = User + + repository_type = Repo + + def get_user_response(self, user_id: int) -> Response: + user = self.get(user_id) + return self.jsonify(user.dict()) + +The ``jsonify`` method is analogous to Flask's ``jsonify`` function. However, this implementation will serialize with the configured Advanced Alchemy serialize (i.e. Msgspec or Orjson based on installation). + +Database Migrations +------------------- + +When the extension is configured for Flask, database commands are automatically added to the Flask CLI. These are the same commands available to you when running the ``alchemy`` standalone CLI. + +Here's an example of the commands available to Flask + +.. code-block:: bash + + # Initialize migrations + flask database init + + # Create a new migration + flask database revision --autogenerate -m "Add users table" + + # Apply migrations + flask database upgrade + + # Revert migrations + flask database downgrade + + # Show migration history + flask database history + + # Show all commands + flask database --help diff --git a/docs/usage/index.rst b/docs/usage/index.rst index 2ace6165..a3b6e89b 100644 --- a/docs/usage/index.rst +++ b/docs/usage/index.rst @@ -25,6 +25,7 @@ This guide demonstrates building a complete blog system using Advanced Alchemy's :caption: Framework Integration frameworks/litestar + frameworks/flask frameworks/fastapi The guide follows a practical approach: diff --git a/examples/flask.py b/examples/flask.py deleted file mode 100644 index ee97e8bb..00000000 --- a/examples/flask.py +++ /dev/null @@ -1,47 +0,0 @@ -from flask import Flask -from flask_sqlalchemy import SQLAlchemy -from sqlalchemy.orm import Mapped, sessionmaker - -from advanced_alchemy.base import UUIDBase -from advanced_alchemy.repository import SQLAlchemySyncRepository - -SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" - -app = Flask(__name__) -app.config["SQLALCHEMY_DATABASE_URI"] = SQLALCHEMY_DATABASE_URI -db = SQLAlchemy(app) - - -class Message(UUIDBase): - text: Mapped[str] - - -class MessageRepository(SQLAlchemySyncRepository[Message]): - model_type = Message - - -# Working -with app.app_context(): - with db.engine.begin() as conn: - Message.metadata.create_all(conn) - - session = sessionmaker(db.engine)() - - repo = MessageRepository(session=session) - repo.add(Message(text="Hello, world!")) - - message = repo.list()[0] - assert message.text == "Hello, world!" # noqa: S101 - - -# Not working -with app.app_context(): - with db.engine.begin() as conn: - Message.metadata.create_all(conn) - - session = db.session # type: ignore[assignment,unused-ignore] - repo = MessageRepository(session=session) # pyright: ignore[reportArgumentType] - repo.add(Message(text="Hello, world!")) - - message = repo.list()[0] - assert message.text == "Hello, world!" # noqa: S101 diff --git a/examples/flask/__init__.py b/examples/flask/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/flask/flask_services.py b/examples/flask/flask_services.py new file mode 100644 index 00000000..c50d616c --- /dev/null +++ b/examples/flask/flask_services.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import datetime # noqa: TC003 +import os +from uuid import UUID # noqa: TC003 + +from flask import Flask, request +from msgspec import Struct +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from advanced_alchemy.extensions.flask import ( + AdvancedAlchemy, + FlaskServiceMixin, + SQLAlchemySyncConfig, + base, + filters, + repository, + service, +) + + +class Author(base.UUIDBase): + """Author model.""" + + name: Mapped[str] + dob: Mapped[datetime.date | None] + books: Mapped[list[Book]] = relationship(back_populates="author", lazy="noload") + + +class Book(base.UUIDAuditBase): + """Book model.""" + + title: Mapped[str] + author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id")) + author: Mapped[Author] = relationship(lazy="joined", innerjoin=True, viewonly=True) + + +class AuthorService(service.SQLAlchemySyncRepositoryService[Author], FlaskServiceMixin): + """Author service.""" + + class Repo(repository.SQLAlchemySyncRepository[Author]): + """Author repository.""" + + model_type = Author + + repository_type = Repo + + +class AuthorSchema(Struct): + """Author schema.""" + + name: str + id: UUID | None = None + dob: datetime.date | None = None + + +app = Flask(__name__) +config = SQLAlchemySyncConfig(connection_string="sqlite:///:memory:", commit_mode="autocommit") +alchemy = AdvancedAlchemy(config, app) + + +@app.route("/authors", methods=["GET"]) +def list_authors(): + """List authors with pagination.""" + page, page_size = request.args.get("currentPage", 1, type=int), request.args.get("pageSize", 10, type=int) + limit_offset = filters.LimitOffset(limit=page_size, offset=page_size * (page - 1)) + service = AuthorService(session=alchemy.get_sync_session()) + results, total = service.list_and_count(limit_offset) + response = service.to_schema(results, total, filters=[limit_offset], schema_type=AuthorSchema) + return service.jsonify(response) + + +@app.route("/authors", methods=["POST"]) +def create_author(): + """Create a new author.""" + service = AuthorService(session=alchemy.get_sync_session()) + obj = service.create(**request.get_json()) + return service.jsonify(obj) + + +@app.route("/authors/", methods=["GET"]) +def get_author(author_id: UUID): + """Get an existing author.""" + service = AuthorService(session=alchemy.get_sync_session(), load=[Author.books]) + obj = service.get(author_id) + return service.jsonify(obj) + + +@app.route("/authors/", methods=["PATCH"]) +def update_author(author_id: UUID): + """Update an author.""" + service = AuthorService(session=alchemy.get_sync_session(), load=[Author.books]) + obj = service.update(**request.get_json(), item_id=author_id) + return service.jsonify(obj) + + +@app.route("/authors/", methods=["DELETE"]) +def delete_author(author_id: UUID): + """Delete an author.""" + service = AuthorService(session=alchemy.get_sync_session()) + service.delete(author_id) + return "", 204 + + +if __name__ == "__main__": + app.run(debug=os.environ["ENV"] == "dev") diff --git a/pyproject.toml b/pyproject.toml index a3c955b9..16f82723 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ doc = [ ] duckdb = ["duckdb>=1.1.2", "duckdb-engine>=0.13.4", "pytz>=2024.2"] fastapi = ["fastapi[all]>=0.115.3", "starlette<0.45.0; python_version < '3.9'", "starlette; python_version >= '3.9'"] -flask = ["flask-sqlalchemy>=3.1.1"] +flask = ["flask-sqlalchemy>=3.1.1", "flask[async]"] lint = [ "mypy>=1.13.0", "pre-commit>=3.5.0", @@ -152,8 +152,9 @@ test = [ "pytest>=7.4.4", "pytest-asyncio>=0.23.8", "pytest-cov>=5.0.0", - "pytest-databases>=0.10.0", + "pytest-databases", "pytest-lazy-fixtures>=1.1.1", + "pytest-rerunfailures", "pytest-mock>=3.14.0", "pytest-sugar>=1.0.0", "pytest-xdist>=3.6.1", @@ -215,12 +216,14 @@ asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" filterwarnings = [ "ignore::DeprecationWarning:pkg_resources.*", + "ignore:pkg_resources is deprecated as an API:DeprecationWarning", "ignore::DeprecationWarning:pkg_resources", "ignore::DeprecationWarning:google.rpc", "ignore::DeprecationWarning:google.gcloud", "ignore::DeprecationWarning:google.iam", "ignore::DeprecationWarning:google", "ignore::DeprecationWarning:websockets.connection", + "ignore::DeprecationWarning:websockets.legacy", ] markers = [ "integration: SQLAlchemy integration tests", @@ -286,6 +289,7 @@ docstring-code-line-length = 60 [tool.ruff.lint] dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +extend-safe-fixes = ["TC"] fixable = ["ALL"] ignore = [ "A003", # flake8-builtins - class attribute {name} is shadowing a python builtin @@ -317,6 +321,7 @@ ignore = [ "PLR0912", "ISC001", "COM812", + "CPY001", ] select = ["ALL"] @@ -339,6 +344,8 @@ classmethod-decorators = [ [tool.ruff.lint.per-file-ignores] "advanced_alchemy/repository/*.py" = ['C901'] +"examples/flask.py" = ["ANN"] +"examples/flask/*.py" = ["ANN"] "tests/**/*.*" = [ "A", "ARG", @@ -365,6 +372,7 @@ classmethod-decorators = [ "UP006", "SLF001", ] + [tool.ruff.lint.flake8-tidy-imports] # Disallow all relative imports. ban-relative-imports = "all" @@ -416,6 +424,7 @@ ignore_missing_imports = true module = [ "asyncmy", "pyodbc", + "greenlet", "google.auth.*", "google.cloud.*", "google.protobuf.*", @@ -457,6 +466,16 @@ warn_unused_ignores = false module = "advanced_alchemy.base" warn_unused_ignores = false +[[tool.mypy.overrides]] +disallow_untyped_decorators = false +module = "advanced_alchemy.extensions.litestar.cli" + +[[tool.mypy.overrides]] +disable_error_code = "arg-type,no-any-return,no-untyped-def" +disallow_untyped_decorators = false +disallow_untyped_defs = false +module = "examples.flask.*" + [[tool.mypy.overrides]] disable_error_code = "unreachable" module = "tests.integration.test_repository" @@ -470,6 +489,7 @@ exclude = [ "tests/unit/test_repository.py", "tests/helpers.py", "tests/docker_service_fixtures.py", + "examples/flask/flask_services.py", ] include = ["advanced_alchemy"] pythonVersion = "3.8" diff --git a/tests/docker_service_fixtures.py b/tests/docker_service_fixtures.py index b216af9d..e29be5d6 100644 --- a/tests/docker_service_fixtures.py +++ b/tests/docker_service_fixtures.py @@ -90,6 +90,9 @@ async def start( **kwargs: Any, ) -> None: if name not in self._running_services: + if await wrap_sync(check)(self.docker_ip, **kwargs): + self._running_services.add(name) + return self.run_command("up", "-d", name) self._running_services.add(name) diff --git a/tests/helpers.py b/tests/helpers.py index 14b302b6..e0e13057 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import importlib import inspect import sys @@ -9,7 +10,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable, List, Type, TypeVar, cast, overload -import anyio from typing_extensions import ParamSpec if TYPE_CHECKING: @@ -65,6 +65,6 @@ def wrap_sync(fn: Callable[P, T]) -> Callable[P, Awaitable[T]]: return fn async def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: - return await anyio.to_thread.run_sync(partial(fn, *args, **kwargs)) # pyright: ignore + return await asyncio.get_running_loop().run_in_executor(None, partial(fn, *args, **kwargs)) return wrapped diff --git a/tests/unit/test_extensions/test_flask.py b/tests/unit/test_extensions/test_flask.py new file mode 100644 index 00000000..e394f42e --- /dev/null +++ b/tests/unit/test_extensions/test_flask.py @@ -0,0 +1,599 @@ +# ruff: noqa: RUF029 +"""Tests for the Flask extension.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Generator, Sequence + +import pytest +from flask import Flask, Response +from msgspec import Struct +from sqlalchemy import String, select, text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column + +from advanced_alchemy import base, mixins +from advanced_alchemy.exceptions import ImproperConfigurationError +from advanced_alchemy.extensions.flask import ( + AdvancedAlchemy, + FlaskServiceMixin, + SQLAlchemyAsyncConfig, + SQLAlchemySyncConfig, +) +from advanced_alchemy.repository import SQLAlchemyAsyncRepository, SQLAlchemySyncRepository +from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, SQLAlchemySyncRepositoryService + +metadata = base.metadata_registry.get("flask_testing") + + +class NewBigIntBase(mixins.BigIntPrimaryKey, base.CommonTableAttributes, DeclarativeBase): + """Base model with a big integer primary key.""" + + __metadata__ = metadata + + +class User(NewBigIntBase): + """Test user model.""" + + __tablename__ = "users_testing" + + name: Mapped[str] = mapped_column(String(50)) + + +class UserSchema(Struct): + """Test user pydantic model.""" + + name: str + + +class UserService(SQLAlchemySyncRepositoryService[User], FlaskServiceMixin): + """Test user service.""" + + class Repo(SQLAlchemySyncRepository[User]): + model_type = User + + repository_type = Repo + + +class AsyncUserService(SQLAlchemyAsyncRepositoryService[User], FlaskServiceMixin): + """Test user service.""" + + class Repo(SQLAlchemyAsyncRepository[User]): + model_type = User + + repository_type = Repo + + +@pytest.fixture(scope="session") +def tmp_path_session(tmp_path_factory: pytest.TempPathFactory) -> Path: + return tmp_path_factory.mktemp("test_extensions_flask") + + +@pytest.fixture(scope="session") +def setup_database(tmp_path_session: Path) -> Generator[Path, None, None]: + # Create a new database for each test + db_path = tmp_path_session / "test.db" + config = SQLAlchemySyncConfig(connection_string=f"sqlite:///{db_path}", metadata=metadata) + engine = config.get_engine() + User._sa_registry.metadata.create_all(engine) # pyright: ignore[reportPrivateUsage] + with config.get_session() as session: + assert isinstance(session, Session) + table_exists = session.execute(text("SELECT COUNT(*) FROM users_testing")).scalar_one() + assert table_exists >= 0 + yield db_path + + +def test_sync_extension_init(setup_database: Path) -> None: + app = Flask(__name__) + + with app.app_context(): + config = SQLAlchemySyncConfig(connection_string=f"sqlite:///{setup_database}", metadata=metadata) + extension = AdvancedAlchemy(config, app) + assert "advanced_alchemy" in app.extensions + session = extension.get_session() + assert isinstance(session, Session) + + +def test_sync_extension_init_with_app(setup_database: Path) -> None: + app = Flask(__name__) + + with app.app_context(): + config = SQLAlchemySyncConfig(connection_string=f"sqlite:///{setup_database}", metadata=metadata) + extension = AdvancedAlchemy(config, app) + assert "advanced_alchemy" in app.extensions + session = extension.get_session() + assert isinstance(session, Session) + + +def test_sync_extension_multiple_init(setup_database: Path) -> None: + app = Flask(__name__) + + with app.app_context(), pytest.raises( + ImproperConfigurationError, match="Advanced Alchemy extension is already registered" + ): + config = SQLAlchemySyncConfig(connection_string=f"sqlite:///{setup_database}", metadata=metadata) + extension = AdvancedAlchemy(config, app) + extension.init_app(app) + + +def test_async_extension_init(setup_database: Path) -> None: + app = Flask(__name__) + + with app.app_context(): + config = SQLAlchemyAsyncConfig( + bind_key="async", connection_string=f"sqlite+aiosqlite:///{setup_database}", metadata=metadata + ) + extension = AdvancedAlchemy(config, app) + assert "advanced_alchemy" in app.extensions + session = extension.get_session("async") + assert isinstance(session, AsyncSession) + extension.portal_provider.stop() + + +def test_async_extension_init_single_config_no_bind_key(setup_database: Path) -> None: + app = Flask(__name__) + + with app.app_context(): + config = SQLAlchemyAsyncConfig(connection_string=f"sqlite+aiosqlite:///{setup_database}", metadata=metadata) + extension = AdvancedAlchemy(config, app) + assert "advanced_alchemy" in app.extensions + session = extension.get_session() + assert isinstance(session, AsyncSession) + extension.portal_provider.stop() + + +def test_async_extension_init_with_app(setup_database: Path) -> None: + app = Flask(__name__) + + with app.app_context(): + config = SQLAlchemyAsyncConfig( + bind_key="async", connection_string=f"sqlite+aiosqlite:///{setup_database}", metadata=metadata + ) + extension = AdvancedAlchemy(config, app) + assert "advanced_alchemy" in app.extensions + session = extension.get_session("async") + assert isinstance(session, AsyncSession) + extension.portal_provider.stop() + + +def test_async_extension_multiple_init(setup_database: Path) -> None: + app = Flask(__name__) + + with app.app_context(), pytest.raises( + ImproperConfigurationError, match="Advanced Alchemy extension is already registered" + ): + config = SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", bind_key="async", metadata=metadata + ) + extension = AdvancedAlchemy(config, app) + extension.init_app(app) + + +def test_sync_and_async_extension_init(setup_database: Path) -> None: + app = Flask(__name__) + + with app.app_context(): + extension = AdvancedAlchemy( + [ + SQLAlchemySyncConfig(connection_string=f"sqlite:///{setup_database}"), + SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", bind_key="async", metadata=metadata + ), + ], + app, + ) + assert "advanced_alchemy" in app.extensions + session = extension.get_session() + assert isinstance(session, Session) + + +def test_multiple_binds(setup_database: Path) -> None: + app = Flask(__name__) + + with app.app_context(): + extension = AdvancedAlchemy( + [ + SQLAlchemySyncConfig( + connection_string=f"sqlite:///{setup_database}", bind_key="db1", metadata=metadata + ), + SQLAlchemySyncConfig( + connection_string=f"sqlite:///{setup_database}", bind_key="db2", metadata=metadata + ), + ], + app, + ) + + session = extension.get_session("db1") + assert isinstance(session, Session) + session = extension.get_session("db2") + assert isinstance(session, Session) + + +def test_multiple_binds_async(setup_database: Path) -> None: + app = Flask(__name__) + + with app.app_context(): + configs: Sequence[SQLAlchemyAsyncConfig] = [ + SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", bind_key="db1", metadata=metadata + ), + SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", bind_key="db2", metadata=metadata + ), + ] + extension = AdvancedAlchemy(configs, app) + + session = extension.get_session("db1") + assert isinstance(session, AsyncSession) + session = extension.get_session("db2") + assert isinstance(session, AsyncSession) + extension.portal_provider.stop() + + +def test_mixed_binds(setup_database: Path) -> None: + app = Flask(__name__) + + with app.app_context(): + configs: Sequence[SQLAlchemyAsyncConfig | SQLAlchemySyncConfig] = [ + SQLAlchemySyncConfig(connection_string=f"sqlite:///{setup_database}", bind_key="sync", metadata=metadata), + SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", bind_key="async", metadata=metadata + ), + ] + extension = AdvancedAlchemy(configs, app) + + session = extension.get_session("sync") + assert isinstance(session, Session) + session.close() + session = extension.get_session("async") + assert isinstance(session, AsyncSession) + extension.portal_provider.portal.call(session.close) + extension.portal_provider.stop() + + +def test_sync_autocommit(setup_database: Path) -> None: + app = Flask(__name__) + + with app.test_client() as client: + config = SQLAlchemySyncConfig( + connection_string=f"sqlite:///{setup_database}", commit_mode="autocommit", metadata=metadata + ) + + extension = AdvancedAlchemy(config, app) + + @app.route("/test", methods=["POST"]) + def test_route() -> tuple[dict[str, str], int]: + session = extension.get_session() + assert isinstance(session, Session) + user = User(name="test") + session.add(user) + return {"status": "success"}, 200 + + # Test successful response (should commit) + response = client.post("/test") + assert response.status_code == 200 + + # Verify the data was committed + session = extension.get_session() + assert isinstance(session, Session) + result = session.execute(select(User).where(User.name == "test")) + assert result.scalar_one().name == "test" + + +def test_sync_autocommit_with_redirect(setup_database: Path) -> None: + app = Flask(__name__) + + with app.test_client() as client: + config = SQLAlchemySyncConfig( + connection_string=f"sqlite:///{setup_database}", commit_mode="autocommit_with_redirect", metadata=metadata + ) + + extension = AdvancedAlchemy(config, app) + + @app.route("/test", methods=["POST"]) + def test_route() -> tuple[str, int, dict[str, str]]: + session = extension.get_session() + assert isinstance(session, Session) + session.add(User(name="test_redirect")) + return "", 302, {"Location": "/redirected"} + + # Test redirect response (should commit with AUTOCOMMIT_WITH_REDIRECT) + response = client.post("/test") + assert response.status_code == 302 + + # Verify the data was committed + session = extension.get_session() + assert isinstance(session, Session) + result = session.execute(select(User).where(User.name == "test_redirect")) + assert result.scalar_one().name == "test_redirect" + + +def test_sync_no_autocommit_on_error(setup_database: Path) -> None: + app = Flask(__name__) + + with app.test_client() as client: + config = SQLAlchemySyncConfig( + connection_string=f"sqlite:///{setup_database}", commit_mode="autocommit", metadata=metadata + ) + extension = AdvancedAlchemy(config, app) + + @app.route("/test", methods=["POST"]) + def test_route() -> tuple[dict[str, str], int]: + session = extension.get_session() + assert isinstance(session, Session) + user = User(name="test_error") + session.add(user) + return {"error": "test error"}, 500 + + # Test error response (should not commit) + response = client.post("/test") + assert response.status_code == 500 + + # Verify the data was not committed + session = extension.get_session() + assert isinstance(session, Session) + result = session.execute(select(User).where(User.name == "test_error")) + assert result.first() is None + + +def test_async_autocommit(setup_database: Path) -> None: + app = Flask(__name__) + + with app.test_client() as client: + config = SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", commit_mode="autocommit", metadata=metadata + ) + extension = AdvancedAlchemy(config, app) + + @app.route("/test", methods=["POST"]) + def test_route() -> tuple[dict[str, str], int]: + session = extension.get_session() + assert isinstance(session, AsyncSession) + session.add(User(name="test_async")) + return {"status": "success"}, 200 + + # Test successful response (should commit) + response = client.post("/test") + assert response.status_code == 200 + + # Verify the data was committed + session = extension.get_session() + assert isinstance(session, AsyncSession) + + result = extension.portal_provider.portal.call(session.execute, select(User).where(User.name == "test_async")) + assert result.scalar_one().name == "test_async" + extension.portal_provider.stop() + + +def test_async_autocommit_with_redirect(setup_database: Path) -> None: + app = Flask(__name__) + + with app.test_client() as client: + config = SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", + commit_mode="autocommit_with_redirect", + metadata=metadata, + ) + extension = AdvancedAlchemy(config, app) + + @app.route("/test", methods=["POST"]) + def test_route() -> tuple[str, int, dict[str, str]]: + session = extension.get_session() + assert isinstance(session, AsyncSession) + user = User(name="test_async_redirect") # type: ignore + session.add(user) + return "", 302, {"Location": "/redirected"} + + # Test redirect response (should commit with AUTOCOMMIT_WITH_REDIRECT) + response = client.post("/test") + assert response.status_code == 302 + session = extension.get_session() + assert isinstance(session, AsyncSession) + + result = extension.portal_provider.portal.call( + session.execute, select(User).where(User.name == "test_async_redirect") + ) + assert result.scalar_one().name == "test_async_redirect" + extension.portal_provider.stop() + + +def test_async_no_autocommit_on_error(setup_database: Path) -> None: + app = Flask(__name__) + + with app.test_client() as client: + config = SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", commit_mode="autocommit", metadata=metadata + ) + + extension = AdvancedAlchemy(config, app) + + @app.route("/test", methods=["POST"]) + def test_route() -> tuple[dict[str, str], int]: + session = extension.get_session() + assert isinstance(session, AsyncSession) + user = User(name="test_async_error") # type: ignore + session.add(user) + return {"error": "test async error"}, 500 + + # Test error response (should not commit) + response = client.post("/test") + assert response.status_code == 500 + + session = extension.get_session() + assert isinstance(session, AsyncSession) + + async def get_user() -> User | None: + result = await session.execute(select(User).where(User.name == "test_async_error")) + return result.scalar_one_or_none() + + # Verify the data was not committed + user = extension.portal_provider.portal.call(get_user) + assert user is None + extension.portal_provider.stop() + + +def test_async_portal_cleanup(setup_database: Path) -> None: + app = Flask(__name__) + + with app.test_client() as client: + config = SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", commit_mode="manual", metadata=metadata + ) + extension = AdvancedAlchemy(config, app) + + @app.route("/test", methods=["POST"]) + def test_route() -> tuple[dict[str, str], int]: + session = extension.get_session() + assert isinstance(session, AsyncSession) + user = User(name="test_async_cleanup") # type: ignore + session.add(user) + return {"status": "success"}, 200 + + # Test successful response (should not commit since we're using MANUAL mode) + response = client.post("/test") + assert response.status_code == 200 + session = extension.get_session() + assert isinstance(session, AsyncSession) + + # Verify the data was not committed (MANUAL mode) + result = extension.portal_provider.portal.call( + session.execute, select(User).where(User.name == "test_async_cleanup") + ) + assert result.first() is None + extension.portal_provider.stop() + + +def test_async_portal_explicit_stop(setup_database: Path) -> None: + app = Flask(__name__) + + with app.test_client() as client: + config = SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", + metadata=metadata, + commit_mode="manual", + ) + extension = AdvancedAlchemy(config, app) + + @app.route("/test", methods=["POST"]) + def test_route() -> tuple[dict[str, str], int]: + session = extension.get_session() + assert isinstance(session, AsyncSession) + user = User(name="test_async_explicit_stop") # type: ignore + session.add(user) + return {"status": "success"}, 200 + + # Test successful response (should not commit since we're using MANUAL mode) + response = client.post("/test") + assert response.status_code == 200 + + with app.app_context(): + session = extension.get_session() + assert isinstance(session, AsyncSession) + + # Verify the data was not committed (MANUAL mode) + result = extension.portal_provider.portal.call( + session.scalar, select(User).where(User.name == "test_async_explicit_stop") + ) + assert result is None + extension.portal_provider.stop() + + +def test_async_portal_explicit_stop_with_commit(setup_database: Path) -> None: + app = Flask(__name__) + + @app.route("/test", methods=["POST"]) + def test_route() -> tuple[dict[str, str], int]: + session = extension.get_session() + assert isinstance(session, AsyncSession) + + async def create_user() -> None: + user = User(name="test_async_explicit_stop_with_commit") # type: ignore + session.add(user) + await session.commit() # type: ignore + + extension.portal_provider.portal.call(create_user) + return {"status": "success"}, 200 + + with app.test_client() as client: + config = SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", + metadata=metadata, + commit_mode="manual", + ) + extension = AdvancedAlchemy(config, app) + + # Test successful response + response = client.post("/test") + assert response.status_code == 200 + + # Verify in a new session + session = extension.get_session() + assert isinstance(session, AsyncSession) + + async def get_user() -> User | None: + async with session: + result = await session.execute(select(User).where(User.name == "test_async_explicit_stop_with_commit")) + return result.scalar_one_or_none() + + user = extension.portal_provider.portal.call(get_user) + assert isinstance(user, User) + assert user.name == "test_async_explicit_stop_with_commit" + extension.portal_provider.stop() + + +def test_sync_service_jsonify(setup_database: Path) -> None: + app = Flask(__name__) + + with app.test_client() as client: + config = SQLAlchemySyncConfig( + connection_string=f"sqlite:///{setup_database}", metadata=metadata, commit_mode="autocommit" + ) + + extension = AdvancedAlchemy(config, app) + + @app.route("/test", methods=["POST"]) + def test_route() -> Response: + service = UserService(extension.get_sync_session()) + user = service.create({"name": "service_test"}) + return service.jsonify(service.to_schema(user, schema_type=UserSchema)) + + # Test successful response (should commit) + response = client.post("/test") + assert response.status_code == 200 + + # Verify the data was committed + session = extension.get_session() + assert isinstance(session, Session) + result = session.execute(select(User).where(User.name == "service_test")) + assert result.scalar_one().name == "service_test" + + +def test_async_service_jsonify(setup_database: Path) -> None: + app = Flask(__name__) + + with app.test_client() as client: + config = SQLAlchemyAsyncConfig( + connection_string=f"sqlite+aiosqlite:///{setup_database}", metadata=metadata, commit_mode="autocommit" + ) + extension = AdvancedAlchemy(config, app) + + @app.route("/test", methods=["POST"]) + def test_route() -> Response: + service = AsyncUserService(extension.get_async_session()) + user = extension.portal_provider.portal.call(service.create, {"name": "async_service_test"}) + return service.jsonify(service.to_schema(user, schema_type=UserSchema)) + + # Test successful response (should commit) + response = client.post("/test") + assert response.status_code == 200 + + # Verify the data was committed + session = extension.get_session() + assert isinstance(session, AsyncSession) + result = extension.portal_provider.portal.call( + session.scalar, select(User).where(User.name == "async_service_test") + ) + assert result + assert result.name == "async_service_test" + extension.portal_provider.stop() diff --git a/tests/unit/test_extensions/test_litestar/test_dto.py b/tests/unit/test_extensions/test_litestar/test_dto.py index a3a35158..dfccb5aa 100644 --- a/tests/unit/test_extensions/test_litestar/test_dto.py +++ b/tests/unit/test_extensions/test_litestar/test_dto.py @@ -8,10 +8,10 @@ import pytest import sqlalchemy from litestar import Request, get -from litestar.contrib.pydantic import PydanticInitPlugin from litestar.dto import DTOField, Mark from litestar.dto.field import DTO_FIELD_META_KEY from litestar.enums import MediaType +from litestar.plugins.pydantic import PydanticInitPlugin from litestar.serialization import encode_json from litestar.testing import RequestFactory from litestar.typing import FieldDefinition @@ -197,7 +197,7 @@ async def test_dto_instrumented_attribute_key( class Model(base): # type: ignore field: Mapped[UUID] = mapped_column(default=lambda: val) - dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig(exclude={Model.id, Model.created, Model.updated})]] # pyright: ignore[reportAttributeAccessIssue] + dto_type = SQLAlchemyDTO[Annotated[Model, SQLAlchemyDTOConfig(exclude={Model.id, Model.created, Model.updated})]] # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownArgumentType] model = await get_model_from_dto(dto_type, Model, asgi_connection, b'{"a":"b"}') assert_model_values(model, {"field": val}) diff --git a/tests/unit/test_extensions/test_litestar/test_dto_integration.py b/tests/unit/test_extensions/test_litestar/test_dto_integration.py index fe94c383..19ac921e 100644 --- a/tests/unit/test_extensions/test_litestar/test_dto_integration.py +++ b/tests/unit/test_extensions/test_litestar/test_dto_integration.py @@ -31,7 +31,7 @@ class Base(DeclarativeBase): - id: Mapped[str] = mapped_column(primary_key=True, default=UUID) # pyright: ignore + id: Mapped[str] = mapped_column(primary_key=True, default=UUID) # noinspection PyMethodParameters @declared_attr.directive @@ -41,7 +41,7 @@ def __tablename__(cls) -> str: class Tag(Base): - name: Mapped[str] = mapped_column(default="best seller") # pyright: ignore + name: Mapped[str] = mapped_column(default="best seller") class TaggableMixin: @@ -51,8 +51,8 @@ def tag_association_table(cls) -> Table: return Table( f"{cls.__tablename__}_tag_association", # type: ignore cls.metadata, # type: ignore - Column("base_id", ForeignKey(f"{cls.__tablename__}.id", ondelete="CASCADE"), primary_key=True), # pyright: ignore # type: ignore - Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True), # pyright: ignore # type: ignore + Column("base_id", ForeignKey(f"{cls.__tablename__}.id", ondelete="CASCADE"), primary_key=True), # type: ignore + Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True), # type: ignore ) @declared_attr @@ -70,29 +70,29 @@ def tags(cls) -> AssociationProxy[List[str]]: return association_proxy( "assigned_tags", "name", - creator=lambda name: Tag(name=name), # pyright: ignore + creator=lambda name: Tag(name=name), # pyright: ignore[reportUnknownArgumentType,reportUnknownLambdaType] info={"__dto__": DTOField()}, ) class Author(Base): - name: Mapped[str] = mapped_column(default="Arthur") # pyright: ignore - date_of_birth: Mapped[str] = mapped_column(nullable=True) # pyright: ignore + name: Mapped[str] = mapped_column(default="Arthur") + date_of_birth: Mapped[str] = mapped_column(nullable=True) class BookReview(Base): - review: Mapped[str] # pyright: ignore - book_id: Mapped[str] = mapped_column(ForeignKey("book.id"), default="000") # pyright: ignore + review: Mapped[str] + book_id: Mapped[str] = mapped_column(ForeignKey("book.id"), default="000") class Book(Base): - title: Mapped[str] = mapped_column(String(length=250), default="Hi") # pyright: ignore - author_id: Mapped[str] = mapped_column(ForeignKey("author.id"), default="123") # pyright: ignore - first_author: Mapped[Author] = relationship(lazy="joined", innerjoin=True) # pyright: ignore - reviews: Mapped[List[BookReview]] = relationship(lazy="joined", innerjoin=True) # pyright: ignore - bar: Mapped[str] = mapped_column(default="Hello") # pyright: ignore - SPAM: Mapped[str] = mapped_column(default="Bye") # pyright: ignore - spam_bar: Mapped[str] = mapped_column(default="Goodbye") # pyright: ignore + title: Mapped[str] = mapped_column(String(length=250), default="Hi") + author_id: Mapped[str] = mapped_column(ForeignKey("author.id"), default="123") + first_author: Mapped[Author] = relationship(lazy="joined", innerjoin=True) + reviews: Mapped[List[BookReview]] = relationship(lazy="joined", innerjoin=True) + bar: Mapped[str] = mapped_column(default="Hello") + SPAM: Mapped[str] = mapped_column(default="Bye") + spam_bar: Mapped[str] = mapped_column(default="Goodbye") number_of_reviews: Mapped[Optional[int]] = column_property( # noqa: UP007 select(func.count(BookReview.id)).where(BookReview.book_id == id).scalar_subquery(), # type: ignore ) @@ -236,7 +236,7 @@ def test_dto_with_association_proxy(create_module: Callable[[str], ModuleType]) """ from __future__ import annotations -from typing import Dict, List, Set, Tuple, Type, Final, List +from typing import Dict, List, Set, Tuple, Type, Final, List, Generator from sqlalchemy import Column from sqlalchemy import ForeignKey @@ -568,7 +568,7 @@ class ModelCreateDTO(SQLAlchemyDTO[Model]): ModelReturnDTO = SQLAlchemyDTO[Model] -@post("/", dto=ModelCreateDTO, return_dto=ModelReturnDTO, sync_to_thread=False) +@post("/", dto=ModelCreateDTO, return_dto=ModelReturnDTO) def post_handler(data: Model) -> Model: Base.metadata.create_all(engine) @@ -758,7 +758,7 @@ def provide_service( ) -> Generator[ModelService, None, None]: Model.metadata.drop_all(engine) -@post("/", dependencies={"service": Provide(provide_service, sync_to_thread=False)}, dto=ModelCreateDTO, return_dto=ModelReturnDTO, sync_to_thread=False) +@post("/", dependencies={"service": Provide(provide_service)}, dto=ModelCreateDTO, return_dto=ModelReturnDTO) def post_handler(data: DTOData[Model], service: ModelService) -> Model: return service.create(data, auto_commit=True) @@ -809,7 +809,7 @@ async def provide_service( ) -> AsyncGenerator[ModelService, None]: async with engine.begin() as conn: await conn.run_sync(AModel.metadata.create_all) -@post("/", dependencies={"service": Provide(provide_service, sync_to_thread=False)}, dto=ModelCreateDTO, return_dto=ModelReturnDTO, sync_to_thread=False) +@post("/", dependencies={"service": Provide(provide_service)}, dto=ModelCreateDTO, return_dto=ModelReturnDTO) async def post_handler(data: DTOData[AModel], service: ModelService) -> AModel: return await service.create(data, auto_commit=True) @@ -864,7 +864,7 @@ def provide_service( ) -> Generator[ModelService, None, None]: yield service Model.metadata.drop_all(engine) -@post("/", dependencies={"service": Provide(provide_service, sync_to_thread=False)}, dto=ModelCreateDTO, return_dto=ModelReturnDTO, sync_to_thread=False) +@post("/", dependencies={"service": Provide(provide_service)}, dto=ModelCreateDTO, return_dto=ModelReturnDTO) def post_handler(data: DTOData[Model], service: ModelService) -> Model: return service.create(data, auto_commit=True) diff --git a/tests/unit/test_extensions/test_litestar/test_litestar_re_exports.py b/tests/unit/test_extensions/test_litestar/test_litestar_re_exports.py index 2623e2ee..40ec7dfb 100644 --- a/tests/unit/test_extensions/test_litestar/test_litestar_re_exports.py +++ b/tests/unit/test_extensions/test_litestar/test_litestar_re_exports.py @@ -1,10 +1,13 @@ # ruff: noqa: F401 +import pytest + def test_repository_re_exports() -> None: - from litestar.contrib.sqlalchemy import types # type: ignore - from litestar.contrib.sqlalchemy.repository import ( - SQLAlchemyAsyncRepository, # type: ignore - SQLAlchemySyncRepository, # type: ignore - wrap_sqlalchemy_exception, # type: ignore - ) + with pytest.warns(DeprecationWarning): + from litestar.contrib.sqlalchemy import types # type: ignore + from litestar.contrib.sqlalchemy.repository import ( + SQLAlchemyAsyncRepository, # type: ignore + SQLAlchemySyncRepository, # type: ignore + wrap_sqlalchemy_exception, # type: ignore + ) diff --git a/tests/unit/test_repository.py b/tests/unit/test_repository.py index 9e6b6388..633d9fdf 100644 --- a/tests/unit/test_repository.py +++ b/tests/unit/test_repository.py @@ -4,7 +4,7 @@ import datetime from collections.abc import Collection -from typing import TYPE_CHECKING, Any, Generator, Union, cast +from typing import TYPE_CHECKING, Any, AsyncGenerator, Generator, Union, cast from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -64,7 +64,7 @@ class BigIntModel(base.BigIntAuditBase): @pytest.fixture() -def async_mock_repo() -> Generator[SQLAlchemyAsyncRepository[MagicMock], None, None]: +async def async_mock_repo() -> AsyncGenerator[SQLAlchemyAsyncRepository[MagicMock], None]: """SQLAlchemy repository with a mock model type.""" class Repo(SQLAlchemyAsyncRepository[MagicMock]): @@ -72,7 +72,8 @@ class Repo(SQLAlchemyAsyncRepository[MagicMock]): model_type = MagicMock(__name__="MagicMock") # pyright:ignore[reportGeneralTypeIssues,reportAssignmentType] - yield Repo(session=AsyncMock(spec=AsyncSession, bind=MagicMock()), statement=MagicMock()) + session = AsyncMock(spec=AsyncSession, bind=MagicMock()) + yield Repo(session=session, statement=MagicMock()) @pytest.fixture() @@ -93,42 +94,42 @@ def mock_repo(request: FixtureRequest) -> Generator[SQLAlchemyAsyncRepository[Ma @pytest.fixture() -def mock_session_scalars( +def mock_session_scalars( # pyright: ignore[reportUnknownParameterType] mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture ) -> Generator[AnyMock, None, None]: yield mocker.patch.object(mock_repo.session, "scalars") @pytest.fixture() -def mock_session_execute( +def mock_session_execute( # pyright: ignore[reportUnknownParameterType] mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture ) -> Generator[AnyMock, None, None]: yield mocker.patch.object(mock_repo.session, "scalars") @pytest.fixture() -def mock_repo_list( +def mock_repo_list( # pyright: ignore[reportUnknownParameterType] mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture ) -> Generator[AnyMock, None, None]: yield mocker.patch.object(mock_repo, "list") @pytest.fixture() -def mock_repo_execute( +def mock_repo_execute( # pyright: ignore[reportUnknownParameterType] mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture ) -> Generator[AnyMock, None, None]: yield mocker.patch.object(mock_repo, "_execute") @pytest.fixture() -def mock_repo_attach_to_session( +def mock_repo_attach_to_session( # pyright: ignore[reportUnknownParameterType] mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture ) -> Generator[AnyMock, None, None]: yield mocker.patch.object(mock_repo, "_attach_to_session") @pytest.fixture() -def mock_repo_count( +def mock_repo_count( # pyright: ignore[reportUnknownParameterType] mock_repo: SQLAlchemyAsyncRepository[MagicMock], mocker: MockerFixture ) -> Generator[AnyMock, None, None]: yield mocker.patch.object(mock_repo, "count") @@ -809,7 +810,7 @@ async def test_execute(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None: mock_repo.session.execute.assert_called_once_with(mock_repo.statement) # pyright: ignore[reportFunctionMemberAccess] -def test_filter_in_collection_noop_if_collection_empty(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None: +async def test_filter_in_collection_noop_if_collection_empty(mock_repo: SQLAlchemyAsyncRepository[Any]) -> None: """Ensures we don't filter on an empty collection.""" statement = MagicMock() filter = CollectionFilter(field_name="id", values=[]) # type:ignore[var-annotated] @@ -825,7 +826,7 @@ def test_filter_in_collection_noop_if_collection_empty(mock_repo: SQLAlchemyAsyn (datetime.datetime.max, None), ], ) -def test_filter_on_datetime_field( +async def test_filter_on_datetime_field( before: datetime.datetime, after: datetime.datetime, mock_repo: SQLAlchemyAsyncRepository[Any], diff --git a/tests/unit/test_utils/test_portals.py b/tests/unit/test_utils/test_portals.py new file mode 100644 index 00000000..74eea13c --- /dev/null +++ b/tests/unit/test_utils/test_portals.py @@ -0,0 +1,58 @@ +import asyncio +from typing import Any, Callable, Coroutine + +import pytest + +from advanced_alchemy.utils.portals import Portal, PortalProvider + + +@pytest.fixture +async def async_function() -> Callable[[int], Coroutine[Any, Any, int]]: + async def sample_async_function(x: int) -> int: + await asyncio.sleep(0.1) + return x * 2 + + return sample_async_function + + +def test_portal_provider_singleton() -> None: + provider1 = PortalProvider() + provider2 = PortalProvider() + assert provider1 is provider2, "PortalProvider is not a singleton" + + +def test_portal_provider_start_stop() -> None: + provider = PortalProvider() + provider.start() + assert provider.is_running, "Provider should be running after start()" + assert provider.is_ready, "Provider should be ready after start()" + provider.stop() + assert not provider.is_running, "Provider should not be running after stop()" + + +def test_portal_provider_call(async_function: Callable[[int], Coroutine[Any, Any, int]]) -> None: + provider = PortalProvider() + provider.start() + result = provider.call(async_function, 5) + assert result == 10, "The result of the async function should be 10" + provider.stop() + + +def test_portal_provider_call_exception() -> None: + async def faulty_async_function() -> None: + raise ValueError("Intentional error") + + provider = PortalProvider() + provider.start() + with pytest.raises(ValueError, match="Intentional error"): + provider.call(faulty_async_function) + provider.stop() + + +def test_portal_call(async_function: Callable[[int], Coroutine[Any, Any, int]]) -> None: + provider = PortalProvider() + portal = Portal(provider) + provider.start() + result = portal.call(async_function, 3) + assert result == 6, "The result of the async function should be 6" + provider.stop() diff --git a/uv.lock b/uv.lock index f05b136f..ff610059 100644 --- a/uv.lock +++ b/uv.lock @@ -58,6 +58,8 @@ dev = [ { name = "duckdb" }, { name = "duckdb-engine" }, { name = "fastapi", extra = ["all"] }, + { name = "flask", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, extra = ["async"], marker = "python_full_version < '3.9'" }, + { name = "flask", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, extra = ["async"], marker = "python_full_version >= '3.9'" }, { name = "flask-sqlalchemy" }, { name = "git-cliff" }, { name = "litestar", extra = ["cli"] }, @@ -70,7 +72,7 @@ dev = [ { name = "psycopg", extra = ["binary", "pool"] }, { name = "psycopg2-binary" }, { name = "pydantic-extra-types", version = "2.8.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pydantic-extra-types", version = "2.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pydantic-extra-types", version = "2.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pyodbc" }, { name = "pyright" }, { name = "pytest" }, @@ -82,6 +84,8 @@ dev = [ { name = "pytest-databases" }, { name = "pytest-lazy-fixtures" }, { name = "pytest-mock" }, + { name = "pytest-rerunfailures", version = "14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest-rerunfailures", version = "15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-sugar" }, { name = "pytest-xdist" }, { name = "pytz" }, @@ -100,7 +104,7 @@ dev = [ { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "sphinx-autodoc-typehints", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "sphinx-autodoc-typehints", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "sphinx-autodoc-typehints", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "sphinx-click" }, { name = "sphinx-copybutton" }, { name = "sphinx-design", version = "0.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, @@ -139,7 +143,7 @@ doc = [ { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "sphinx-autodoc-typehints", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "sphinx-autodoc-typehints", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "sphinx-autodoc-typehints", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "sphinx-click" }, { name = "sphinx-copybutton" }, { name = "sphinx-design", version = "0.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, @@ -159,6 +163,8 @@ fastapi = [ { name = "starlette" }, ] flask = [ + { name = "flask", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, extra = ["async"], marker = "python_full_version < '3.9'" }, + { name = "flask", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, extra = ["async"], marker = "python_full_version >= '3.9'" }, { name = "flask-sqlalchemy" }, ] lint = [ @@ -217,7 +223,7 @@ test = [ { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "coverage", version = "7.6.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pydantic-extra-types", version = "2.8.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pydantic-extra-types", version = "2.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pydantic-extra-types", version = "2.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest" }, { name = "pytest-asyncio", version = "0.24.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-asyncio", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, @@ -227,6 +233,8 @@ test = [ { name = "pytest-databases" }, { name = "pytest-lazy-fixtures" }, { name = "pytest-mock" }, + { name = "pytest-rerunfailures", version = "14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest-rerunfailures", version = "15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-sugar" }, { name = "pytest-xdist" }, { name = "rich-click" }, @@ -268,6 +276,7 @@ dev = [ { name = "duckdb", specifier = ">=1.1.2" }, { name = "duckdb-engine", specifier = ">=0.13.4" }, { name = "fastapi", extras = ["all"], specifier = ">=0.115.3" }, + { name = "flask", extras = ["async"] }, { name = "flask-sqlalchemy", specifier = ">=3.1.1" }, { name = "git-cliff", specifier = ">=2.6.1" }, { name = "litestar", extras = ["cli"], specifier = ">=2.12.1" }, @@ -285,9 +294,10 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.23.8" }, { name = "pytest-click" }, { name = "pytest-cov", specifier = ">=5.0.0" }, - { name = "pytest-databases", specifier = ">=0.10.0" }, + { name = "pytest-databases" }, { name = "pytest-lazy-fixtures", specifier = ">=1.1.1" }, { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "pytest-rerunfailures" }, { name = "pytest-sugar", specifier = ">=1.0.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "pytz", specifier = ">=2024.2" }, @@ -354,7 +364,10 @@ fastapi = [ { name = "starlette", marker = "python_full_version < '3.9'", specifier = "<0.45.0" }, { name = "starlette", marker = "python_full_version >= '3.9'" }, ] -flask = [{ name = "flask-sqlalchemy", specifier = ">=3.1.1" }] +flask = [ + { name = "flask", extras = ["async"] }, + { name = "flask-sqlalchemy", specifier = ">=3.1.1" }, +] lint = [ { name = "asyncpg-stubs" }, { name = "mypy", specifier = ">=1.13.0" }, @@ -404,9 +417,10 @@ test = [ { name = "pytest-asyncio", specifier = ">=0.23.8" }, { name = "pytest-click" }, { name = "pytest-cov", specifier = ">=5.0.0" }, - { name = "pytest-databases", specifier = ">=0.10.0" }, + { name = "pytest-databases" }, { name = "pytest-lazy-fixtures", specifier = ">=1.1.1" }, { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "pytest-rerunfailures" }, { name = "pytest-sugar", specifier = ">=1.0.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "rich-click" }, @@ -593,6 +607,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895 }, ] +[[package]] +name = "asgiref" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -1461,16 +1487,16 @@ wheels = [ [[package]] name = "duckdb-engine" -version = "0.14.2" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "duckdb" }, { name = "packaging" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/72/23bd2f8888a835f8f658db0964e492a9f3581c7dc7beff6d84271c7dabb6/duckdb_engine-0.14.2.tar.gz", hash = "sha256:a1e068d8c61d3368405a35740e652b236ae23e98fbbfbe02093855586e07caaf", size = 47657 } +sdist = { url = "https://files.pythonhosted.org/packages/ac/2d/ea892e63f8b372a3aa48caa2de9eed8e4c731275b9715a1334be6b784329/duckdb_engine-0.15.0.tar.gz", hash = "sha256:59f67ec95ebf9eb4dea22994664dfd34edce3c7416b862daa46da43f572ad6ef", size = 47695 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/d9/8a6971d3dd283b84c24e0738c69fb81358c1f2305d422f61ce070ad5c792/duckdb_engine-0.14.2-py3-none-any.whl", hash = "sha256:9c0634e53b91a2b43bbd74b06f275eca80acd7ad02ebb0193ef6d9a22ed29b49", size = 49571 }, + { url = "https://files.pythonhosted.org/packages/0c/92/a3b7edba792772f364ad6c57ceb8685fb5ae5f893704650f2b46978f9b34/duckdb_engine-0.15.0-py3-none-any.whl", hash = "sha256:d18acd73f03202145e1baa86605dca3612080fd0a849dbc42b38111ffee6857c", size = 49634 }, ] [[package]] @@ -1559,7 +1585,7 @@ all = [ { name = "jinja2" }, { name = "orjson" }, { name = "pydantic-extra-types", version = "2.8.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pydantic-extra-types", version = "2.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pydantic-extra-types", version = "2.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pydantic-settings" }, { name = "python-multipart" }, { name = "pyyaml" }, @@ -1721,6 +1747,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/80/ffe1da13ad9300f87c93af113edd0638c75138c42a0994becfacac078c06/flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3", size = 101735 }, ] +[package.optional-dependencies] +async = [ + { name = "asgiref", marker = "python_full_version < '3.9'" }, +] + [[package]] name = "flask" version = "3.1.0" @@ -1744,6 +1775,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979 }, ] +[package.optional-dependencies] +async = [ + { name = "asgiref", marker = "python_full_version >= '3.9'" }, +] + [[package]] name = "flask-sqlalchemy" version = "3.1.1" @@ -3171,16 +3207,16 @@ wheels = [ [[package]] name = "psycopg" -version = "3.2.3" +version = "3.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-zoneinfo", marker = "python_full_version < '3.9'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/ad/7ce016ae63e231575df0498d2395d15f005f05e32d3a2d439038e1bd0851/psycopg-3.2.3.tar.gz", hash = "sha256:a5764f67c27bec8bfac85764d23c534af2c27b893550377e37ce59c12aac47a2", size = 155550 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/f2/954b1467b3e2ca5945b83b5e320268be1f4df486c3e8ffc90f4e4b707979/psycopg-3.2.4.tar.gz", hash = "sha256:f26f1346d6bf1ef5f5ef1714dd405c67fb365cfd1c6cea07de1792747b167b92", size = 156109 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/21/534b8f5bd9734b7a2fcd3a16b1ee82ef6cad81a4796e95ebf4e0c6a24119/psycopg-3.2.3-py3-none-any.whl", hash = "sha256:644d3973fe26908c73d4be746074f6e5224b03c1101d302d9a53bf565ad64907", size = 197934 }, + { url = "https://files.pythonhosted.org/packages/40/49/15114d5f7ee68983f4e1a24d47e75334568960352a07c6f0e796e912685d/psycopg-3.2.4-py3-none-any.whl", hash = "sha256:43665368ccd48180744cab26b74332f46b63b7e06e8ce0775547a3533883d381", size = 198716 }, ] [package.optional-dependencies] @@ -3193,73 +3229,74 @@ pool = [ [[package]] name = "psycopg-binary" -version = "3.2.3" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/1c/1fc9d53844c15059b98b27d7037a8af87e43832e367c88c8ee43b8bb650f/psycopg_binary-3.2.3-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:965455eac8547f32b3181d5ec9ad8b9be500c10fe06193543efaaebe3e4ce70c", size = 3383146 }, - { url = "https://files.pythonhosted.org/packages/fb/80/0d0eca43756578738a14f747b3d27e8e22ba468765071eaf61cd517c52a3/psycopg_binary-3.2.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:71adcc8bc80a65b776510bc39992edf942ace35b153ed7a9c6c573a6849ce308", size = 3504185 }, - { url = "https://files.pythonhosted.org/packages/7c/02/1db86752a2a663cf59d410374e9aced220d1a883a64b7256ed1171685a27/psycopg_binary-3.2.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f73adc05452fb85e7a12ed3f69c81540a8875960739082e6ea5e28c373a30774", size = 4469268 }, - { url = "https://files.pythonhosted.org/packages/59/04/b8cbc84f494247fa887dcc5cba15f99d261dc44b94fbb10fdaa44c4d6dac/psycopg_binary-3.2.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8630943143c6d6ca9aefc88bbe5e76c90553f4e1a3b2dc339e67dc34aa86f7e", size = 4270625 }, - { url = "https://files.pythonhosted.org/packages/74/94/851a58aeab1e2aa30a564133f84229242b2fc774eabb3fc5c164b2423dcd/psycopg_binary-3.2.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bffb61e198a91f712cc3d7f2d176a697cb05b284b2ad150fb8edb308eba9002", size = 4515573 }, - { url = "https://files.pythonhosted.org/packages/5a/95/e3e600687e59df7d5214e81d9aa2d324f2c5dece32068d66b03a4fd6edf6/psycopg_binary-3.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc4fa2240c9fceddaa815a58f29212826fafe43ce80ff666d38c4a03fb036955", size = 4214078 }, - { url = "https://files.pythonhosted.org/packages/2e/1e/4b50e1a2c35a7ee1fc65f8a5fed36026c16b05c9549dc4247914dfbfa2f5/psycopg_binary-3.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:192a5f8496e6e1243fdd9ac20e117e667c0712f148c5f9343483b84435854c78", size = 3139319 }, - { url = "https://files.pythonhosted.org/packages/a0/bb/fc88304a7b759d87ad79f538f1b605c23802f36963d207b6e8e9062a57bd/psycopg_binary-3.2.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64dc6e9ec64f592f19dc01a784e87267a64a743d34f68488924251253da3c818", size = 3118977 }, - { url = "https://files.pythonhosted.org/packages/92/19/88e14b615291b472b616bb3078206eac63dd6cb806c79b12119b7c39e519/psycopg_binary-3.2.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:79498df398970abcee3d326edd1d4655de7d77aa9aecd578154f8af35ce7bbd2", size = 3224533 }, - { url = "https://files.pythonhosted.org/packages/98/cd/6cedff641f1ffb7008b6c511233814d2934df8caf2ec93c50412c37e5f91/psycopg_binary-3.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:949551752930d5e478817e0b49956350d866b26578ced0042a61967e3fcccdea", size = 3258089 }, - { url = "https://files.pythonhosted.org/packages/31/2c/8059fbcd513d4b7c9e25dd93c438ab174e8ce389b85d8432b4ce3c0e8958/psycopg_binary-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:80a2337e2dfb26950894c8301358961430a0304f7bfe729d34cc036474e9c9b1", size = 2921689 }, - { url = "https://files.pythonhosted.org/packages/3d/78/8e8b4063b5cd1cc91cc100fc3e9296b96f52c9a709750b24ade6cfa8021b/psycopg_binary-3.2.3-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:6d8f2144e0d5808c2e2aed40fbebe13869cd00c2ae745aca4b3b16a435edb056", size = 3391535 }, - { url = "https://files.pythonhosted.org/packages/36/7f/04eed0c415d158a0fb1c196957b9c7faec43c7b50d20db05c62e5bd22c93/psycopg_binary-3.2.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:94253be2b57ef2fea7ffe08996067aabf56a1eb9648342c9e3bad9e10c46e045", size = 3509175 }, - { url = "https://files.pythonhosted.org/packages/0d/91/042fe504220a6e1a423e6a26d24f198da976b9cce11bc9ab7e9415bac08f/psycopg_binary-3.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fda0162b0dbfa5eaed6cdc708179fa27e148cb8490c7d62e5cf30713909658ea", size = 4465647 }, - { url = "https://files.pythonhosted.org/packages/35/7c/4cf02ee263431b306453b7b086ec8e91dcbd5008382d711e82afa829f73e/psycopg_binary-3.2.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c0419cdad8c70eaeb3116bb28e7b42d546f91baf5179d7556f230d40942dc78", size = 4267051 }, - { url = "https://files.pythonhosted.org/packages/f5/9b/cea713d8d75621481ece2dfc7edae6e4f05dfbcaab28fac0dbff9b96fc3a/psycopg_binary-3.2.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74fbf5dd3ef09beafd3557631e282f00f8af4e7a78fbfce8ab06d9cd5a789aae", size = 4517398 }, - { url = "https://files.pythonhosted.org/packages/56/65/cd4165c45359f4117147b861c16c7b85afbd93cc9efac6116b13f62bc725/psycopg_binary-3.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d784f614e4d53050cbe8abf2ae9d1aaacf8ed31ce57b42ce3bf2a48a66c3a5c", size = 4210644 }, - { url = "https://files.pythonhosted.org/packages/f3/80/14e7bf67613c4344e74fe6ac5c9876a7acb4ddc15e5455c54e24cdc087f8/psycopg_binary-3.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e76ce2475ed4885fe13b8254058be710ec0de74ebd8ef8224cf44a9a3358e5f", size = 3138032 }, - { url = "https://files.pythonhosted.org/packages/7e/81/e18c36de78e0f7a491a754dc74c1bb6b16469d8c240b2add1e856801d567/psycopg_binary-3.2.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5938b257b04c851c2d1e6cb2f8c18318f06017f35be9a5fe761ee1e2e344dfb7", size = 3114329 }, - { url = "https://files.pythonhosted.org/packages/48/39/07b0bf8355cb535ccdd58261a18fb6e786e175492363f5255b446fff6427/psycopg_binary-3.2.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:257c4aea6f70a9aef39b2a77d0658a41bf05c243e2bf41895eb02220ac6306f3", size = 3219579 }, - { url = "https://files.pythonhosted.org/packages/64/ea/92c700989b5bdeb8e8e59732191547e32da732692d6c016830c82f9b4ac7/psycopg_binary-3.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:06b5cc915e57621eebf2393f4173793ed7e3387295f07fed93ed3fb6a6ccf585", size = 3257145 }, - { url = "https://files.pythonhosted.org/packages/84/49/39f0875fd32a6d77cd22b44887df39eb470039b389c388cee4ba75c0bda7/psycopg_binary-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:09baa041856b35598d335b1a74e19a49da8500acedf78164600694c0ba8ce21b", size = 2924948 }, - { url = "https://files.pythonhosted.org/packages/55/6b/9805a5c743c1d54dcd035bd5c069202fde21b4cf69857ca40c2a55e69f8c/psycopg_binary-3.2.3-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:48f8ca6ee8939bab760225b2ab82934d54330eec10afe4394a92d3f2a0c37dd6", size = 3363376 }, - { url = "https://files.pythonhosted.org/packages/a8/82/45ac156b20e08e8f556a323c9568a011c71cf6e734e49667a398719ce0e4/psycopg_binary-3.2.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5361ea13c241d4f0ec3f95e0bf976c15e2e451e9cc7ef2e5ccfc9d170b197a40", size = 3506449 }, - { url = "https://files.pythonhosted.org/packages/e4/be/760cef50e1adfbc87dab2b05b30f544d7297040cce495835df9016556517/psycopg_binary-3.2.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb987f14af7da7c24f803111dbc7392f5070fd350146af3345103f76ea82e339", size = 4445757 }, - { url = "https://files.pythonhosted.org/packages/b4/9c/bae6a9c6949aac577cc93f58705f649b50c62827038903bd75ff8956e63e/psycopg_binary-3.2.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0463a11b1cace5a6aeffaf167920707b912b8986a9c7920341c75e3686277920", size = 4248376 }, - { url = "https://files.pythonhosted.org/packages/e5/0e/9db06ef94e4a156f3ed06043ee4f370e21866b0e3b7959691c8c4abfb698/psycopg_binary-3.2.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b7be9a6c06518967b641fb15032b1ed682fd3b0443f64078899c61034a0bca6", size = 4487765 }, - { url = "https://files.pythonhosted.org/packages/9f/5f/8afc32b60ee8bc5c4af51e7cf6c42d93a989a09609524d0a393106e300cd/psycopg_binary-3.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64a607e630d9f4b2797f641884e52b9f8e239d35943f51bef817a384ec1678fe", size = 4188374 }, - { url = "https://files.pythonhosted.org/packages/ed/5d/210cb75aff0296dc5c09bcf67babf8679905412d7a11357b983f0d877360/psycopg_binary-3.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fa33ead69ed133210d96af0c63448b1385df48b9c0247eda735c5896b9e6dbbf", size = 3113180 }, - { url = "https://files.pythonhosted.org/packages/40/ec/46b1a5cdb2fe995b8ec0376f0695003e97fed9ac077e090a3165ea15f735/psycopg_binary-3.2.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1f8b0d0e99d8e19923e6e07379fa00570be5182c201a8c0b5aaa9a4d4a4ea20b", size = 3099455 }, - { url = "https://files.pythonhosted.org/packages/11/68/eaf85b3421b3f01b638dd6b16f4e9bc8de42eb1d000da62964fb29f8c823/psycopg_binary-3.2.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:709447bd7203b0b2debab1acec23123eb80b386f6c29e7604a5d4326a11e5bd6", size = 3189977 }, - { url = "https://files.pythonhosted.org/packages/83/5a/cf94c3ba87ea6c8331aa0aba36a18a837a3231764457780661968804673e/psycopg_binary-3.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5e37d5027e297a627da3551a1e962316d0f88ee4ada74c768f6c9234e26346d9", size = 3232263 }, - { url = "https://files.pythonhosted.org/packages/0e/3a/9d912b16059e87b04e3eb4fca457f079d78d6468f627d5622fbda80e9378/psycopg_binary-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:261f0031ee6074765096a19b27ed0f75498a8338c3dcd7f4f0d831e38adf12d1", size = 2912530 }, - { url = "https://files.pythonhosted.org/packages/c6/bf/717c5e51c68e2498b60a6e9f1476cc47953013275a54bf8e23fd5082a72d/psycopg_binary-3.2.3-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:41fdec0182efac66b27478ac15ef54c9ebcecf0e26ed467eb7d6f262a913318b", size = 3360874 }, - { url = "https://files.pythonhosted.org/packages/31/d5/6f9ad6fe5ef80ca9172bc3d028ebae8e9a1ee8aebd917c95c747a5efd85f/psycopg_binary-3.2.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:07d019a786eb020c0f984691aa1b994cb79430061065a694cf6f94056c603d26", size = 3502320 }, - { url = "https://files.pythonhosted.org/packages/fb/7b/c58dd26c27fe7a491141ca765c103e702872ff1c174ebd669d73d7fb0b5d/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c57615791a337378fe5381143259a6c432cdcbb1d3e6428bfb7ce59fff3fb5c", size = 4446950 }, - { url = "https://files.pythonhosted.org/packages/ed/75/acf6a81c788007b7bc0a43b02c22eff7cb19a6ace9e84c32838e86083a3f/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8eb9a4e394926b93ad919cad1b0a918e9b4c846609e8c1cfb6b743683f64da0", size = 4252409 }, - { url = "https://files.pythonhosted.org/packages/83/a5/8a01b923fe42acd185d53f24fb98ead717725ede76a4cd183ff293daf1f1/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5905729668ef1418bd36fbe876322dcb0f90b46811bba96d505af89e6fbdce2f", size = 4488121 }, - { url = "https://files.pythonhosted.org/packages/14/8f/b00e65e204340ab1259ecc8d4cc4c1f72c386be5ca7bfb90ae898a058d68/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd65774ed7d65101b314808b6893e1a75b7664f680c3ef18d2e5c84d570fa393", size = 4190653 }, - { url = "https://files.pythonhosted.org/packages/ce/fc/ba830fc6c9b02b66d1e2fb420736df4d78369760144169a9046f04d72ac6/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:700679c02f9348a0d0a2adcd33a0275717cd0d0aee9d4482b47d935023629505", size = 3118074 }, - { url = "https://files.pythonhosted.org/packages/b8/75/b62d06930a615435e909e05de126aa3d49f6ec2993d1aa6a99e7faab5570/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:96334bb64d054e36fed346c50c4190bad9d7c586376204f50bede21a913bf942", size = 3100457 }, - { url = "https://files.pythonhosted.org/packages/57/e5/32dc7518325d0010813853a87b19c784d8b11fdb17f5c0e0c148c5ac77af/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9099e443d4cc24ac6872e6a05f93205ba1a231b1a8917317b07c9ef2b955f1f4", size = 3192788 }, - { url = "https://files.pythonhosted.org/packages/23/a3/d1aa04329253c024a2323051774446770d47b43073874a3de8cca797ed8e/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1985ab05e9abebfbdf3163a16ebb37fbc5d49aff2bf5b3d7375ff0920bbb54cd", size = 3234247 }, - { url = "https://files.pythonhosted.org/packages/03/20/b675af723b9a61d48abd6a3d64cbb9797697d330255d1f8105713d54ed8e/psycopg_binary-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:e90352d7b610b4693fad0feea48549d4315d10f1eba5605421c92bb834e90170", size = 2913413 }, - { url = "https://files.pythonhosted.org/packages/11/59/6f5763265b51e21abd6d63f529b68a0e60cc1df152316e79f1181a110e96/psycopg_binary-3.2.3-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:69320f05de8cdf4077ecd7fefdec223890eea232af0d58f2530cbda2871244a0", size = 3384410 }, - { url = "https://files.pythonhosted.org/packages/92/be/50dcb479e75906b8cb003b0b57e9a8fda0b81bb0b2c738b52ccac0d32ab6/psycopg_binary-3.2.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4926ea5c46da30bec4a85907aa3f7e4ea6313145b2aa9469fdb861798daf1502", size = 4469507 }, - { url = "https://files.pythonhosted.org/packages/ce/44/2b87aa5b2464367c18e007476032660554a5ba228508755f20e23e879039/psycopg_binary-3.2.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c64c4cd0d50d5b2288ab1bcb26c7126c772bbdebdfadcd77225a77df01c4a57e", size = 4270222 }, - { url = "https://files.pythonhosted.org/packages/dd/cf/2011bbd6bdfc995087c24965cc1c59fddf6f7e27700febb124941dcf06a4/psycopg_binary-3.2.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05a1bdce30356e70a05428928717765f4a9229999421013f41338d9680d03a63", size = 4522305 }, - { url = "https://files.pythonhosted.org/packages/7b/75/b82f43c38c4b7fbf0278d31165126acf889f69cdae7a3065c3bf935dba9a/psycopg_binary-3.2.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad357e426b0ea5c3043b8ec905546fa44b734bf11d33b3da3959f6e4447d350", size = 4215983 }, - { url = "https://files.pythonhosted.org/packages/6f/f4/a821440a62d0c4d6f5d76827c233a412f83e2d839e8e5589136d4e935ca4/psycopg_binary-3.2.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:967b47a0fd237aa17c2748fdb7425015c394a6fb57cdad1562e46a6eb070f96d", size = 3142095 }, - { url = "https://files.pythonhosted.org/packages/3f/44/93841190fd09c76316f5d462508f1aeda7a98b57dfe7b78c622cadf99350/psycopg_binary-3.2.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:71db8896b942770ed7ab4efa59b22eee5203be2dfdee3c5258d60e57605d688c", size = 3120815 }, - { url = "https://files.pythonhosted.org/packages/e5/84/757ddc8f76c624969a98c00f4774a55c5d4c6bf8674db4c7283b13a5673a/psycopg_binary-3.2.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2773f850a778575dd7158a6dd072f7925b67f3ba305e2003538e8831fec77a1d", size = 3228866 }, - { url = "https://files.pythonhosted.org/packages/e9/5d/d214ee8b84771695737737c433bdaa8ee37c378ead3e9123fe07bec08d1f/psycopg_binary-3.2.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aeddf7b3b3f6e24ccf7d0edfe2d94094ea76b40e831c16eff5230e040ce3b76b", size = 3264374 }, - { url = "https://files.pythonhosted.org/packages/21/b2/350cda49155d95649c091e7618e7fea81d617b048df42bac5d80c6a3de9f/psycopg_binary-3.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:824c867a38521d61d62b60aca7db7ca013a2b479e428a0db47d25d8ca5067410", size = 2929244 }, - { url = "https://files.pythonhosted.org/packages/1c/21/71110c15aecf73176b4dfd9dccb7d5b48f7ad2b3ef845d30344a05096e96/psycopg_binary-3.2.3-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:9994f7db390c17fc2bd4c09dca722fd792ff8a49bb3bdace0c50a83f22f1767d", size = 3384458 }, - { url = "https://files.pythonhosted.org/packages/0b/cb/c80fd6ba503434d538f71c271e189f32541ca0795a7ef6d11359dc621430/psycopg_binary-3.2.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1303bf8347d6be7ad26d1362af2c38b3a90b8293e8d56244296488ee8591058e", size = 4469330 }, - { url = "https://files.pythonhosted.org/packages/6b/03/86c6b20c621cccc1a2eb74a01462036f6ff1bec45f9707c8ee8c35978f14/psycopg_binary-3.2.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:842da42a63ecb32612bb7f5b9e9f8617eab9bc23bd58679a441f4150fcc51c96", size = 4271605 }, - { url = "https://files.pythonhosted.org/packages/37/47/fa357809bd873c90461ab41f71aa94a1fd43925adfd043c2aa6e56e0f615/psycopg_binary-3.2.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2bb342a01c76f38a12432848e6013c57eb630103e7556cf79b705b53814c3949", size = 4519938 }, - { url = "https://files.pythonhosted.org/packages/d1/f4/2bdee9374313d224148a66f8d6f2f9726d1eb6b1919c37685d4d98d0d129/psycopg_binary-3.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd40af959173ea0d087b6b232b855cfeaa6738f47cb2a0fd10a7f4fa8b74293f", size = 4216398 }, - { url = "https://files.pythonhosted.org/packages/1c/93/5ca69e54d331372aacc8ec1e63805c9e54c2f16d77a9156f5e0cb6c2bebe/psycopg_binary-3.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9b60b465773a52c7d4705b0a751f7f1cdccf81dd12aee3b921b31a6e76b07b0e", size = 3141385 }, - { url = "https://files.pythonhosted.org/packages/18/ad/c28bd3661accde75df677af0ba6bc01089a332c31105ebddf5925d765374/psycopg_binary-3.2.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fc6d87a1c44df8d493ef44988a3ded751e284e02cdf785f746c2d357e99782a6", size = 3118288 }, - { url = "https://files.pythonhosted.org/packages/63/d9/5dd0bdf7804ae2ab7326d27c94ea96f7c0dac2710f0603ef92ebc58ffb8c/psycopg_binary-3.2.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f0b018e37608c3bfc6039a1dc4eb461e89334465a19916be0153c757a78ea426", size = 3226249 }, - { url = "https://files.pythonhosted.org/packages/56/d7/f1cf6447d1c45641cb8d860bb21c039fbe7f643f79b66dbd5d4b9201f068/psycopg_binary-3.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2a29f5294b0b6360bfda69653697eff70aaf2908f58d1073b0acd6f6ab5b5a4f", size = 3260299 }, - { url = "https://files.pythonhosted.org/packages/c4/ab/f08f734154193af28ac02ab93fa0f1917ef75d4947277cbb87ccfa7ba1ac/psycopg_binary-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:e56b1fd529e5dde2d1452a7d72907b37ed1b4f07fdced5d8fb1e963acfff6749", size = 2923376 }, +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/7b/6d7a4626b49e227125f8edf6f114dd8e9a9b22fc4f0abc3b2b0068d5f2bd/psycopg_binary-3.2.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c716f75b5c0388fc5283b5124046292c727511dd8c6aa59ca2dc644b9a2ed0cd", size = 3862864 }, + { url = "https://files.pythonhosted.org/packages/2b/7b/bc0dbb8384997e1321ffb265f96e68ba8584c2af58229816c16809218bdf/psycopg_binary-3.2.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e2e8050347018f596a63f5dccbb92fb68bca52b13912cb8fc40184b24c0e534f", size = 3934048 }, + { url = "https://files.pythonhosted.org/packages/42/c0/8a8034650e4618efc8c0be32c30469933a1ddac1656525c0c6b2b2151736/psycopg_binary-3.2.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04171f9af9ab567c0fd339bac06f2c75836db839cebac5bd07824778dafa7f0e", size = 4516741 }, + { url = "https://files.pythonhosted.org/packages/b8/6c/714572fc7c59295498287b9b4b965e3b1d6ff5758c310535a2f02d159688/psycopg_binary-3.2.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7ba7b2ff25a6405826f627fb7d0f1e06e5c08ae25ffabc74a5e9ec7b0a63b85", size = 4323332 }, + { url = "https://files.pythonhosted.org/packages/64/19/a807021e48719cf226a7b520fd0c9c741577ad8974ecd264efe03862d80c/psycopg_binary-3.2.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e58eeba520d405b2ad72dffaafd04d0b592bef870e718bf37c261e89a75450a", size = 4569646 }, + { url = "https://files.pythonhosted.org/packages/67/78/70c515175c623bbc505d015ef1ee55b1ee4d0878985a95d4d6317fdd6894/psycopg_binary-3.2.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb18cfbb1cfc8172786ceefd314f0faa05c40ea93b3db7194d0f6bbbbfedb42a", size = 4279629 }, + { url = "https://files.pythonhosted.org/packages/0f/02/8a0395ac8f69320ca26f4f7ec7fd16620671ba002072e01ed5fb13c29a38/psycopg_binary-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:769804b4f753ddec9403183a6d4577d5b696fc49c2451421013fb06d6fa2f288", size = 3868189 }, + { url = "https://files.pythonhosted.org/packages/b9/a8/fa254c48513580c9cae242b5fac4af4dd1227178061a27a2eb260ff61a27/psycopg_binary-3.2.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7d4f0c9b01eb933ce35bb32a54205f48d7bc36bf455565afe269cabcb7973955", size = 3335018 }, + { url = "https://files.pythonhosted.org/packages/d6/c1/98c239f40851c67eb4813d6a7eb90b39f717de2fd48f23fe3121899eb70b/psycopg_binary-3.2.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:26aed7ff8691ba810de95718d3bc81a43fd48a4036c3641ef711eb5f71fc7106", size = 3432703 }, + { url = "https://files.pythonhosted.org/packages/91/08/5b6fa2247bf964ac14d10cff3f7163d901dd008b7b6300e13eace8394751/psycopg_binary-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8a4b65eaf44dfed0b47e6ebd392e88cd3cff62ea11652d92db6fefeb2608ed25", size = 3457676 }, + { url = "https://files.pythonhosted.org/packages/2f/55/79db2b10f87eb7a913b59bbcdd10f794c4c964141f2db31f8eb1f567c7d9/psycopg_binary-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9fa48a2dc54c4e906d7dd781031d227d1b13966deff7e5ece5b037588643190", size = 2787324 }, + { url = "https://files.pythonhosted.org/packages/f3/9a/8013aa4ad4d76dfcf9b822da549d51aab96abfc77afc44b200ef295685dc/psycopg_binary-3.2.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d092b0aa80b8c3ee0701a7252cbfb0bdb742e1f74aaf0c1a13ef22c05c9266ab", size = 3871518 }, + { url = "https://files.pythonhosted.org/packages/1e/65/2422036d0169e33e5f06d868a36235340f85e42afe153d59b0edf4b4210f/psycopg_binary-3.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3955381dacc6d15f3838d5f25445ee99f80882876a163f8de0c01ffc54aeef4a", size = 3938511 }, + { url = "https://files.pythonhosted.org/packages/bf/ab/4f6c815862d62d9d06353abfbf36fef69ad7e6ca0763eed1629f47579e83/psycopg_binary-3.2.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04144d1963aa3309247980f1a742b98e15f60d68ea9745143c433f99aaeb70d7", size = 4512971 }, + { url = "https://files.pythonhosted.org/packages/27/ef/0e5e9ea6122f61f9e0c4e70b7f7a28ef51404c98bbb32096ad99f79f85b5/psycopg_binary-3.2.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eac61931bc90c1c6fdc648452894d3a434a005ffefaf12819b4709548c894bf2", size = 4318297 }, + { url = "https://files.pythonhosted.org/packages/93/cd/05d71e4f2f7f69fd185d2ec44b66de13734ff70c426ead14523e206258bb/psycopg_binary-3.2.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c09b765960480c4586758a3c16f0ee0db6f7e2f31c88cccb5e7d7024215468cd", size = 4570696 }, + { url = "https://files.pythonhosted.org/packages/af/7c/f5099ad491f78ba491e56cd686b38b0737eb09a719e919661a9f8d08e754/psycopg_binary-3.2.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:220de8efcc276e42ba7cc7ed613145b1274b6b5de321a1396fb6b6ce1758d34c", size = 4275069 }, + { url = "https://files.pythonhosted.org/packages/2d/95/a1a2f861d90f3394f98d032329a1e44a67c8d1f5bded0ec343b664c65ba5/psycopg_binary-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b558d3de315d18819ce477908e27518cbdd3275717c6193b58dde36f0443e167", size = 3865827 }, + { url = "https://files.pythonhosted.org/packages/ab/72/0b395ad2db2adc6009d2a1cdc2707b1764a3e870d6895cf92dc87e251aa9/psycopg_binary-3.2.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e3b4c9b9a112d43533f7dbdedbb1188107d4ddcd262e2a2af41b4de0caf7d053", size = 3329276 }, + { url = "https://files.pythonhosted.org/packages/ba/5d/8e9904664e5bae3852989a0f1b0517c781ff0a9cba64416ffa68952129ac/psycopg_binary-3.2.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:870df866f789bb641a350897c1751c293b9420f46be4eb366d190ff5f2f2ffd8", size = 3426059 }, + { url = "https://files.pythonhosted.org/packages/46/6a/9abc03e01c1cb97878e6e87d5ea9e3d925790b04fa03d72b2d6e3455f124/psycopg_binary-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89506e268fb95428fb0f8f7abe48032e66cf47390469e11a4fe989f7407a5d88", size = 3456766 }, + { url = "https://files.pythonhosted.org/packages/12/c5/1be474bfa7282aa9177c3e498eb641b1441724f0155953f3872c69deddf0/psycopg_binary-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:7ddf1494cc3bf60761c01265c44dfc7a7fd63f21308c403c14f5dd91702df84d", size = 2790400 }, + { url = "https://files.pythonhosted.org/packages/48/f8/f30cf36bc9bc672894413f10f0498d5e81b0813c87f1b963d85e7c5cc9f1/psycopg_binary-3.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ac24b3d421127ebe8662eba2c1e149a12f0f5b6795e66c1811a3f59111456bb", size = 3852023 }, + { url = "https://files.pythonhosted.org/packages/2f/23/88a265ca4a35def6f53cb239e352bf52f01ea418f57f4272b3913ecd6fd2/psycopg_binary-3.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f702f36204127984dd212eb57bb328676abdfe8a56f179e408a806d5e520aa11", size = 3935919 }, + { url = "https://files.pythonhosted.org/packages/0f/2b/2ac3456208c255a6fad9fec4fea0e411e34a0b4b0ecd1e60c0ba36fb78c4/psycopg_binary-3.2.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:610cd2013ee0849154fcff34b0cca17f720c91c7430ca094a61f1e5ff1d38e15", size = 4493108 }, + { url = "https://files.pythonhosted.org/packages/55/f5/725b786b7cf1b91f1afbe03545f0b14857c0a5cc03b4f8a6735ec289ff89/psycopg_binary-3.2.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95da59edd95f6b6488799c9710fafc2d5750e3ec6328ec991f7a9be04efe6886", size = 4300141 }, + { url = "https://files.pythonhosted.org/packages/09/80/72b3a1ec912b8be51e6af858fcd2a016d25145aca400e75bba6ab91025c4/psycopg_binary-3.2.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b71e98e3186f08473962e1ea4bfbc4387ecc398644b794cb112ad0a4276e3789", size = 4540559 }, + { url = "https://files.pythonhosted.org/packages/0b/8e/6cd6643f04e033bcdab008d5175c9356ade1eecff53fa4558d383dd9866c/psycopg_binary-3.2.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ccf4f71c3a0d46bc74207bf7997f010a6586414161dd10f3dd026ec059942ef", size = 4253687 }, + { url = "https://files.pythonhosted.org/packages/85/47/50d93bef98d32eba1f7b95e3c4e671a7f59b1d0b9ed01fdb43e951d6012b/psycopg_binary-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:244e1dd33b694792b7bc7a3d412a535ba39116218b07d8936b4591567f4121e9", size = 3842084 }, + { url = "https://files.pythonhosted.org/packages/2e/a0/2cf0dda5634d14219a24c05bc85cb928a5b2ea29684d167aebc974df016c/psycopg_binary-3.2.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f8dc8f4de5130c6278dd5e34b18ad8324a74658a7adb72d4e67ca97f9aeaaf3c", size = 3315357 }, + { url = "https://files.pythonhosted.org/packages/14/65/13b3dd91dd62f6e4ee3cb00bd24ab60a251592c03a8fb090c28057f21e38/psycopg_binary-3.2.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c336e58a48061a9189d3ba8c19f00fe5d9570219e6f7f954b923ad5c33e5bc71", size = 3394512 }, + { url = "https://files.pythonhosted.org/packages/07/cc/90b5307ff833892c8985aefd73c1894b1a9d8b5df4965650e95636ba8161/psycopg_binary-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9633c5dc6796d11766d2475e62335b67e5f99f119f40ba1675c1d23208d7709d", size = 3431893 }, + { url = "https://files.pythonhosted.org/packages/40/dc/5ab8fec2fc2e0599fd7a60abe046c853477bbb7cd978b818f795c5423848/psycopg_binary-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:295c25e56b430d786a475c5c2cef266b0b27c0a6fcaadf9d83a4cdcfb76f971f", size = 2778464 }, + { url = "https://files.pythonhosted.org/packages/25/e2/f56675aada063762f08559b6969e47e1313f269fc1682c16457c13da8186/psycopg_binary-3.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:81ab801c0d35830c876bf0d1edc8e7dd2f73aa2b04fe24eb812159c0b054d149", size = 3846854 }, + { url = "https://files.pythonhosted.org/packages/7b/8b/8c4a66b2b3db494367df0299535b7d2df78f303334228c517b8d00c411d5/psycopg_binary-3.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c09e02ce1124eb6638b3381df050a8cf88aedfad4522f939945cda49050a990c", size = 3932292 }, + { url = "https://files.pythonhosted.org/packages/84/e8/618d45f77cebce73d75497c95685a0902aea3783386d9335ce486c69e13a/psycopg_binary-3.2.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a249cdc6a5c2b5088a8677acba66b291e5237524739ab3d27498e1ef189312f5", size = 4493785 }, + { url = "https://files.pythonhosted.org/packages/c4/87/fc30318e6b97e723e017e7dc88d0f721bbfb749de1a6e414e52d4ac54c9a/psycopg_binary-3.2.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2960ba8a5c0ad75e184f6d8bf76bdf023708999efe75fe4e13445136c1cd206", size = 4304874 }, + { url = "https://files.pythonhosted.org/packages/91/30/1d127e651c21cd77befaf361c7c3b9001bfff51ac38027e8fce598ba0701/psycopg_binary-3.2.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dae2e50b0d3425c167eebbedc3553f7c811dbc0dbfc737b6877f68a03be7daf", size = 4541296 }, + { url = "https://files.pythonhosted.org/packages/0d/5e/22c824cb38745c1c744cec85d227190727c564afb75960ce0057ca15fd84/psycopg_binary-3.2.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03bf7ee7e0002c2cce43ecb923ec510358056eb2e44a96afaeb0424518f35206", size = 4255756 }, + { url = "https://files.pythonhosted.org/packages/b3/83/ae8783dec3f7e39df8a4056e4d383926ffec531970c0b415d48d9fd4a2c2/psycopg_binary-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f5c85eeb63b1a8a6b026eef57f5da36ff215ce9a6a3bb8e20a409670d6cfbda", size = 3845918 }, + { url = "https://files.pythonhosted.org/packages/be/f7/fb7bffb0c4c45a5a82fe324e4f7b176075a4c5372e546a038858dd13c7ab/psycopg_binary-3.2.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8c7b95899d4d6d23c5cc46cb3419e8e6ca68d867509432ee1487042564a1ea55", size = 3315429 }, + { url = "https://files.pythonhosted.org/packages/81/a3/29f4993a239d6a3fb18ef8681d9990c007f5f73bdd2e21f65f07ac55ad6f/psycopg_binary-3.2.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fa4acea9ca20a567c3872a5afab2084751530bb57b8fb6b52820d5c54e7c8c3b", size = 3399388 }, + { url = "https://files.pythonhosted.org/packages/25/5b/925171cbfa2e3d1ccb7f4c005d0d5db609ba796c1d08a23c42825b09c554/psycopg_binary-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c487f35a1905bb15da927c1fc05f70f3d29f0e21fb4ba21d360a0da9c755f20", size = 3436702 }, + { url = "https://files.pythonhosted.org/packages/b6/47/25b2b85b8fcabf99bfa92b4b0d587894c01576bf0b2bf137c243d1eb1070/psycopg_binary-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:80297c3a9f7b5a6afdb0d8f220661ccd796e5c9128c44b32c41267f7daefd37f", size = 2779196 }, + { url = "https://files.pythonhosted.org/packages/3b/22/c62a3c58d943d0270aa611b92e36893f2c7e795de31421d69cc7c3a980e6/psycopg_binary-3.2.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:22cf23d037310ae08feceea5e24f727b1ef816867188dbec2edde2e7237b0004", size = 3865169 }, + { url = "https://files.pythonhosted.org/packages/92/7c/343113de819bb69190d2a92e01bdff5eb602fe4fe2265583b0d07d9c2ed6/psycopg_binary-3.2.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3409151b91df85ef99a72d137aba289e1d7b5d4ac7750b37183674421903e04", size = 4517184 }, + { url = "https://files.pythonhosted.org/packages/52/fc/af761c52a36adb7b35ffb75dd8a96ef632b5d94acac66fcdcd18c30e8601/psycopg_binary-3.2.4-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1145c3c038e6dbe7127309cc9bbe209bce5743f9f02a2a65c4f9478bd794598e", size = 4323490 }, + { url = "https://files.pythonhosted.org/packages/c7/fc/99faa7e8fde8ebda71956820da3b219fd56a13fa993d832edbdfebac609c/psycopg_binary-3.2.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21d369bac7606157ef2699a0ff65c8d43d274f0178fd03241babb5f86b7586f7", size = 4575376 }, + { url = "https://files.pythonhosted.org/packages/c5/c7/e01b2ca4d1e8d36a43d1b499c12f81998590113387f2ea0b063f5bdc61d5/psycopg_binary-3.2.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:158fa0dbda433e0069bd2b6ffdf357c9fcdb84ec5e3b353fb8206636873b54f9", size = 4281050 }, + { url = "https://files.pythonhosted.org/packages/6e/63/0a308146098682cf2b7a8761610964850a6bfc3565f18fcf03d5aa393882/psycopg_binary-3.2.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4a56b55072a3e0629e6421a7f6fdd4eecc0eba4e9cedaaf2e7578ac62c336680", size = 3870946 }, + { url = "https://files.pythonhosted.org/packages/5a/dc/6b69afa49acd6d36fe7c977105d2d2ecbc41087f4ec03c9c58ab4afb52f3/psycopg_binary-3.2.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:d993ecfa7f2ac30108d57e7418732d70aa399ccb4a8ca1cf415638679fb32e8b", size = 3338122 }, + { url = "https://files.pythonhosted.org/packages/c2/58/8734b528378c2fc57aa51ecaf3b852230216660f0257ad2718a6be41f4ad/psycopg_binary-3.2.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:a5b68ba52bdf3ed86a8a1f1ac809ecd775ffd7bb611438d3ab9e1ee572742f95", size = 3435520 }, + { url = "https://files.pythonhosted.org/packages/b2/3d/18500fa20251b4dbddb4d10277662ef9653b2d8d98bb3b4df03f374378fb/psycopg_binary-3.2.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:90423ff7a0c1f4001b8d54e6c7866f5bbb778f3f4272a70a7926878fe7d8763c", size = 3463460 }, + { url = "https://files.pythonhosted.org/packages/20/2d/d6dd1b2759a5e5b14a7aaf51b831e4579f18b36e05db090456f3737162df/psycopg_binary-3.2.4-cp38-cp38-win_amd64.whl", hash = "sha256:5a462bdd427330418fa2a011b6494103edd94cacd4f5b00e598bcbd1c8d20fb9", size = 2794690 }, + { url = "https://files.pythonhosted.org/packages/2f/56/f40184d35179e433bc88d99993435e370feaa3e1dd25b670aeccdf44b321/psycopg_binary-3.2.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2ddec5deed4c93a1bd73f210bed6dadbabc470ac1f9ebf55fa260e48396fd61f", size = 3864122 }, + { url = "https://files.pythonhosted.org/packages/9f/55/3e3ef6a140aaecd4ada5fe81099ab26b380f5bc6e9dcf9ef1fca2b298071/psycopg_binary-3.2.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8bd54787d894261ff48d5c4b7f23e281c05c9a5ac67355eff7d29cfbcde640cd", size = 3934859 }, + { url = "https://files.pythonhosted.org/packages/02/87/58af827b8388b8218ca627b739b737d79ead688d564080a4a10277c83641/psycopg_binary-3.2.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ae8cf8694d01788be5f418f6cada813e2b86cef67efba9c60cb9371cee9eb9", size = 4516943 }, + { url = "https://files.pythonhosted.org/packages/94/3e/bbb50f5f1c3055aca8d16091c5d0f64327697d444e3078d2d2951cc7bdef/psycopg_binary-3.2.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0958dd3bfffbdef86594a6fa45255d4389ade94d17572bdf5207a900166a3cba", size = 4323772 }, + { url = "https://files.pythonhosted.org/packages/bf/32/1a3524942befe5ddc0369cf29e0e9f5ea1607d1ffa089fa4ca10fa43a5d8/psycopg_binary-3.2.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b9558f9d101907e412ea12c355e8989c811d382d893ba6a541c091e6d916164", size = 4570266 }, + { url = "https://files.pythonhosted.org/packages/50/05/19c199ea980f652a8033af5308887ea21dd929558eb16e66c482de5b310c/psycopg_binary-3.2.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:279faafe9a4cdaeeee7844c19cccb865328bd55a2bf4012fef8d7040223a5245", size = 4283435 }, + { url = "https://files.pythonhosted.org/packages/bc/eb/8a3f9475ba305447e41687e031e140e2f7829a9b9cd7c8432d34d2f63df0/psycopg_binary-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:196d8426a9220d29c118eec6074034648267c176d220cb42c49b3c9c396f0dbc", size = 3868207 }, + { url = "https://files.pythonhosted.org/packages/a9/29/4f0f4b7c51cdc4ba6e72f77418a4f43500415866b4b748b08cae43f77aa7/psycopg_binary-3.2.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:166e68b1e42862b18570d636a7b615630552daeab8b129083aa094f848be64b0", size = 3335297 }, + { url = "https://files.pythonhosted.org/packages/61/b5/56042b08bf5962ac631198efe6a949e52c95cb1111c015cae7eab1eb8afc/psycopg_binary-3.2.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b84c3f51969d33266640c218ad5bb5f8487e6a991db7a95b2c3c46fbda37a77c", size = 3433536 }, + { url = "https://files.pythonhosted.org/packages/1a/fb/361e8ed5f7f79f697a6a9193b7529a7a509ef761bb33f1aeb42bd25c7329/psycopg_binary-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:501113e4d84887c03f83c7d8886c0744fe088fd6b633b919ebf7af4f0f7186be", size = 3459503 }, + { url = "https://files.pythonhosted.org/packages/c7/8d/6db8fba11a23c541186c42298e38d75e9ce45722dce3c5ee258372f74bcd/psycopg_binary-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:e889fe21c578c6c533c8550e1b3ba5d2cc5d151890458fa5fbfc2ca3b2324cfa", size = 2789153 }, ] [[package]] @@ -3521,7 +3558,7 @@ wheels = [ [[package]] name = "pydantic-extra-types" -version = "2.10.1" +version = "2.10.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13'", @@ -3533,9 +3570,9 @@ dependencies = [ { name = "pydantic", marker = "python_full_version >= '3.9'" }, { name = "typing-extensions", marker = "python_full_version >= '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/12/844a4796dbbc814ef0a706f403cb0f3029bf324c2bca2bf0681f4f7d8618/pydantic_extra_types-2.10.1.tar.gz", hash = "sha256:e4f937af34a754b8f1fa228a2fac867091a51f56ed0e8a61d5b3a6719b13c923", size = 85694 } +sdist = { url = "https://files.pythonhosted.org/packages/23/ed/69f3f3de12c02ebd58b2f66ffb73d0f5a1b10b322227897499753cebe818/pydantic_extra_types-2.10.2.tar.gz", hash = "sha256:934d59ab7a02ff788759c3a97bc896f5cfdc91e62e4f88ea4669067a73f14b98", size = 86893 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/f1/92f7b4711d3d0d08981c2677ec9cdde6cb114205a69814bf803e0be5ae9b/pydantic_extra_types-2.10.1-py3-none-any.whl", hash = "sha256:db2c86c04a837bbac0d2d79bbd6f5d46c4c9253db11ca3fdd36a2b282575f1e2", size = 35155 }, + { url = "https://files.pythonhosted.org/packages/08/da/86bc9addde8a24348ac15f8f7dcb853f78e9573c7667800dd9bc60558678/pydantic_extra_types-2.10.2-py3-none-any.whl", hash = "sha256:9eccd55a2b7935cea25f0a67f6ff763d55d80c41d86b887d88915412ccf5b7fa", size = 35473 }, ] [[package]] @@ -3753,6 +3790,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, ] +[[package]] +name = "pytest-rerunfailures" +version = "14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.8.1' and python_full_version < '3.9'", + "python_full_version < '3.8.1'", +] +dependencies = [ + { name = "packaging", marker = "python_full_version < '3.9'" }, + { name = "pytest", marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/a4/6de45fe850759e94aa9a55cda807c76245af1941047294df26c851dfb4a9/pytest-rerunfailures-14.0.tar.gz", hash = "sha256:4a400bcbcd3c7a4ad151ab8afac123d90eca3abe27f98725dc4d9702887d2e92", size = 21350 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/e7/e75bd157331aecc190f5f8950d7ea3d2cf56c3c57fb44da70e60b221133f/pytest_rerunfailures-14.0-py3-none-any.whl", hash = "sha256:4197bdd2eaeffdbf50b5ea6e7236f47ff0e44d1def8dae08e409f536d84e7b32", size = 12709 }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "15.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version == '3.10.*'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "packaging", marker = "python_full_version >= '3.9'" }, + { name = "pytest", marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/47/ec4e12f45f4b9fac027a41ccaabb353ed4f23695aae860258ba11a84ed9b/pytest-rerunfailures-15.0.tar.gz", hash = "sha256:2d9ac7baf59f4c13ac730b47f6fa80e755d1ba0581da45ce30b72fb3542b4474", size = 21816 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/37/54e5ffc7c0cebee7cf30a3ac5915faa7e7abf8bdfdf3228c277f7c192489/pytest_rerunfailures-15.0-py3-none-any.whl", hash = "sha256:dd150c4795c229ef44320adc9a0c0532c51b78bb7a6843a8c53556b9a611df1a", size = 13017 }, +] + [[package]] name = "pytest-sugar" version = "1.0.0" @@ -4087,27 +4160,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/3e/e89f736f01aa9517a97e2e7e0ce8d34a4d8207087b3cfdec95133fee13b5/ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17", size = 3498844 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/05/c3a2e0feb3d5d394cdfd552de01df9d3ec8a3a3771bbff247fab7e668653/ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743", size = 10645241 }, - { url = "https://files.pythonhosted.org/packages/dd/da/59f0a40e5f88ee5c054ad175caaa2319fc96571e1d29ab4730728f2aad4f/ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f", size = 10391066 }, - { url = "https://files.pythonhosted.org/packages/b7/fe/85e1c1acf0ba04a3f2d54ae61073da030f7a5dc386194f96f3c6ca444a78/ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb", size = 10012308 }, - { url = "https://files.pythonhosted.org/packages/6f/9b/780aa5d4bdca8dcea4309264b8faa304bac30e1ce0bcc910422bfcadd203/ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca", size = 10881960 }, - { url = "https://files.pythonhosted.org/packages/12/f4/dac4361afbfe520afa7186439e8094e4884ae3b15c8fc75fb2e759c1f267/ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce", size = 10414803 }, - { url = "https://files.pythonhosted.org/packages/f0/a2/057a3cb7999513cb78d6cb33a7d1cc6401c82d7332583786e4dad9e38e44/ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969", size = 11464929 }, - { url = "https://files.pythonhosted.org/packages/eb/c6/1ccfcc209bee465ced4874dcfeaadc88aafcc1ea9c9f31ef66f063c187f0/ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd", size = 12170717 }, - { url = "https://files.pythonhosted.org/packages/84/97/4a524027518525c7cf6931e9fd3b2382be5e4b75b2b61bec02681a7685a5/ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a", size = 11708921 }, - { url = "https://files.pythonhosted.org/packages/a6/a4/4e77cf6065c700d5593b25fca6cf725b1ab6d70674904f876254d0112ed0/ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b", size = 13058074 }, - { url = "https://files.pythonhosted.org/packages/f9/d6/fcb78e0531e863d0a952c4c5600cc5cd317437f0e5f031cd2288b117bb37/ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831", size = 11281093 }, - { url = "https://files.pythonhosted.org/packages/e4/3b/7235bbeff00c95dc2d073cfdbf2b871b5bbf476754c5d277815d286b4328/ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab", size = 10882610 }, - { url = "https://files.pythonhosted.org/packages/2a/66/5599d23257c61cf038137f82999ca8f9d0080d9d5134440a461bef85b461/ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1", size = 10489273 }, - { url = "https://files.pythonhosted.org/packages/78/85/de4aa057e2532db0f9761e2c2c13834991e087787b93e4aeb5f1cb10d2df/ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366", size = 11003314 }, - { url = "https://files.pythonhosted.org/packages/00/42/afedcaa089116d81447347f76041ff46025849fedb0ed2b187d24cf70fca/ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f", size = 11342982 }, - { url = "https://files.pythonhosted.org/packages/39/c6/fe45f3eb27e3948b41a305d8b768e949bf6a39310e9df73f6c576d7f1d9f/ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72", size = 8819750 }, - { url = "https://files.pythonhosted.org/packages/38/8d/580db77c3b9d5c3d9479e55b0b832d279c30c8f00ab0190d4cd8fc67831c/ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19", size = 9701331 }, - { url = "https://files.pythonhosted.org/packages/b2/94/0498cdb7316ed67a1928300dd87d659c933479f44dec51b4f62bfd1f8028/ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7", size = 9145708 }, +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/63/77ecca9d21177600f551d1c58ab0e5a0b260940ea7312195bd2a4798f8a8/ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0", size = 3553799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b9/0e168e4e7fb3af851f739e8f07889b91d1a33a30fca8c29fa3149d6b03ec/ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347", size = 11652408 }, + { url = "https://files.pythonhosted.org/packages/2c/22/08ede5db17cf701372a461d1cb8fdde037da1d4fa622b69ac21960e6237e/ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00", size = 11587553 }, + { url = "https://files.pythonhosted.org/packages/42/05/dedfc70f0bf010230229e33dec6e7b2235b2a1b8cbb2a991c710743e343f/ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4", size = 11020755 }, + { url = "https://files.pythonhosted.org/packages/df/9b/65d87ad9b2e3def67342830bd1af98803af731243da1255537ddb8f22209/ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d", size = 11826502 }, + { url = "https://files.pythonhosted.org/packages/93/02/f2239f56786479e1a89c3da9bc9391120057fc6f4a8266a5b091314e72ce/ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c", size = 11390562 }, + { url = "https://files.pythonhosted.org/packages/c9/37/d3a854dba9931f8cb1b2a19509bfe59e00875f48ade632e95aefcb7a0aee/ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f", size = 12548968 }, + { url = "https://files.pythonhosted.org/packages/fa/c3/c7b812bb256c7a1d5553433e95980934ffa85396d332401f6b391d3c4569/ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684", size = 13187155 }, + { url = "https://files.pythonhosted.org/packages/bd/5a/3c7f9696a7875522b66aa9bba9e326e4e5894b4366bd1dc32aa6791cb1ff/ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d", size = 12704674 }, + { url = "https://files.pythonhosted.org/packages/be/d6/d908762257a96ce5912187ae9ae86792e677ca4f3dc973b71e7508ff6282/ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df", size = 14529328 }, + { url = "https://files.pythonhosted.org/packages/2d/c2/049f1e6755d12d9cd8823242fa105968f34ee4c669d04cac8cea51a50407/ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247", size = 12385955 }, + { url = "https://files.pythonhosted.org/packages/91/5a/a9bdb50e39810bd9627074e42743b00e6dc4009d42ae9f9351bc3dbc28e7/ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e", size = 11810149 }, + { url = "https://files.pythonhosted.org/packages/e5/fd/57df1a0543182f79a1236e82a79c68ce210efb00e97c30657d5bdb12b478/ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe", size = 11479141 }, + { url = "https://files.pythonhosted.org/packages/dc/16/bc3fd1d38974f6775fc152a0554f8c210ff80f2764b43777163c3c45d61b/ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb", size = 12014073 }, + { url = "https://files.pythonhosted.org/packages/47/6b/e4ca048a8f2047eb652e1e8c755f384d1b7944f69ed69066a37acd4118b0/ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a", size = 12435758 }, + { url = "https://files.pythonhosted.org/packages/c2/40/4d3d6c979c67ba24cf183d29f706051a53c36d78358036a9cd21421582ab/ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145", size = 9796916 }, + { url = "https://files.pythonhosted.org/packages/c3/ef/7f548752bdb6867e6939489c87fe4da489ab36191525fadc5cede2a6e8e2/ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5", size = 10773080 }, + { url = "https://files.pythonhosted.org/packages/0e/4e/33df635528292bd2d18404e4daabcd74ca8a9853b2e1df85ed3d32d24362/ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6", size = 10001738 }, ] [[package]] @@ -4503,7 +4576,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-typehints" -version = "3.0.0" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13'", @@ -4513,9 +4586,9 @@ resolution-markers = [ dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/37/725d06f80cbe85b8538021df4515b7408af231cbe8b201a3bb2cf773a83d/sphinx_autodoc_typehints-3.0.0.tar.gz", hash = "sha256:d5cdab471efb10fcff4ffe81a2ef713398bc891af9d942a4b763f5ed1d9bf550", size = 35943 } +sdist = { url = "https://files.pythonhosted.org/packages/26/f0/43c6a5ff3e7b08a8c3b32f81b859f1b518ccc31e45f22e2b41ced38be7b9/sphinx_autodoc_typehints-3.0.1.tar.gz", hash = "sha256:b9b40dd15dee54f6f810c924f863f9cf1c54f9f3265c495140ea01be7f44fa55", size = 36282 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/36/110d38a8b481b915d77461ca68764ebacf7aeba84be7cbe2dc965a65ae80/sphinx_autodoc_typehints-3.0.0-py3-none-any.whl", hash = "sha256:b82bf83e23ae3d5dc25881004a6d6614be6291ff8ff165b2d1e18799f0f6bd74", size = 20041 }, + { url = "https://files.pythonhosted.org/packages/3c/dc/dc46c5c7c566b7ec5e8f860f9c89533bf03c0e6aadc96fb9b337867e4460/sphinx_autodoc_typehints-3.0.1-py3-none-any.whl", hash = "sha256:4b64b676a14b5b79cefb6628a6dc8070e320d4963e8ff640a2f3e9390ae9045a", size = 20245 }, ] [[package]] @@ -4726,7 +4799,7 @@ dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "sphinx-autodoc-typehints", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "sphinx-autodoc-typehints", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "sphinx-autodoc-typehints", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "sphinx-jinja2-compat" }, { name = "sphinx-prompt", version = "1.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "sphinx-prompt", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, @@ -5782,16 +5855,16 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.28.1" +version = "20.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/39/689abee4adc85aad2af8174bb195a819d0be064bf55fcc73b49d2b28ae77/virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329", size = 7650532 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/5d/8d625ebddf9d31c301f85125b78002d4e4401fe1c15c04dca58a54a3056a/virtualenv-20.29.0.tar.gz", hash = "sha256:6345e1ff19d4b1296954cee076baaf58ff2a12a84a338c62b02eda39f20aa982", size = 7658081 } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/8f/dfb257ca6b4e27cb990f1631142361e4712badab8e3ca8dc134d96111515/virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb", size = 4276719 }, + { url = "https://files.pythonhosted.org/packages/f0/d3/12687ab375bb0e077ea802a5128f7b45eb5de7a7c6cb576ccf9dd59ff80a/virtualenv-20.29.0-py3-none-any.whl", hash = "sha256:c12311863497992dc4b8644f8ea82d3b35bb7ef8ee82e6630d76d0197c39baf9", size = 4282443 }, ] [[package]]