├── .editorconfig ├── .flake8 ├── .gitignore ├── .python-version ├── CHANGES.rst ├── LICENSE.rst ├── README.rst ├── examples ├── eager_loading.py ├── repository │ ├── base.py │ ├── builder.py │ ├── meta.py │ └── sqla.py └── testing │ └── conftest.py ├── pdm.lock ├── pyproject.toml ├── setup.cfg ├── src └── quart_sqlalchemy │ ├── __init__.py │ ├── bind.py │ ├── config.py │ ├── framework │ ├── __init__.py │ ├── cli.py │ └── extension.py │ ├── model │ ├── __init__.py │ ├── columns.py │ ├── custom_types.py │ ├── mixins.py │ └── model.py │ ├── py.typed │ ├── retry.py │ ├── session.py │ ├── signals.py │ ├── sqla.py │ ├── testing │ ├── __init__.py │ └── transaction.py │ ├── types.py │ └── util.py ├── tests ├── __init__.py ├── base.py ├── conftest.py ├── constants.py └── integration │ ├── __init__.py │ ├── bind_test.py │ ├── framework │ ├── __init__.py │ ├── extension_test.py │ └── smoke_test.py │ ├── model │ ├── __init__.py │ └── mixins_test.py │ └── retry_test.py ├── tox.ini └── workspace.code-workspace /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | charset = utf-8 10 | max_line_length = 88 11 | 12 | [*.{yml,yaml,json,js,css,html}] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # B = bugbear 3 | # E = pycodestyle errors 4 | # F = flake8 pyflakes 5 | # W = pycodestyle warnings 6 | # B9 = bugbear opinions 7 | # ISC = implicit-str-concat 8 | select = B, E, F, W, B9, ISC 9 | ignore = 10 | # slice notation whitespace, invalid 11 | E203 12 | # line length, handled by bugbear B950 13 | E501 14 | # bare except, handled by bugbear B001 15 | E722 16 | # bin op line break, invalid 17 | W503 18 | # up to 88 allowed by bugbear B950 19 | max-line-length = 88 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.pyo 4 | *.egg-info/ 5 | dist/ 6 | build/ 7 | docs/_build/ 8 | .tox/ 9 | .idea/ 10 | .pytest_cache/ 11 | .mypy_cache/ 12 | htmlcov/ 13 | .coverage 14 | .coverage.* 15 | .vscode/ 16 | env/ 17 | venv/ 18 | .venv/ 19 | .pdm.toml 20 | pytest-plugin-work 21 | old 22 | examples/two/ 23 | *.sqlite 24 | *.db 25 | .pdm-python -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.8.16 2 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Version 3.0.0 2 | ------------- 3 | 4 | Unreleased 5 | 6 | - New simple implementation of SQL. -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | Copyright 2023 Joe Black 2 | 3 | The MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Quart-SQLAlchemy 2 | ================ 3 | 4 | Quart-SQLAlchemy provides a simple wrapper for SQLAlchemy made for humans. I've kept things as 5 | simple as possible, abstracted much complexity, and implemented everything using the current 6 | best practices recommended by the SQLAlchemy developers and targets version 2.0.x+. As a 7 | convenience, a framework adapter is provided for Quart, but the rest of this library is framework 8 | agnostic. 9 | 10 | The bundled SQLAlchemy object intentionally discards the use of scoped_session and it's async 11 | counterpart. With version 2.x+, it's expected that sessions are short lived and vanilla and 12 | context managers are used for managing sesssion lifecycle. Any operations that intend to change 13 | state should open an explicit transaction using the context manager returned by session.begin(). 14 | This pattern of usage prevents problems like sessions being shared between processes, threads, or 15 | tasks entirely, as opposed to the past conventions of mitigating this type of sharing. Another 16 | best practice is expecting any transaction to intermittently fail, and structuring your logic to 17 | automatically perform retries. You can find the retrying session context managers in the retry 18 | module. 19 | 20 | Installing 21 | ---------- 22 | 23 | Install and update using `pip`_: 24 | 25 | .. code-block:: text 26 | 27 | $ pip install quart-sqlalchemy 28 | 29 | .. _pip: https://pip.pypa.io/en/stable/getting-started/ 30 | 31 | 32 | Install the latest release with unreleased pytest-asyncio fixes: 33 | 34 | .. code-block:: text 35 | 36 | $ pip install git+ssh://git@github.com/joeblackwaslike/quart-sqlalchemy.git#egg=quart_sqlalchemy 37 | 38 | Install a wheel from our releases: 39 | 40 | .. code-block:: text 41 | 42 | $ pip install https://github.com/joeblackwaslike/quart-sqlalchemy/releases/download/v3.0.1/quart_sqlalchemy-3.0.1-py3-none-any.whl 43 | 44 | 45 | Add to requirements.txt: 46 | 47 | .. code-block:: text 48 | 49 | quart-sqlalchemy @ https://github.com/joeblackwaslike/quart-sqlalchemy/releases/download/v3.0.1/quart_sqlalchemy-3.0.1-py3-none-any.whl 50 | 51 | 52 | A Simple Example 53 | ---------------- 54 | 55 | .. code-block:: python 56 | 57 | import sqlalchemy as sa 58 | import sqlalchemy.orm 59 | from sqlalchemy.orm import Mapped, mapped_column 60 | from quart import Quart 61 | 62 | from quart_sqlalchemy import SQLAlchemyConfig 63 | from quart_sqlalchemy.framework import QuartSQLAlchemy 64 | 65 | app = Quart(__name__) 66 | 67 | db = QuartSQLAlchemy( 68 | config=SQLAlchemyConfig( 69 | binds=dict( 70 | default=dict( 71 | engine=dict( 72 | url="sqlite:///", 73 | echo=True, 74 | connect_args=dict(check_same_thread=False), 75 | ), 76 | session=dict( 77 | expire_on_commit=False, 78 | ), 79 | ) 80 | ) 81 | ), 82 | app=app, 83 | ) 84 | 85 | class User(db.Model) 86 | __tablename__ = "user" 87 | 88 | id: Mapped[int] = mapped_column(sa.Identity(), primary_key=True, autoincrement=True) 89 | username: Mapped[str] = mapped_column(default="default") 90 | 91 | db.create_all() 92 | 93 | with db.bind.Session() as s: 94 | with s.begin(): 95 | user = User(username="example") 96 | s.add(user) 97 | s.flush() 98 | s.refresh(user) 99 | 100 | users = s.scalars(sa.select(User)).all() 101 | 102 | print(user, users) 103 | assert user in users 104 | 105 | Contributing 106 | ------------ 107 | 108 | For guidance on setting up a development environment and how to make a 109 | contribution to Quart-SQLAlchemy, see the `contributing guidelines`_. 110 | 111 | .. _contributing guidelines: https://github.com/joeblackwaslike/quart-sqlalchemy/blob/main/CONTRIBUTING.rst 112 | -------------------------------------------------------------------------------- /examples/eager_loading.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | 5 | import sqlalchemy 6 | import sqlalchemy.orm 7 | import sqlalchemy.sql.selectable 8 | from sqlalchemy.orm import Mapped, mapped_column 9 | 10 | sa = sqlalchemy 11 | 12 | engine = sa.create_engine("sqlite://", echo=False) 13 | Base = sa.orm.declarative_base() 14 | Session = sa.orm.sessionmaker(bind=engine, expire_on_commit=False) 15 | 16 | 17 | class User(Base): 18 | __tablename__ = "user" 19 | 20 | id: Mapped[int] = mapped_column(sa.Identity(), primary_key=True) 21 | name: Mapped[str] = mapped_column(sa.String(255), nullable=True) 22 | time_created: Mapped[datetime] = mapped_column( 23 | default=sa.func.now(), 24 | server_default=sa.FetchedValue(), 25 | ) 26 | time_updated: Mapped[datetime] = mapped_column( 27 | default=sa.func.now(), 28 | onupdate=sa.func.now(), 29 | server_default=sa.FetchedValue(), 30 | server_onupdate=sa.FetchedValue(), 31 | ) 32 | 33 | posts = sa.orm.relationship("Post", back_populates="user") 34 | 35 | 36 | class Post(Base): 37 | __tablename__ = "post" 38 | 39 | id: Mapped[int] = mapped_column(sa.Identity(), primary_key=True) 40 | user_id: Mapped[int] = mapped_column(sa.ForeignKey("user.id"), nullable=True) 41 | 42 | user = sa.orm.relationship("User", back_populates="posts", uselist=False) 43 | 44 | 45 | Base.metadata.create_all(bind=engine) 46 | 47 | with Session() as session: 48 | with session.begin(): 49 | user = User(name="joe", posts=[Post(), Post()]) 50 | session.add(user) 51 | session.flush() 52 | session.refresh(user) 53 | 54 | 55 | with Session() as session: 56 | statement = sa.select(User).where(User.id == user.id) 57 | eager_statement = statement.options(sa.orm.joinedload(User.posts)) 58 | 59 | print(user.id, user.time_created, user.time_updated) 60 | 61 | with Session() as session: 62 | with session.begin(): 63 | user = session.get(User, user.id) 64 | new_user = session.merge(User(id=user.id, name="new")) 65 | session.flush() 66 | session.refresh(new_user) 67 | 68 | # time_updated not fetched, needs to be refreshed 69 | # print(new_user.name, new_user.time_created) 70 | 71 | print(user.id, user.name, user.time_created, user.time_updated) 72 | 73 | with Session() as session: 74 | user = session.get(User, new_user.id) 75 | 76 | print(user.id, user.name, user.time_created, user.time_updated) 77 | 78 | >>> print(user.id, user.time_created, user.time_updated) 79 | '1 2023-03-21 18:02:56 2023-03-21 18:02:56' -------------------------------------------------------------------------------- /examples/repository/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from abc import ABCMeta 5 | from abc import abstractmethod 6 | 7 | import sqlalchemy 8 | import sqlalchemy.event 9 | import sqlalchemy.exc 10 | import sqlalchemy.orm 11 | import sqlalchemy.sql 12 | from builder import StatementBuilder 13 | 14 | from quart_sqlalchemy.types import ColumnExpr 15 | from quart_sqlalchemy.types import EntityIdT 16 | from quart_sqlalchemy.types import EntityT 17 | from quart_sqlalchemy.types import ORMOption 18 | from quart_sqlalchemy.types import Selectable 19 | 20 | 21 | sa = sqlalchemy 22 | 23 | 24 | class AbstractRepository(t.Generic[EntityT, EntityIdT], metaclass=ABCMeta): 25 | """A repository interface.""" 26 | 27 | identity: t.Type[EntityIdT] 28 | 29 | # def __init__(self, model: t.Type[EntityT]): 30 | # self.model = model 31 | 32 | @property 33 | def model(self) -> EntityT: 34 | return self.__orig_class__.__args__[0] 35 | 36 | @abstractmethod 37 | def insert(self, values: t.Dict[str, t.Any]) -> EntityT: 38 | """Add `values` to the collection.""" 39 | 40 | @abstractmethod 41 | def update(self, id_: EntityIdT, values: t.Dict[str, t.Any]) -> EntityT: 42 | """Update model with model_id using values.""" 43 | 44 | @abstractmethod 45 | def merge( 46 | self, id_: EntityIdT, values: t.Dict[str, t.Any], for_update: bool = False 47 | ) -> EntityT: 48 | """Merge model with model_id using values.""" 49 | 50 | @abstractmethod 51 | def get( 52 | self, 53 | id_: EntityIdT, 54 | options: t.Sequence[ORMOption] = (), 55 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 56 | for_update: bool = False, 57 | include_inactive: bool = False, 58 | ) -> t.Optional[EntityT]: 59 | """Get model with model_id.""" 60 | 61 | @abstractmethod 62 | def select( 63 | self, 64 | selectables: t.Sequence[Selectable] = (), 65 | conditions: t.Sequence[ColumnExpr] = (), 66 | group_by: t.Sequence[t.Union[ColumnExpr, str]] = (), 67 | order_by: t.Sequence[t.Union[ColumnExpr, str]] = (), 68 | options: t.Sequence[ORMOption] = (), 69 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 70 | offset: t.Optional[int] = None, 71 | limit: t.Optional[int] = None, 72 | distinct: bool = False, 73 | for_update: bool = False, 74 | include_inactive: bool = False, 75 | yield_by_chunk: t.Optional[int] = None, 76 | ) -> t.Union[sa.ScalarResult[EntityT], t.Iterator[t.Sequence[EntityT]]]: 77 | """Select models matching conditions.""" 78 | 79 | @abstractmethod 80 | def delete(self, id_: EntityIdT) -> None: 81 | """Delete model with id_.""" 82 | 83 | @abstractmethod 84 | def exists( 85 | self, 86 | conditions: t.Sequence[ColumnExpr] = (), 87 | for_update: bool = False, 88 | include_inactive: bool = False, 89 | ) -> bool: 90 | """Return the existence of an object matching conditions.""" 91 | 92 | @abstractmethod 93 | def deactivate(self, id_: EntityIdT) -> EntityT: 94 | """Soft-Delete model with id_.""" 95 | 96 | @abstractmethod 97 | def reactivate(self, id_: EntityIdT) -> EntityT: 98 | """Soft-Delete model with id_.""" 99 | 100 | 101 | class AbstractBulkRepository(t.Generic[EntityT, EntityIdT], metaclass=ABCMeta): 102 | """A repository interface for bulk operations. 103 | 104 | Note: this interface circumvents ORM internals, breaking commonly expected behavior in order 105 | to gain performance benefits. Only use this class whenever absolutely necessary. 106 | """ 107 | 108 | model: t.Type[EntityT] 109 | builder: StatementBuilder 110 | 111 | @abstractmethod 112 | def bulk_insert( 113 | self, 114 | values: t.Sequence[t.Dict[str, t.Any]] = (), 115 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 116 | ) -> sa.Result[t.Any]: 117 | ... 118 | 119 | @abstractmethod 120 | def bulk_update( 121 | self, 122 | conditions: t.Sequence[ColumnExpr] = (), 123 | values: t.Optional[t.Dict[str, t.Any]] = None, 124 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 125 | ) -> sa.Result[t.Any]: 126 | ... 127 | 128 | @abstractmethod 129 | def bulk_delete( 130 | self, 131 | conditions: t.Sequence[ColumnExpr] = (), 132 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 133 | ) -> sa.Result[t.Any]: 134 | ... 135 | -------------------------------------------------------------------------------- /examples/repository/builder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import sqlalchemy 6 | import sqlalchemy.event 7 | import sqlalchemy.exc 8 | import sqlalchemy.orm 9 | import sqlalchemy.sql 10 | from sqlalchemy.orm.interfaces import ORMOption 11 | 12 | from quart_sqlalchemy.types import ColumnExpr 13 | from quart_sqlalchemy.types import DMLTable 14 | from quart_sqlalchemy.types import EntityT 15 | from quart_sqlalchemy.types import Selectable 16 | 17 | 18 | sa = sqlalchemy 19 | 20 | 21 | class StatementBuilder(t.Generic[EntityT]): 22 | model: t.Type[EntityT] 23 | 24 | def __init__(self, model: t.Type[EntityT]): 25 | self.model = model 26 | 27 | def complex_select( 28 | self, 29 | selectables: t.Sequence[Selectable] = (), 30 | conditions: t.Sequence[ColumnExpr] = (), 31 | group_by: t.Sequence[t.Union[ColumnExpr, str]] = (), 32 | order_by: t.Sequence[t.Union[ColumnExpr, str]] = (), 33 | options: t.Sequence[ORMOption] = (), 34 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 35 | offset: t.Optional[int] = None, 36 | limit: t.Optional[int] = None, 37 | distinct: bool = False, 38 | for_update: bool = False, 39 | ) -> sa.Select: 40 | statement = sa.select(*selectables or self.model).where(*conditions) 41 | 42 | if for_update: 43 | statement = statement.with_for_update() 44 | if offset: 45 | statement = statement.offset(offset) 46 | if limit: 47 | statement = statement.limit(limit) 48 | if group_by: 49 | statement = statement.group_by(*group_by) 50 | if order_by: 51 | statement = statement.order_by(*order_by) 52 | 53 | for option in options: 54 | for context in option.context: 55 | for strategy in context.strategy: 56 | if "joined" in strategy: 57 | distinct = True 58 | 59 | statement = statement.options(option) 60 | 61 | if distinct: 62 | statement = statement.distinct() 63 | 64 | if execution_options: 65 | statement = statement.execution_options(**execution_options) 66 | 67 | return statement 68 | 69 | def insert( 70 | self, 71 | target: t.Optional[DMLTable] = None, 72 | values: t.Optional[t.Dict[str, t.Any]] = None, 73 | ) -> sa.Insert: 74 | return sa.insert(target or self.model).values(**values or {}) 75 | 76 | def bulk_insert( 77 | self, 78 | target: t.Optional[DMLTable] = None, 79 | values: t.Sequence[t.Dict[str, t.Any]] = (), 80 | ) -> sa.Insert: 81 | return sa.insert(target or self.model).values(*values) 82 | 83 | def bulk_update( 84 | self, 85 | target: t.Optional[DMLTable] = None, 86 | conditions: t.Sequence[ColumnExpr] = (), 87 | values: t.Optional[t.Dict[str, t.Any]] = None, 88 | ) -> sa.Update: 89 | return sa.update(target or self.model).where(*conditions).values(**values or {}) 90 | 91 | def bulk_delete( 92 | self, 93 | target: t.Optional[DMLTable] = None, 94 | conditions: t.Sequence[ColumnExpr] = (), 95 | ) -> sa.Delete: 96 | return sa.delete(target or self.model).where(*conditions) 97 | -------------------------------------------------------------------------------- /examples/repository/meta.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | from quart_sqlalchemy.model import SoftDeleteMixin 6 | from quart_sqlalchemy.types import EntityT 7 | from quart_sqlalchemy.util import lazy_property 8 | 9 | 10 | class TableMetadataMixin(t.Generic[EntityT]): 11 | model: type[EntityT] 12 | 13 | @lazy_property 14 | def table(self): 15 | return self.model.__table__ 16 | 17 | @lazy_property 18 | def columns(self): 19 | return self.table 20 | 21 | @lazy_property 22 | def primary_keys(self): 23 | return set([column.name for column in self.model.__table__.primary_key.columns.values()]) 24 | 25 | @lazy_property 26 | def required_keys(self): 27 | return set( 28 | [ 29 | column.name 30 | for column in self.columns 31 | if not column.nullable and column.name not in self.primary_keys 32 | ] 33 | ) 34 | 35 | @lazy_property 36 | def has_soft_delete(self): 37 | return issubclass(self.model, SoftDeleteMixin) or hasattr(self.model, "is_active") 38 | -------------------------------------------------------------------------------- /examples/repository/sqla.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import sqlalchemy 6 | import sqlalchemy.event 7 | import sqlalchemy.exc 8 | import sqlalchemy.orm 9 | import sqlalchemy.sql 10 | from builder import StatementBuilder 11 | from meta import TableMetadataMixin 12 | 13 | from base import AbstractBulkRepository 14 | from base import AbstractRepository 15 | from quart_sqlalchemy.types import ColumnExpr 16 | from quart_sqlalchemy.types import EntityIdT 17 | from quart_sqlalchemy.types import EntityT 18 | from quart_sqlalchemy.types import ORMOption 19 | from quart_sqlalchemy.types import Selectable 20 | from quart_sqlalchemy.types import SessionT 21 | 22 | 23 | sa = sqlalchemy 24 | 25 | 26 | class SQLAlchemyRepository( 27 | TableMetadataMixin, 28 | AbstractRepository[EntityT, EntityIdT], 29 | t.Generic[EntityT, EntityIdT], 30 | ): 31 | """A repository that uses SQLAlchemy to persist data. 32 | 33 | The biggest change with this repository is that for methods returning multiple results, we 34 | return the sa.ScalarResult so that the caller has maximum flexibility in how it's consumed. 35 | 36 | As a result, when calling a method such as get_by, you then need to decide how to fetch the 37 | result. 38 | 39 | Methods of fetching results: 40 | - .all() to return a list of results 41 | - .first() to return the first result 42 | - .one() to return the first result or raise an exception if there are no results 43 | - .one_or_none() to return the first result or None if there are no results 44 | - .partitions(n) to return a results as a list of n-sized sublists 45 | 46 | Additionally, there are methods for transforming the results prior to fetching. 47 | 48 | Methods of transforming results: 49 | - .unique() to apply unique filtering to the result 50 | 51 | """ 52 | 53 | session: sa.orm.Session 54 | builder: StatementBuilder 55 | 56 | def __init__(self, session: sa.orm.Session, **kwargs): 57 | super().__init__(**kwargs) 58 | self.session = session 59 | self.builder = StatementBuilder(None) 60 | 61 | def insert(self, values: t.Dict[str, t.Any]) -> EntityT: 62 | """Insert a new model into the database.""" 63 | new = self.model(**values) 64 | self.session.add(new) 65 | self.session.flush() 66 | self.session.refresh(new) 67 | return new 68 | 69 | def update(self, id_: EntityIdT, values: t.Dict[str, t.Any]) -> EntityT: 70 | """Update existing model with new values.""" 71 | obj = self.session.get(self.model, id_) 72 | if obj is None: 73 | raise ValueError(f"Object with id {id_} not found") 74 | for field, value in values.items(): 75 | if getattr(obj, field) != value: 76 | setattr(obj, field, value) 77 | self.session.flush() 78 | self.session.refresh(obj) 79 | return obj 80 | 81 | def merge( 82 | self, id_: EntityIdT, values: t.Dict[str, t.Any], for_update: bool = False 83 | ) -> EntityT: 84 | """Merge model in session/db having id_ with values.""" 85 | self.session.get(self.model, id_) 86 | values.update(id=id_) 87 | merged = self.session.merge(self.model(**values)) 88 | self.session.flush() 89 | self.session.refresh(merged, with_for_update=for_update) # type: ignore 90 | return merged 91 | 92 | def get( 93 | self, 94 | id_: EntityIdT, 95 | options: t.Sequence[ORMOption] = (), 96 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 97 | for_update: bool = False, 98 | include_inactive: bool = False, 99 | ) -> t.Optional[EntityT]: 100 | """Get object identified by id_ from the database. 101 | 102 | Note: It's a common misconception that session.get(Model, id) is akin to a shortcut for 103 | a select(Model).where(Model.id == id) like statement. However this is not the case. 104 | 105 | Session.get is actually used for looking an object up in the sessions identity map. When 106 | present it will be returned directly, when not, a database lookup will be performed. 107 | 108 | For use cases where this is what you actually want, you can still access the original get 109 | method on self.session. For most uses cases, this behavior can introduce non-determinism 110 | and because of that this method performs lookup using a select statement. Additionally, 111 | to satisfy the expected interface's return type: Optional[EntityT], one_or_none is called 112 | on the result before returning. 113 | """ 114 | execution_options = execution_options or {} 115 | if include_inactive: 116 | execution_options.setdefault("include_inactive", include_inactive) 117 | 118 | statement = sa.select(self.model).where(self.model.id == id_).limit(1) # type: ignore 119 | 120 | for option in options: 121 | statement = statement.options(option) 122 | 123 | if for_update: 124 | statement = statement.with_for_update() 125 | 126 | return self.session.scalars(statement, execution_options=execution_options).one_or_none() 127 | 128 | def select( 129 | self, 130 | selectables: t.Sequence[Selectable] = (), 131 | conditions: t.Sequence[ColumnExpr] = (), 132 | group_by: t.Sequence[t.Union[ColumnExpr, str]] = (), 133 | order_by: t.Sequence[t.Union[ColumnExpr, str]] = (), 134 | options: t.Sequence[ORMOption] = (), 135 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 136 | offset: t.Optional[int] = None, 137 | limit: t.Optional[int] = None, 138 | distinct: bool = False, 139 | for_update: bool = False, 140 | include_inactive: bool = False, 141 | yield_by_chunk: t.Optional[int] = None, 142 | ) -> t.Union[sa.ScalarResult[EntityT], t.Iterator[t.Sequence[EntityT]]]: 143 | """Select from the database. 144 | 145 | Note: yield_by_chunk is not compatible with the subquery and joined loader strategies, use selectinload for eager loading. 146 | """ 147 | selectables = selectables or (self.model,) # type: ignore 148 | 149 | execution_options = execution_options or {} 150 | if include_inactive: 151 | execution_options.setdefault("include_inactive", include_inactive) 152 | if yield_by_chunk: 153 | execution_options.setdefault("yield_per", yield_by_chunk) 154 | 155 | statement = self.builder.complex_select( 156 | selectables, 157 | conditions=conditions, 158 | group_by=group_by, 159 | order_by=order_by, 160 | options=options, 161 | execution_options=execution_options, 162 | offset=offset, 163 | limit=limit, 164 | distinct=distinct, 165 | for_update=for_update, 166 | ) 167 | 168 | results = self.session.scalars(statement) 169 | if yield_by_chunk: 170 | results = results.partitions() 171 | return results 172 | 173 | def delete(self, id_: EntityIdT) -> None: 174 | if self.has_soft_delete: 175 | raise RuntimeError("Can't delete entity that uses soft-delete semantics.") 176 | 177 | entity = self.get(id_) 178 | if not entity: 179 | raise RuntimeError(f"Entity with id {id_} not found.") 180 | 181 | self.session.delete(entity) 182 | self.session.flush() 183 | 184 | def deactivate(self, id_: EntityIdT) -> EntityT: 185 | if not self.has_soft_delete: 186 | raise RuntimeError("Can't delete entity that uses soft-delete semantics.") 187 | 188 | return self.update(id_, dict(is_active=False)) 189 | 190 | def reactivate(self, id_: EntityIdT) -> EntityT: 191 | if not self.has_soft_delete: 192 | raise RuntimeError("Can't delete entity that uses soft-delete semantics.") 193 | 194 | return self.update(id_, dict(is_active=False)) 195 | 196 | def exists( 197 | self, 198 | conditions: t.Sequence[ColumnExpr] = (), 199 | for_update: bool = False, 200 | include_inactive: bool = False, 201 | ) -> bool: 202 | """Return whether an object matching conditions exists. 203 | 204 | Note: This performs better than simply trying to select an object since there is no 205 | overhead in sending the selected object and deserializing it. 206 | """ 207 | selectable = sa.sql.literal(True) 208 | 209 | execution_options = {} 210 | if include_inactive: 211 | execution_options.setdefault("include_inactive", include_inactive) 212 | 213 | statement = sa.select(selectable).where(*conditions) # type: ignore 214 | 215 | if for_update: 216 | statement = statement.with_for_update() 217 | 218 | result = self.session.execute(statement, execution_options=execution_options).scalar() 219 | 220 | return bool(result) 221 | 222 | # def get_or_insert( 223 | # self, 224 | # values: t.Dict[str, t.Any], 225 | # unique_columns: t.Sequence[ColumnExpr] = (), 226 | # execution_options: t.Optional[t.Dict[str, t.Any]] = None, 227 | # include_inactive: bool = False, 228 | # ) -> EntityT: 229 | # """Add `data` to the collection.""" 230 | # selectable = self.model 231 | 232 | # execution_options = {} 233 | # if include_inactive: 234 | # execution_options.setdefault("include_inactive", include_inactive) 235 | 236 | # lookup_conditions = {field: val for field, val in values.items() if field in unique_columns} 237 | # lookup_statement = sa.select(selectable).filter_by(*lookup_conditions) 238 | # try: 239 | # return self.session.scalars(lookup_statement, execution_options=execution_options).one() 240 | # except sa.orm.exc.NoResultFound: 241 | # obj = self.model(**values) 242 | # self.session.add(obj) 243 | # try: 244 | # with self.session.begin_nested(): 245 | # self.session.flush() 246 | # except sa.exc.IntegrityError: 247 | # self.session.rollback() 248 | # try: 249 | # self.session.scalars(sa.select(selectable).filter_by(*lookup_conditions)).one() 250 | # except sa.orm.exc.NoResultFound: 251 | # raise 252 | # else: 253 | # return obj 254 | # else: 255 | # return obj 256 | 257 | 258 | class SQLAlchemyBulkRepository(AbstractBulkRepository, t.Generic[SessionT, EntityT, EntityIdT]): 259 | def __init__(self, session: SessionT, **kwargs: t.Any): 260 | super().__init__(**kwargs) 261 | self.builder = StatementBuilder(self.model) 262 | self.session = session 263 | 264 | def bulk_insert( 265 | self, 266 | values: t.Sequence[t.Dict[str, t.Any]] = (), 267 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 268 | ) -> sa.Result[t.Any]: 269 | statement = self.builder.bulk_insert(self.model, values) 270 | return self.session.execute(statement, execution_options=execution_options or {}) 271 | 272 | def bulk_update( 273 | self, 274 | conditions: t.Sequence[ColumnExpr] = (), 275 | values: t.Optional[t.Dict[str, t.Any]] = None, 276 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 277 | ) -> sa.Result[t.Any]: 278 | statement = self.builder.bulk_update(self.model, conditions, values) 279 | return self.session.execute(statement, execution_options=execution_options or {}) 280 | 281 | def bulk_delete( 282 | self, 283 | conditions: t.Sequence[ColumnExpr] = (), 284 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 285 | ) -> sa.Result[t.Any]: 286 | statement = self.builder.bulk_delete(self.model, conditions) 287 | return self.session.execute(statement, execution_options=execution_options or {}) 288 | -------------------------------------------------------------------------------- /examples/testing/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest plugin for quart-sqlalchemy. 2 | 3 | The lifecycle of the database during testing should look something like this. 4 | 5 | 1. The QuartSQLAlchemy object is instantiated by the application in a well known location such as the top level package. Usually named `db`. The database should be a fresh, in-memory, sqlite instance. 6 | 2. The Quart object is instantiated in a pytest fixture `app` using the application factory pattern, inside this factory, db.init_app(app) is called. 7 | 3. The database schema or DDL is executed using something like `db.create_all()`. 8 | 4. The necessary database test fixtures are loaded into the database. 9 | 5. A test transaction is created with savepoint, this transaction should be scoped at the `function` level. 10 | 6. Any calls to Session() should be patched to return a session bound to the test transaction savepoint. 11 | a. Bind.Session: sessionmaker 12 | b. BindContext.Session 13 | c. TestTransaction.Session (already patched) 14 | 7. Engine should be patched to return connections bound to the savepoint transaction but this is too complex to be in scope. 15 | 8. The test is run. 16 | 9. The test transaction goes out of function scope and rolls back the database. 17 | 10. The test transaction is recreated for the next test and so on until the pytest session is closed. 18 | """ 19 | 20 | import typing as t 21 | 22 | import pytest 23 | import sqlalchemy 24 | import sqlalchemy.orm 25 | from quart import Quart 26 | 27 | 28 | sa = sqlalchemy 29 | 30 | from quart_sqlalchemy import AsyncBind 31 | from quart_sqlalchemy import Base 32 | from quart_sqlalchemy import SQLAlchemyConfig 33 | from quart_sqlalchemy.framework import QuartSQLAlchemy 34 | from quart_sqlalchemy.testing import AsyncTestTransaction 35 | from quart_sqlalchemy.testing import TestTransaction 36 | 37 | 38 | default_app = Quart(__name__) 39 | 40 | 41 | @pytest.fixture(scope="session") 42 | def app() -> Quart: 43 | """ 44 | This pytest fixture should return the Quart object 45 | """ 46 | return default_app 47 | 48 | 49 | @pytest.fixture(scope="session") 50 | def db_config() -> SQLAlchemyConfig: 51 | """ 52 | This pytest fixture should return the SQLAlchemyConfig object 53 | """ 54 | return SQLAlchemyConfig( 55 | model_class=Base, 56 | binds={ # type: ignore 57 | "default": { 58 | "engine": {"url": "sqlite:///file:mem.db?mode=memory&cache=shared&uri=true"}, 59 | "session": {"expire_on_commit": False}, 60 | }, 61 | "read-replica": { 62 | "engine": {"url": "sqlite:///file:mem.db?mode=memory&cache=shared&uri=true"}, 63 | "session": {"expire_on_commit": False}, 64 | "read_only": True, 65 | }, 66 | "async": { 67 | "engine": { 68 | "url": "sqlite+aiosqlite:///file:mem.db?mode=memory&cache=shared&uri=true" 69 | }, 70 | "session": {"expire_on_commit": False}, 71 | }, 72 | }, 73 | ) 74 | 75 | 76 | @pytest.fixture(scope="session") 77 | @pytest.fixture 78 | def _db(db_config: SQLAlchemyConfig, app: Quart) -> QuartSQLAlchemy: 79 | """ 80 | This pytest fixture should return the QuartSQLAlchemy object 81 | """ 82 | return QuartSQLAlchemy(db_config, app) 83 | 84 | 85 | @pytest.fixture(scope="session", autouse=True) 86 | @pytest.fixture 87 | def database_test_fixtures(_db: QuartSQLAlchemy) -> t.Generator[None, None, None]: 88 | """ 89 | This pytest fixture should use the injected session to load any necessary testing fixtures. 90 | """ 91 | _db.create_all() 92 | 93 | with _db.bind.Session() as s: 94 | with s.begin(): 95 | # add test fixtures to this session 96 | pass 97 | 98 | yield 99 | 100 | _db.drop_all() 101 | 102 | 103 | @pytest.fixture(autouse=True) 104 | def db_test_transaction( 105 | _db: QuartSQLAlchemy, database_test_fixtures: None 106 | ) -> t.Generator[TestTransaction, None, None]: 107 | """ 108 | This pytest fixture should yield a synchronous TestTransaction 109 | """ 110 | with _db.bind.test_transaction(savepoint=True) as test_transaction: 111 | yield test_transaction 112 | 113 | 114 | @pytest.fixture(autouse=True) 115 | async def async_db_test_transaction( 116 | _db: QuartSQLAlchemy, database_test_fixtures: None 117 | ) -> t.AsyncGenerator[TestTransaction, None]: 118 | """ 119 | This pytest fixture should yield an asynchronous TestTransaction 120 | """ 121 | async_bind: AsyncBind = _db.get_bind("async") # type: ignore 122 | async with async_bind.test_transaction(savepoint=True) as async_test_transaction: 123 | yield async_test_transaction 124 | 125 | 126 | @pytest.fixture(autouse=True) 127 | def patch_sessionmakers( 128 | _db: QuartSQLAlchemy, 129 | db_test_transaction: TestTransaction, 130 | async_db_test_transaction: AsyncTestTransaction, 131 | monkeypatch, 132 | ) -> t.Generator[None, None, None]: 133 | for bind in _db.binds.values(): 134 | if isinstance(bind, AsyncBind): 135 | savepoint_bound_session = async_db_test_transaction.Session 136 | else: 137 | savepoint_bound_session = db_test_transaction.Session 138 | 139 | monkeypatch.setattr(bind, "Session", savepoint_bound_session) 140 | 141 | yield 142 | 143 | 144 | @pytest.fixture(name="db", autouse=True) 145 | def patched_db(_db, patch_sessionmakers): 146 | return _db 147 | -------------------------------------------------------------------------------- /pdm.lock: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [metadata] 5 | groups = ["default", "dev"] 6 | strategy = ["cross_platform"] 7 | lock_version = "4.4" 8 | content_hash = "sha256:a0cf470d02a1e0b624d01c081b336b6e6d3014a5bbf84dcf8185c2093ed455ef" 9 | 10 | [[package]] 11 | name = "aiofiles" 12 | version = "23.2.1" 13 | requires_python = ">=3.7" 14 | summary = "File support for asyncio." 15 | files = [ 16 | {file = "aiofiles-23.2.1-py3-none-any.whl", hash = "sha256:19297512c647d4b27a2cf7c34caa7e405c0d60b5560618a29a9fe027b18b0107"}, 17 | {file = "aiofiles-23.2.1.tar.gz", hash = "sha256:84ec2218d8419404abcb9f0c02df3f34c6e0a68ed41072acfb1cef5cbc29051a"}, 18 | ] 19 | 20 | [[package]] 21 | name = "aiosqlite" 22 | version = "0.19.0" 23 | requires_python = ">=3.7" 24 | summary = "asyncio bridge to the standard sqlite3 module" 25 | files = [ 26 | {file = "aiosqlite-0.19.0-py3-none-any.whl", hash = "sha256:edba222e03453e094a3ce605db1b970c4b3376264e56f32e2a4959f948d66a96"}, 27 | {file = "aiosqlite-0.19.0.tar.gz", hash = "sha256:95ee77b91c8d2808bd08a59fbebf66270e9090c3d92ffbf260dc0db0b979577d"}, 28 | ] 29 | 30 | [[package]] 31 | name = "anyio" 32 | version = "3.3.4" 33 | requires_python = ">=3.6.2" 34 | summary = "High level compatibility layer for multiple asynchronous event loop implementations" 35 | dependencies = [ 36 | "idna>=2.8", 37 | "sniffio>=1.1", 38 | ] 39 | files = [ 40 | {file = "anyio-3.3.4-py3-none-any.whl", hash = "sha256:4fd09a25ab7fa01d34512b7249e366cd10358cdafc95022c7ff8c8f8a5026d66"}, 41 | {file = "anyio-3.3.4.tar.gz", hash = "sha256:67da67b5b21f96b9d3d65daa6ea99f5d5282cb09f50eb4456f8fb51dffefc3ff"}, 42 | ] 43 | 44 | [[package]] 45 | name = "blinker" 46 | version = "1.6.3" 47 | requires_python = ">=3.7" 48 | summary = "Fast, simple object-to-object and broadcast signaling" 49 | files = [ 50 | {file = "blinker-1.6.3-py3-none-any.whl", hash = "sha256:296320d6c28b006eb5e32d4712202dbcdcbf5dc482da298c2f44881c43884aaa"}, 51 | {file = "blinker-1.6.3.tar.gz", hash = "sha256:152090d27c1c5c722ee7e48504b02d76502811ce02e1523553b4cf8c8b3d3a8d"}, 52 | ] 53 | 54 | [[package]] 55 | name = "cachetools" 56 | version = "5.3.2" 57 | requires_python = ">=3.7" 58 | summary = "Extensible memoizing collections and decorators" 59 | files = [ 60 | {file = "cachetools-5.3.2-py3-none-any.whl", hash = "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1"}, 61 | {file = "cachetools-5.3.2.tar.gz", hash = "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2"}, 62 | ] 63 | 64 | [[package]] 65 | name = "cfgv" 66 | version = "3.4.0" 67 | requires_python = ">=3.8" 68 | summary = "Validate configuration and produce human readable error messages." 69 | files = [ 70 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 71 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 72 | ] 73 | 74 | [[package]] 75 | name = "chardet" 76 | version = "5.2.0" 77 | requires_python = ">=3.7" 78 | summary = "Universal encoding detector for Python 3" 79 | files = [ 80 | {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, 81 | {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, 82 | ] 83 | 84 | [[package]] 85 | name = "click" 86 | version = "8.1.7" 87 | requires_python = ">=3.7" 88 | summary = "Composable command line interface toolkit" 89 | dependencies = [ 90 | "colorama; platform_system == \"Windows\"", 91 | ] 92 | files = [ 93 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 94 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 95 | ] 96 | 97 | [[package]] 98 | name = "colorama" 99 | version = "0.4.6" 100 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 101 | summary = "Cross-platform colored terminal text." 102 | files = [ 103 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 104 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 105 | ] 106 | 107 | [[package]] 108 | name = "coverage" 109 | version = "7.3.2" 110 | requires_python = ">=3.8" 111 | summary = "Code coverage measurement for Python" 112 | files = [ 113 | {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, 114 | {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, 115 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, 116 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, 117 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, 118 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, 119 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, 120 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, 121 | {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, 122 | {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, 123 | {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, 124 | {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, 125 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, 126 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, 127 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, 128 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, 129 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, 130 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, 131 | {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, 132 | {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, 133 | {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, 134 | {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, 135 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, 136 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, 137 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, 138 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, 139 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, 140 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, 141 | {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, 142 | {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, 143 | {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, 144 | {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, 145 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, 146 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, 147 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, 148 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, 149 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, 150 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, 151 | {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, 152 | {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, 153 | {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, 154 | {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, 155 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, 156 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, 157 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, 158 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, 159 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, 160 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, 161 | {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, 162 | {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, 163 | {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, 164 | {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, 165 | ] 166 | 167 | [[package]] 168 | name = "coverage" 169 | version = "7.3.2" 170 | extras = ["toml"] 171 | requires_python = ">=3.8" 172 | summary = "Code coverage measurement for Python" 173 | dependencies = [ 174 | "coverage==7.3.2", 175 | "tomli; python_full_version <= \"3.11.0a6\"", 176 | ] 177 | files = [ 178 | {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, 179 | {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, 180 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, 181 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, 182 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, 183 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, 184 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, 185 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, 186 | {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, 187 | {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, 188 | {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, 189 | {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, 190 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, 191 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, 192 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, 193 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, 194 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, 195 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, 196 | {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, 197 | {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, 198 | {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, 199 | {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, 200 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, 201 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, 202 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, 203 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, 204 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, 205 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, 206 | {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, 207 | {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, 208 | {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, 209 | {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, 210 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, 211 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, 212 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, 213 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, 214 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, 215 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, 216 | {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, 217 | {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, 218 | {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, 219 | {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, 220 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, 221 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, 222 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, 223 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, 224 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, 225 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, 226 | {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, 227 | {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, 228 | {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, 229 | {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, 230 | ] 231 | 232 | [[package]] 233 | name = "distlib" 234 | version = "0.3.7" 235 | summary = "Distribution utilities" 236 | files = [ 237 | {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, 238 | {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, 239 | ] 240 | 241 | [[package]] 242 | name = "exceptiongroup" 243 | version = "1.1.3" 244 | requires_python = ">=3.7" 245 | summary = "Backport of PEP 654 (exception groups)" 246 | files = [ 247 | {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, 248 | {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, 249 | ] 250 | 251 | [[package]] 252 | name = "filelock" 253 | version = "3.13.1" 254 | requires_python = ">=3.8" 255 | summary = "A platform independent file lock." 256 | files = [ 257 | {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, 258 | {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, 259 | ] 260 | 261 | [[package]] 262 | name = "flask" 263 | version = "3.0.0" 264 | requires_python = ">=3.8" 265 | summary = "A simple framework for building complex web applications." 266 | dependencies = [ 267 | "Jinja2>=3.1.2", 268 | "Werkzeug>=3.0.0", 269 | "blinker>=1.6.2", 270 | "click>=8.1.3", 271 | "importlib-metadata>=3.6.0; python_version < \"3.10\"", 272 | "itsdangerous>=2.1.2", 273 | ] 274 | files = [ 275 | {file = "flask-3.0.0-py3-none-any.whl", hash = "sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638"}, 276 | {file = "flask-3.0.0.tar.gz", hash = "sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58"}, 277 | ] 278 | 279 | [[package]] 280 | name = "greenlet" 281 | version = "3.0.1" 282 | requires_python = ">=3.7" 283 | summary = "Lightweight in-process concurrent programming" 284 | files = [ 285 | {file = "greenlet-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f89e21afe925fcfa655965ca8ea10f24773a1791400989ff32f467badfe4a064"}, 286 | {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28e89e232c7593d33cac35425b58950789962011cc274aa43ef8865f2e11f46d"}, 287 | {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8ba29306c5de7717b5761b9ea74f9c72b9e2b834e24aa984da99cbfc70157fd"}, 288 | {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19bbdf1cce0346ef7341705d71e2ecf6f41a35c311137f29b8a2dc2341374565"}, 289 | {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:599daf06ea59bfedbec564b1692b0166a0045f32b6f0933b0dd4df59a854caf2"}, 290 | {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b641161c302efbb860ae6b081f406839a8b7d5573f20a455539823802c655f63"}, 291 | {file = "greenlet-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d57e20ba591727da0c230ab2c3f200ac9d6d333860d85348816e1dca4cc4792e"}, 292 | {file = "greenlet-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5805e71e5b570d490938d55552f5a9e10f477c19400c38bf1d5190d760691846"}, 293 | {file = "greenlet-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:52e93b28db27ae7d208748f45d2db8a7b6a380e0d703f099c949d0f0d80b70e9"}, 294 | {file = "greenlet-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f7bfb769f7efa0eefcd039dd19d843a4fbfbac52f1878b1da2ed5793ec9b1a65"}, 295 | {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91e6c7db42638dc45cf2e13c73be16bf83179f7859b07cfc139518941320be96"}, 296 | {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1757936efea16e3f03db20efd0cd50a1c86b06734f9f7338a90c4ba85ec2ad5a"}, 297 | {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19075157a10055759066854a973b3d1325d964d498a805bb68a1f9af4aaef8ec"}, 298 | {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9d21aaa84557d64209af04ff48e0ad5e28c5cca67ce43444e939579d085da72"}, 299 | {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2847e5d7beedb8d614186962c3d774d40d3374d580d2cbdab7f184580a39d234"}, 300 | {file = "greenlet-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:97e7ac860d64e2dcba5c5944cfc8fa9ea185cd84061c623536154d5a89237884"}, 301 | {file = "greenlet-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b2c02d2ad98116e914d4f3155ffc905fd0c025d901ead3f6ed07385e19122c94"}, 302 | {file = "greenlet-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:22f79120a24aeeae2b4471c711dcf4f8c736a2bb2fabad2a67ac9a55ea72523c"}, 303 | {file = "greenlet-3.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:100f78a29707ca1525ea47388cec8a049405147719f47ebf3895e7509c6446aa"}, 304 | {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60d5772e8195f4e9ebf74046a9121bbb90090f6550f81d8956a05387ba139353"}, 305 | {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:daa7197b43c707462f06d2c693ffdbb5991cbb8b80b5b984007de431493a319c"}, 306 | {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea6b8aa9e08eea388c5f7a276fabb1d4b6b9d6e4ceb12cc477c3d352001768a9"}, 307 | {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d11ebbd679e927593978aa44c10fc2092bc454b7d13fdc958d3e9d508aba7d0"}, 308 | {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbd4c177afb8a8d9ba348d925b0b67246147af806f0b104af4d24f144d461cd5"}, 309 | {file = "greenlet-3.0.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20107edf7c2c3644c67c12205dc60b1bb11d26b2610b276f97d666110d1b511d"}, 310 | {file = "greenlet-3.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8bef097455dea90ffe855286926ae02d8faa335ed8e4067326257cb571fc1445"}, 311 | {file = "greenlet-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:b2d3337dcfaa99698aa2377c81c9ca72fcd89c07e7eb62ece3f23a3fe89b2ce4"}, 312 | {file = "greenlet-3.0.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:d2905ce1df400360463c772b55d8e2518d0e488a87cdea13dd2c71dcb2a1fa16"}, 313 | {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a02d259510b3630f330c86557331a3b0e0c79dac3d166e449a39363beaae174"}, 314 | {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55d62807f1c5a1682075c62436702aaba941daa316e9161e4b6ccebbbf38bda3"}, 315 | {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fcc780ae8edbb1d050d920ab44790201f027d59fdbd21362340a85c79066a74"}, 316 | {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eddd98afc726f8aee1948858aed9e6feeb1758889dfd869072d4465973f6bfd"}, 317 | {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eabe7090db68c981fca689299c2d116400b553f4b713266b130cfc9e2aa9c5a9"}, 318 | {file = "greenlet-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f2f6d303f3dee132b322a14cd8765287b8f86cdc10d2cb6a6fae234ea488888e"}, 319 | {file = "greenlet-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d923ff276f1c1f9680d32832f8d6c040fe9306cbfb5d161b0911e9634be9ef0a"}, 320 | {file = "greenlet-3.0.1-cp38-cp38-win32.whl", hash = "sha256:0b6f9f8ca7093fd4433472fd99b5650f8a26dcd8ba410e14094c1e44cd3ceddd"}, 321 | {file = "greenlet-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:990066bff27c4fcf3b69382b86f4c99b3652bab2a7e685d968cd4d0cfc6f67c6"}, 322 | {file = "greenlet-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ce85c43ae54845272f6f9cd8320d034d7a946e9773c693b27d620edec825e376"}, 323 | {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ee2e967bd7ff85d84a2de09df10e021c9b38c7d91dead95b406ed6350c6997"}, 324 | {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87c8ceb0cf8a5a51b8008b643844b7f4a8264a2c13fcbcd8a8316161725383fe"}, 325 | {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6a8c9d4f8692917a3dc7eb25a6fb337bff86909febe2f793ec1928cd97bedfc"}, 326 | {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fbc5b8f3dfe24784cee8ce0be3da2d8a79e46a276593db6868382d9c50d97b1"}, 327 | {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85d2b77e7c9382f004b41d9c72c85537fac834fb141b0296942d52bf03fe4a3d"}, 328 | {file = "greenlet-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:696d8e7d82398e810f2b3622b24e87906763b6ebfd90e361e88eb85b0e554dc8"}, 329 | {file = "greenlet-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:329c5a2e5a0ee942f2992c5e3ff40be03e75f745f48847f118a3cfece7a28546"}, 330 | {file = "greenlet-3.0.1-cp39-cp39-win32.whl", hash = "sha256:cf868e08690cb89360eebc73ba4be7fb461cfbc6168dd88e2fbbe6f31812cd57"}, 331 | {file = "greenlet-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:ac4a39d1abae48184d420aa8e5e63efd1b75c8444dd95daa3e03f6c6310e9619"}, 332 | {file = "greenlet-3.0.1.tar.gz", hash = "sha256:816bd9488a94cba78d93e1abb58000e8266fa9cc2aa9ccdd6eb0696acb24005b"}, 333 | ] 334 | 335 | [[package]] 336 | name = "h11" 337 | version = "0.14.0" 338 | requires_python = ">=3.7" 339 | summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 340 | files = [ 341 | {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, 342 | {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, 343 | ] 344 | 345 | [[package]] 346 | name = "h2" 347 | version = "4.1.0" 348 | requires_python = ">=3.6.1" 349 | summary = "HTTP/2 State-Machine based protocol implementation" 350 | dependencies = [ 351 | "hpack<5,>=4.0", 352 | "hyperframe<7,>=6.0", 353 | ] 354 | files = [ 355 | {file = "h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d"}, 356 | {file = "h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb"}, 357 | ] 358 | 359 | [[package]] 360 | name = "hpack" 361 | version = "4.0.0" 362 | requires_python = ">=3.6.1" 363 | summary = "Pure-Python HPACK header compression" 364 | files = [ 365 | {file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"}, 366 | {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, 367 | ] 368 | 369 | [[package]] 370 | name = "hypercorn" 371 | version = "0.15.0" 372 | requires_python = ">=3.7" 373 | summary = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" 374 | dependencies = [ 375 | "h11", 376 | "h2>=3.1.0", 377 | "priority", 378 | "taskgroup; python_version < \"3.11\"", 379 | "tomli; python_version < \"3.11\"", 380 | "wsproto>=0.14.0", 381 | ] 382 | files = [ 383 | {file = "hypercorn-0.15.0-py3-none-any.whl", hash = "sha256:5008944999612fd188d7a1ca02e89d20065642b89503020ac392dfed11840730"}, 384 | {file = "hypercorn-0.15.0.tar.gz", hash = "sha256:d517f68d5dc7afa9a9d50ecefb0f769f466ebe8c1c18d2c2f447a24e763c9a63"}, 385 | ] 386 | 387 | [[package]] 388 | name = "hyperframe" 389 | version = "6.0.1" 390 | requires_python = ">=3.6.1" 391 | summary = "HTTP/2 framing layer for Python" 392 | files = [ 393 | {file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"}, 394 | {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"}, 395 | ] 396 | 397 | [[package]] 398 | name = "identify" 399 | version = "2.5.31" 400 | requires_python = ">=3.8" 401 | summary = "File identification library for Python" 402 | files = [ 403 | {file = "identify-2.5.31-py2.py3-none-any.whl", hash = "sha256:90199cb9e7bd3c5407a9b7e81b4abec4bb9d249991c79439ec8af740afc6293d"}, 404 | {file = "identify-2.5.31.tar.gz", hash = "sha256:7736b3c7a28233637e3c36550646fc6389bedd74ae84cb788200cc8e2dd60b75"}, 405 | ] 406 | 407 | [[package]] 408 | name = "idna" 409 | version = "3.4" 410 | requires_python = ">=3.5" 411 | summary = "Internationalized Domain Names in Applications (IDNA)" 412 | files = [ 413 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 414 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 415 | ] 416 | 417 | [[package]] 418 | name = "importlib-metadata" 419 | version = "6.8.0" 420 | requires_python = ">=3.8" 421 | summary = "Read metadata from Python packages" 422 | dependencies = [ 423 | "zipp>=0.5", 424 | ] 425 | files = [ 426 | {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, 427 | {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, 428 | ] 429 | 430 | [[package]] 431 | name = "iniconfig" 432 | version = "2.0.0" 433 | requires_python = ">=3.7" 434 | summary = "brain-dead simple config-ini parsing" 435 | files = [ 436 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 437 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 438 | ] 439 | 440 | [[package]] 441 | name = "itsdangerous" 442 | version = "2.1.2" 443 | requires_python = ">=3.7" 444 | summary = "Safely pass data to untrusted environments and back." 445 | files = [ 446 | {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, 447 | {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, 448 | ] 449 | 450 | [[package]] 451 | name = "jinja2" 452 | version = "3.1.2" 453 | requires_python = ">=3.7" 454 | summary = "A very fast and expressive template engine." 455 | dependencies = [ 456 | "MarkupSafe>=2.0", 457 | ] 458 | files = [ 459 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 460 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 461 | ] 462 | 463 | [[package]] 464 | name = "markupsafe" 465 | version = "2.1.3" 466 | requires_python = ">=3.7" 467 | summary = "Safely add untrusted strings to HTML/XML markup." 468 | files = [ 469 | {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, 470 | {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, 471 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, 472 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, 473 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, 474 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, 475 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, 476 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, 477 | {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, 478 | {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, 479 | {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, 480 | {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, 481 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, 482 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, 483 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, 484 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, 485 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, 486 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, 487 | {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, 488 | {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, 489 | {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, 490 | {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, 491 | {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, 492 | {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, 493 | {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, 494 | {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, 495 | {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, 496 | {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, 497 | {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, 498 | {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, 499 | {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, 500 | {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, 501 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, 502 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, 503 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, 504 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, 505 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, 506 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, 507 | {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, 508 | {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, 509 | {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, 510 | {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, 511 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, 512 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, 513 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, 514 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, 515 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, 516 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, 517 | {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, 518 | {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, 519 | {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, 520 | ] 521 | 522 | [[package]] 523 | name = "mypy" 524 | version = "1.7.0" 525 | requires_python = ">=3.8" 526 | summary = "Optional static typing for Python" 527 | dependencies = [ 528 | "mypy-extensions>=1.0.0", 529 | "tomli>=1.1.0; python_version < \"3.11\"", 530 | "typing-extensions>=4.1.0", 531 | ] 532 | files = [ 533 | {file = "mypy-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5da84d7bf257fd8f66b4f759a904fd2c5a765f70d8b52dde62b521972a0a2357"}, 534 | {file = "mypy-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a3637c03f4025f6405737570d6cbfa4f1400eb3c649317634d273687a09ffc2f"}, 535 | {file = "mypy-1.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b633f188fc5ae1b6edca39dae566974d7ef4e9aaaae00bc36efe1f855e5173ac"}, 536 | {file = "mypy-1.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d6ed9a3997b90c6f891138e3f83fb8f475c74db4ccaa942a1c7bf99e83a989a1"}, 537 | {file = "mypy-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:1fe46e96ae319df21359c8db77e1aecac8e5949da4773c0274c0ef3d8d1268a9"}, 538 | {file = "mypy-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:df67fbeb666ee8828f675fee724cc2cbd2e4828cc3df56703e02fe6a421b7401"}, 539 | {file = "mypy-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a79cdc12a02eb526d808a32a934c6fe6df07b05f3573d210e41808020aed8b5d"}, 540 | {file = "mypy-1.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f65f385a6f43211effe8c682e8ec3f55d79391f70a201575def73d08db68ead1"}, 541 | {file = "mypy-1.7.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e81ffd120ee24959b449b647c4b2fbfcf8acf3465e082b8d58fd6c4c2b27e46"}, 542 | {file = "mypy-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:f29386804c3577c83d76520abf18cfcd7d68264c7e431c5907d250ab502658ee"}, 543 | {file = "mypy-1.7.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:87c076c174e2c7ef8ab416c4e252d94c08cd4980a10967754f91571070bf5fbe"}, 544 | {file = "mypy-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cb8d5f6d0fcd9e708bb190b224089e45902cacef6f6915481806b0c77f7786d"}, 545 | {file = "mypy-1.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93e76c2256aa50d9c82a88e2f569232e9862c9982095f6d54e13509f01222fc"}, 546 | {file = "mypy-1.7.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cddee95dea7990e2215576fae95f6b78a8c12f4c089d7e4367564704e99118d3"}, 547 | {file = "mypy-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:d01921dbd691c4061a3e2ecdbfbfad029410c5c2b1ee88946bf45c62c6c91210"}, 548 | {file = "mypy-1.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:185cff9b9a7fec1f9f7d8352dff8a4c713b2e3eea9c6c4b5ff7f0edf46b91e41"}, 549 | {file = "mypy-1.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7a7b1e399c47b18feb6f8ad4a3eef3813e28c1e871ea7d4ea5d444b2ac03c418"}, 550 | {file = "mypy-1.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc9fe455ad58a20ec68599139ed1113b21f977b536a91b42bef3ffed5cce7391"}, 551 | {file = "mypy-1.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d0fa29919d2e720c8dbaf07d5578f93d7b313c3e9954c8ec05b6d83da592e5d9"}, 552 | {file = "mypy-1.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b53655a295c1ed1af9e96b462a736bf083adba7b314ae775563e3fb4e6795f5"}, 553 | {file = "mypy-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c1b06b4b109e342f7dccc9efda965fc3970a604db70f8560ddfdee7ef19afb05"}, 554 | {file = "mypy-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bf7a2f0a6907f231d5e41adba1a82d7d88cf1f61a70335889412dec99feeb0f8"}, 555 | {file = "mypy-1.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:551d4a0cdcbd1d2cccdcc7cb516bb4ae888794929f5b040bb51aae1846062901"}, 556 | {file = "mypy-1.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:55d28d7963bef00c330cb6461db80b0b72afe2f3c4e2963c99517cf06454e665"}, 557 | {file = "mypy-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:870bd1ffc8a5862e593185a4c169804f2744112b4a7c55b93eb50f48e7a77010"}, 558 | {file = "mypy-1.7.0-py3-none-any.whl", hash = "sha256:96650d9a4c651bc2a4991cf46f100973f656d69edc7faf91844e87fe627f7e96"}, 559 | {file = "mypy-1.7.0.tar.gz", hash = "sha256:1e280b5697202efa698372d2f39e9a6713a0395a756b1c6bd48995f8d72690dc"}, 560 | ] 561 | 562 | [[package]] 563 | name = "mypy-extensions" 564 | version = "1.0.0" 565 | requires_python = ">=3.5" 566 | summary = "Type system extensions for programs checked with the mypy type checker." 567 | files = [ 568 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 569 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 570 | ] 571 | 572 | [[package]] 573 | name = "nodeenv" 574 | version = "1.8.0" 575 | requires_python = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 576 | summary = "Node.js virtual environment builder" 577 | dependencies = [ 578 | "setuptools", 579 | ] 580 | files = [ 581 | {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, 582 | {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, 583 | ] 584 | 585 | [[package]] 586 | name = "packaging" 587 | version = "23.2" 588 | requires_python = ">=3.7" 589 | summary = "Core utilities for Python packages" 590 | files = [ 591 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 592 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 593 | ] 594 | 595 | [[package]] 596 | name = "platformdirs" 597 | version = "3.11.0" 598 | requires_python = ">=3.7" 599 | summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 600 | files = [ 601 | {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, 602 | {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, 603 | ] 604 | 605 | [[package]] 606 | name = "pluggy" 607 | version = "1.3.0" 608 | requires_python = ">=3.8" 609 | summary = "plugin and hook calling mechanisms for python" 610 | files = [ 611 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 612 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 613 | ] 614 | 615 | [[package]] 616 | name = "pre-commit" 617 | version = "3.5.0" 618 | requires_python = ">=3.8" 619 | summary = "A framework for managing and maintaining multi-language pre-commit hooks." 620 | dependencies = [ 621 | "cfgv>=2.0.0", 622 | "identify>=1.0.0", 623 | "nodeenv>=0.11.1", 624 | "pyyaml>=5.1", 625 | "virtualenv>=20.10.0", 626 | ] 627 | files = [ 628 | {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, 629 | {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, 630 | ] 631 | 632 | [[package]] 633 | name = "priority" 634 | version = "2.0.0" 635 | requires_python = ">=3.6.1" 636 | summary = "A pure-Python implementation of the HTTP/2 priority tree" 637 | files = [ 638 | {file = "priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa"}, 639 | {file = "priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0"}, 640 | ] 641 | 642 | [[package]] 643 | name = "pydantic" 644 | version = "1.10.13" 645 | requires_python = ">=3.7" 646 | summary = "Data validation and settings management using python type hints" 647 | dependencies = [ 648 | "typing-extensions>=4.2.0", 649 | ] 650 | files = [ 651 | {file = "pydantic-1.10.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:efff03cc7a4f29d9009d1c96ceb1e7a70a65cfe86e89d34e4a5f2ab1e5693737"}, 652 | {file = "pydantic-1.10.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3ecea2b9d80e5333303eeb77e180b90e95eea8f765d08c3d278cd56b00345d01"}, 653 | {file = "pydantic-1.10.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1740068fd8e2ef6eb27a20e5651df000978edce6da6803c2bef0bc74540f9548"}, 654 | {file = "pydantic-1.10.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84bafe2e60b5e78bc64a2941b4c071a4b7404c5c907f5f5a99b0139781e69ed8"}, 655 | {file = "pydantic-1.10.13-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bc0898c12f8e9c97f6cd44c0ed70d55749eaf783716896960b4ecce2edfd2d69"}, 656 | {file = "pydantic-1.10.13-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:654db58ae399fe6434e55325a2c3e959836bd17a6f6a0b6ca8107ea0571d2e17"}, 657 | {file = "pydantic-1.10.13-cp310-cp310-win_amd64.whl", hash = "sha256:75ac15385a3534d887a99c713aa3da88a30fbd6204a5cd0dc4dab3d770b9bd2f"}, 658 | {file = "pydantic-1.10.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c553f6a156deb868ba38a23cf0df886c63492e9257f60a79c0fd8e7173537653"}, 659 | {file = "pydantic-1.10.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e08865bc6464df8c7d61439ef4439829e3ab62ab1669cddea8dd00cd74b9ffe"}, 660 | {file = "pydantic-1.10.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e31647d85a2013d926ce60b84f9dd5300d44535a9941fe825dc349ae1f760df9"}, 661 | {file = "pydantic-1.10.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:210ce042e8f6f7c01168b2d84d4c9eb2b009fe7bf572c2266e235edf14bacd80"}, 662 | {file = "pydantic-1.10.13-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8ae5dd6b721459bfa30805f4c25880e0dd78fc5b5879f9f7a692196ddcb5a580"}, 663 | {file = "pydantic-1.10.13-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f8e81fc5fb17dae698f52bdd1c4f18b6ca674d7068242b2aff075f588301bbb0"}, 664 | {file = "pydantic-1.10.13-cp311-cp311-win_amd64.whl", hash = "sha256:61d9dce220447fb74f45e73d7ff3b530e25db30192ad8d425166d43c5deb6df0"}, 665 | {file = "pydantic-1.10.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c958d053453a1c4b1c2062b05cd42d9d5c8eb67537b8d5a7e3c3032943ecd261"}, 666 | {file = "pydantic-1.10.13-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c5370a7edaac06daee3af1c8b1192e305bc102abcbf2a92374b5bc793818599"}, 667 | {file = "pydantic-1.10.13-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d6f6e7305244bddb4414ba7094ce910560c907bdfa3501e9db1a7fd7eaea127"}, 668 | {file = "pydantic-1.10.13-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3a3c792a58e1622667a2837512099eac62490cdfd63bd407993aaf200a4cf1f"}, 669 | {file = "pydantic-1.10.13-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c636925f38b8db208e09d344c7aa4f29a86bb9947495dd6b6d376ad10334fb78"}, 670 | {file = "pydantic-1.10.13-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:678bcf5591b63cc917100dc50ab6caebe597ac67e8c9ccb75e698f66038ea953"}, 671 | {file = "pydantic-1.10.13-cp38-cp38-win_amd64.whl", hash = "sha256:6cf25c1a65c27923a17b3da28a0bdb99f62ee04230c931d83e888012851f4e7f"}, 672 | {file = "pydantic-1.10.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8ef467901d7a41fa0ca6db9ae3ec0021e3f657ce2c208e98cd511f3161c762c6"}, 673 | {file = "pydantic-1.10.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:968ac42970f57b8344ee08837b62f6ee6f53c33f603547a55571c954a4225691"}, 674 | {file = "pydantic-1.10.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9849f031cf8a2f0a928fe885e5a04b08006d6d41876b8bbd2fc68a18f9f2e3fd"}, 675 | {file = "pydantic-1.10.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56e3ff861c3b9c6857579de282ce8baabf443f42ffba355bf070770ed63e11e1"}, 676 | {file = "pydantic-1.10.13-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f00790179497767aae6bcdc36355792c79e7bbb20b145ff449700eb076c5f96"}, 677 | {file = "pydantic-1.10.13-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:75b297827b59bc229cac1a23a2f7a4ac0031068e5be0ce385be1462e7e17a35d"}, 678 | {file = "pydantic-1.10.13-cp39-cp39-win_amd64.whl", hash = "sha256:e70ca129d2053fb8b728ee7d1af8e553a928d7e301a311094b8a0501adc8763d"}, 679 | {file = "pydantic-1.10.13-py3-none-any.whl", hash = "sha256:b87326822e71bd5f313e7d3bfdc77ac3247035ac10b0c0618bd99dcf95b1e687"}, 680 | {file = "pydantic-1.10.13.tar.gz", hash = "sha256:32c8b48dcd3b2ac4e78b0ba4af3a2c2eb6048cb75202f0ea7b34feb740efc340"}, 681 | ] 682 | 683 | [[package]] 684 | name = "pyproject-api" 685 | version = "1.6.1" 686 | requires_python = ">=3.8" 687 | summary = "API to interact with the python pyproject.toml based projects" 688 | dependencies = [ 689 | "packaging>=23.1", 690 | "tomli>=2.0.1; python_version < \"3.11\"", 691 | ] 692 | files = [ 693 | {file = "pyproject_api-1.6.1-py3-none-any.whl", hash = "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675"}, 694 | {file = "pyproject_api-1.6.1.tar.gz", hash = "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538"}, 695 | ] 696 | 697 | [[package]] 698 | name = "pytest" 699 | version = "7.4.3" 700 | requires_python = ">=3.7" 701 | summary = "pytest: simple powerful testing with Python" 702 | dependencies = [ 703 | "colorama; sys_platform == \"win32\"", 704 | "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", 705 | "iniconfig", 706 | "packaging", 707 | "pluggy<2.0,>=0.12", 708 | "tomli>=1.0.0; python_version < \"3.11\"", 709 | ] 710 | files = [ 711 | {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, 712 | {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, 713 | ] 714 | 715 | [[package]] 716 | name = "pytest-asyncio" 717 | version = "0.20.4.dev42" 718 | requires_python = ">=3.7" 719 | url = "https://github.com/joeblackwaslike/pytest-asyncio/releases/download/v0.20.4.dev42/pytest_asyncio-0.20.4.dev42-py3-none-any.whl" 720 | summary = "Pytest support for asyncio" 721 | dependencies = [ 722 | "pytest>=7.0.0", 723 | ] 724 | files = [ 725 | {file = "pytest_asyncio-0.20.4.dev42-py3-none-any.whl", hash = "sha256:b203cf8688783b6d14eef2eedb2eee5498e1f4418adf302b0e7b3e9c41c8b87d"}, 726 | ] 727 | 728 | [[package]] 729 | name = "pytest-cov" 730 | version = "4.1.0" 731 | requires_python = ">=3.7" 732 | summary = "Pytest plugin for measuring coverage." 733 | dependencies = [ 734 | "coverage[toml]>=5.2.1", 735 | "pytest>=4.6", 736 | ] 737 | files = [ 738 | {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, 739 | {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, 740 | ] 741 | 742 | [[package]] 743 | name = "pytest-mock" 744 | version = "3.12.0" 745 | requires_python = ">=3.8" 746 | summary = "Thin-wrapper around the mock package for easier use with pytest" 747 | dependencies = [ 748 | "pytest>=5.0", 749 | ] 750 | files = [ 751 | {file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"}, 752 | {file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"}, 753 | ] 754 | 755 | [[package]] 756 | name = "pyyaml" 757 | version = "6.0.1" 758 | requires_python = ">=3.6" 759 | summary = "YAML parser and emitter for Python" 760 | files = [ 761 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, 762 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, 763 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, 764 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, 765 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, 766 | {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, 767 | {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, 768 | {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, 769 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, 770 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, 771 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, 772 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, 773 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, 774 | {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, 775 | {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, 776 | {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, 777 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, 778 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, 779 | {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, 780 | {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, 781 | {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, 782 | {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, 783 | {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, 784 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, 785 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, 786 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, 787 | {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, 788 | {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, 789 | {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, 790 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, 791 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, 792 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, 793 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, 794 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, 795 | {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, 796 | {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, 797 | {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, 798 | {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, 799 | ] 800 | 801 | [[package]] 802 | name = "quart" 803 | version = "0.19.3" 804 | requires_python = ">=3.8" 805 | summary = "A Python ASGI web microframework with the same API as Flask" 806 | dependencies = [ 807 | "aiofiles", 808 | "blinker>=1.6", 809 | "click>=8.0.0", 810 | "flask>=3.0.0", 811 | "hypercorn>=0.11.2", 812 | "importlib-metadata; python_version < \"3.10\"", 813 | "itsdangerous", 814 | "jinja2", 815 | "markupsafe", 816 | "typing-extensions; python_version < \"3.10\"", 817 | "werkzeug>=3.0.0", 818 | ] 819 | files = [ 820 | {file = "quart-0.19.3-py3-none-any.whl", hash = "sha256:3bfb433bb4edfcb13eb908096e579cb99eaae0b57ef05a3edfde797a2b12518a"}, 821 | {file = "quart-0.19.3.tar.gz", hash = "sha256:530ab6cc9b9e694ba48c00085a7bf881e2f7f23bdc51e27281e6739eee36c13f"}, 822 | ] 823 | 824 | [[package]] 825 | name = "ruff" 826 | version = "0.1.5" 827 | requires_python = ">=3.7" 828 | summary = "An extremely fast Python linter and code formatter, written in Rust." 829 | files = [ 830 | {file = "ruff-0.1.5-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:32d47fc69261c21a4c48916f16ca272bf2f273eb635d91c65d5cd548bf1f3d96"}, 831 | {file = "ruff-0.1.5-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:171276c1df6c07fa0597fb946139ced1c2978f4f0b8254f201281729981f3c17"}, 832 | {file = "ruff-0.1.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17ef33cd0bb7316ca65649fc748acc1406dfa4da96a3d0cde6d52f2e866c7b39"}, 833 | {file = "ruff-0.1.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b2c205827b3f8c13b4a432e9585750b93fd907986fe1aec62b2a02cf4401eee6"}, 834 | {file = "ruff-0.1.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb408e3a2ad8f6881d0f2e7ad70cddb3ed9f200eb3517a91a245bbe27101d379"}, 835 | {file = "ruff-0.1.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f20dc5e5905ddb407060ca27267c7174f532375c08076d1a953cf7bb016f5a24"}, 836 | {file = "ruff-0.1.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aafb9d2b671ed934998e881e2c0f5845a4295e84e719359c71c39a5363cccc91"}, 837 | {file = "ruff-0.1.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a4894dddb476597a0ba4473d72a23151b8b3b0b5f958f2cf4d3f1c572cdb7af7"}, 838 | {file = "ruff-0.1.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00a7ec893f665ed60008c70fe9eeb58d210e6b4d83ec6654a9904871f982a2a"}, 839 | {file = "ruff-0.1.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8c11206b47f283cbda399a654fd0178d7a389e631f19f51da15cbe631480c5b"}, 840 | {file = "ruff-0.1.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fa29e67b3284b9a79b1a85ee66e293a94ac6b7bb068b307a8a373c3d343aa8ec"}, 841 | {file = "ruff-0.1.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9b97fd6da44d6cceb188147b68db69a5741fbc736465b5cea3928fdac0bc1aeb"}, 842 | {file = "ruff-0.1.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:721f4b9d3b4161df8dc9f09aa8562e39d14e55a4dbaa451a8e55bdc9590e20f4"}, 843 | {file = "ruff-0.1.5-py3-none-win32.whl", hash = "sha256:f80c73bba6bc69e4fdc73b3991db0b546ce641bdcd5b07210b8ad6f64c79f1ab"}, 844 | {file = "ruff-0.1.5-py3-none-win_amd64.whl", hash = "sha256:c21fe20ee7d76206d290a76271c1af7a5096bc4c73ab9383ed2ad35f852a0087"}, 845 | {file = "ruff-0.1.5-py3-none-win_arm64.whl", hash = "sha256:82bfcb9927e88c1ed50f49ac6c9728dab3ea451212693fe40d08d314663e412f"}, 846 | {file = "ruff-0.1.5.tar.gz", hash = "sha256:5cbec0ef2ae1748fb194f420fb03fb2c25c3258c86129af7172ff8f198f125ab"}, 847 | ] 848 | 849 | [[package]] 850 | name = "setuptools" 851 | version = "68.2.2" 852 | requires_python = ">=3.8" 853 | summary = "Easily download, build, install, upgrade, and uninstall Python packages" 854 | files = [ 855 | {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, 856 | {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, 857 | ] 858 | 859 | [[package]] 860 | name = "sniffio" 861 | version = "1.3.0" 862 | requires_python = ">=3.7" 863 | summary = "Sniff out which async library your code is running under" 864 | files = [ 865 | {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, 866 | {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, 867 | ] 868 | 869 | [[package]] 870 | name = "sqlalchemy" 871 | version = "2.0.23" 872 | requires_python = ">=3.7" 873 | summary = "Database Abstraction Library" 874 | dependencies = [ 875 | "greenlet!=0.4.17; platform_machine == \"aarch64\" or (platform_machine == \"ppc64le\" or (platform_machine == \"x86_64\" or (platform_machine == \"amd64\" or (platform_machine == \"AMD64\" or (platform_machine == \"win32\" or platform_machine == \"WIN32\")))))", 876 | "typing-extensions>=4.2.0", 877 | ] 878 | files = [ 879 | {file = "SQLAlchemy-2.0.23-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:638c2c0b6b4661a4fd264f6fb804eccd392745c5887f9317feb64bb7cb03b3ea"}, 880 | {file = "SQLAlchemy-2.0.23-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3b5036aa326dc2df50cba3c958e29b291a80f604b1afa4c8ce73e78e1c9f01d"}, 881 | {file = "SQLAlchemy-2.0.23-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:787af80107fb691934a01889ca8f82a44adedbf5ef3d6ad7d0f0b9ac557e0c34"}, 882 | {file = "SQLAlchemy-2.0.23-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c14eba45983d2f48f7546bb32b47937ee2cafae353646295f0e99f35b14286ab"}, 883 | {file = "SQLAlchemy-2.0.23-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0666031df46b9badba9bed00092a1ffa3aa063a5e68fa244acd9f08070e936d3"}, 884 | {file = "SQLAlchemy-2.0.23-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:89a01238fcb9a8af118eaad3ffcc5dedaacbd429dc6fdc43fe430d3a941ff965"}, 885 | {file = "SQLAlchemy-2.0.23-cp310-cp310-win32.whl", hash = "sha256:cabafc7837b6cec61c0e1e5c6d14ef250b675fa9c3060ed8a7e38653bd732ff8"}, 886 | {file = "SQLAlchemy-2.0.23-cp310-cp310-win_amd64.whl", hash = "sha256:87a3d6b53c39cd173990de2f5f4b83431d534a74f0e2f88bd16eabb5667e65c6"}, 887 | {file = "SQLAlchemy-2.0.23-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d5578e6863eeb998980c212a39106ea139bdc0b3f73291b96e27c929c90cd8e1"}, 888 | {file = "SQLAlchemy-2.0.23-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62d9e964870ea5ade4bc870ac4004c456efe75fb50404c03c5fd61f8bc669a72"}, 889 | {file = "SQLAlchemy-2.0.23-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c80c38bd2ea35b97cbf7c21aeb129dcbebbf344ee01a7141016ab7b851464f8e"}, 890 | {file = "SQLAlchemy-2.0.23-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75eefe09e98043cff2fb8af9796e20747ae870c903dc61d41b0c2e55128f958d"}, 891 | {file = "SQLAlchemy-2.0.23-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd45a5b6c68357578263d74daab6ff9439517f87da63442d244f9f23df56138d"}, 892 | {file = "SQLAlchemy-2.0.23-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a86cb7063e2c9fb8e774f77fbf8475516d270a3e989da55fa05d08089d77f8c4"}, 893 | {file = "SQLAlchemy-2.0.23-cp311-cp311-win32.whl", hash = "sha256:b41f5d65b54cdf4934ecede2f41b9c60c9f785620416e8e6c48349ab18643855"}, 894 | {file = "SQLAlchemy-2.0.23-cp311-cp311-win_amd64.whl", hash = "sha256:9ca922f305d67605668e93991aaf2c12239c78207bca3b891cd51a4515c72e22"}, 895 | {file = "SQLAlchemy-2.0.23-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d0f7fb0c7527c41fa6fcae2be537ac137f636a41b4c5a4c58914541e2f436b45"}, 896 | {file = "SQLAlchemy-2.0.23-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c424983ab447dab126c39d3ce3be5bee95700783204a72549c3dceffe0fc8f4"}, 897 | {file = "SQLAlchemy-2.0.23-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f508ba8f89e0a5ecdfd3761f82dda2a3d7b678a626967608f4273e0dba8f07ac"}, 898 | {file = "SQLAlchemy-2.0.23-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6463aa765cf02b9247e38b35853923edbf2f6fd1963df88706bc1d02410a5577"}, 899 | {file = "SQLAlchemy-2.0.23-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e599a51acf3cc4d31d1a0cf248d8f8d863b6386d2b6782c5074427ebb7803bda"}, 900 | {file = "SQLAlchemy-2.0.23-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fd54601ef9cc455a0c61e5245f690c8a3ad67ddb03d3b91c361d076def0b4c60"}, 901 | {file = "SQLAlchemy-2.0.23-cp312-cp312-win32.whl", hash = "sha256:42d0b0290a8fb0165ea2c2781ae66e95cca6e27a2fbe1016ff8db3112ac1e846"}, 902 | {file = "SQLAlchemy-2.0.23-cp312-cp312-win_amd64.whl", hash = "sha256:227135ef1e48165f37590b8bfc44ed7ff4c074bf04dc8d6f8e7f1c14a94aa6ca"}, 903 | {file = "SQLAlchemy-2.0.23-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:64ac935a90bc479fee77f9463f298943b0e60005fe5de2aa654d9cdef46c54df"}, 904 | {file = "SQLAlchemy-2.0.23-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c4722f3bc3c1c2fcc3702dbe0016ba31148dd6efcd2a2fd33c1b4897c6a19693"}, 905 | {file = "SQLAlchemy-2.0.23-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af79c06825e2836de21439cb2a6ce22b2ca129bad74f359bddd173f39582bf5"}, 906 | {file = "SQLAlchemy-2.0.23-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:683ef58ca8eea4747737a1c35c11372ffeb84578d3aab8f3e10b1d13d66f2bc4"}, 907 | {file = "SQLAlchemy-2.0.23-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d4041ad05b35f1f4da481f6b811b4af2f29e83af253bf37c3c4582b2c68934ab"}, 908 | {file = "SQLAlchemy-2.0.23-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aeb397de65a0a62f14c257f36a726945a7f7bb60253462e8602d9b97b5cbe204"}, 909 | {file = "SQLAlchemy-2.0.23-cp38-cp38-win32.whl", hash = "sha256:42ede90148b73fe4ab4a089f3126b2cfae8cfefc955c8174d697bb46210c8306"}, 910 | {file = "SQLAlchemy-2.0.23-cp38-cp38-win_amd64.whl", hash = "sha256:964971b52daab357d2c0875825e36584d58f536e920f2968df8d581054eada4b"}, 911 | {file = "SQLAlchemy-2.0.23-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:616fe7bcff0a05098f64b4478b78ec2dfa03225c23734d83d6c169eb41a93e55"}, 912 | {file = "SQLAlchemy-2.0.23-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e680527245895aba86afbd5bef6c316831c02aa988d1aad83c47ffe92655e74"}, 913 | {file = "SQLAlchemy-2.0.23-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9585b646ffb048c0250acc7dad92536591ffe35dba624bb8fd9b471e25212a35"}, 914 | {file = "SQLAlchemy-2.0.23-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4895a63e2c271ffc7a81ea424b94060f7b3b03b4ea0cd58ab5bb676ed02f4221"}, 915 | {file = "SQLAlchemy-2.0.23-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cc1d21576f958c42d9aec68eba5c1a7d715e5fc07825a629015fe8e3b0657fb0"}, 916 | {file = "SQLAlchemy-2.0.23-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:967c0b71156f793e6662dd839da54f884631755275ed71f1539c95bbada9aaab"}, 917 | {file = "SQLAlchemy-2.0.23-cp39-cp39-win32.whl", hash = "sha256:0a8c6aa506893e25a04233bc721c6b6cf844bafd7250535abb56cb6cc1368884"}, 918 | {file = "SQLAlchemy-2.0.23-cp39-cp39-win_amd64.whl", hash = "sha256:f3420d00d2cb42432c1d0e44540ae83185ccbbc67a6054dcc8ab5387add6620b"}, 919 | {file = "SQLAlchemy-2.0.23-py3-none-any.whl", hash = "sha256:31952bbc527d633b9479f5f81e8b9dfada00b91d6baba021a869095f1a97006d"}, 920 | {file = "SQLAlchemy-2.0.23.tar.gz", hash = "sha256:c1bda93cbbe4aa2aa0aa8655c5aeda505cd219ff3e8da91d1d329e143e4aff69"}, 921 | ] 922 | 923 | [[package]] 924 | name = "sqlalchemy-utils" 925 | version = "0.41.1" 926 | requires_python = ">=3.6" 927 | summary = "Various utility functions for SQLAlchemy." 928 | dependencies = [ 929 | "SQLAlchemy>=1.3", 930 | ] 931 | files = [ 932 | {file = "SQLAlchemy-Utils-0.41.1.tar.gz", hash = "sha256:a2181bff01eeb84479e38571d2c0718eb52042f9afd8c194d0d02877e84b7d74"}, 933 | {file = "SQLAlchemy_Utils-0.41.1-py3-none-any.whl", hash = "sha256:6c96b0768ea3f15c0dc56b363d386138c562752b84f647fb8d31a2223aaab801"}, 934 | ] 935 | 936 | [[package]] 937 | name = "sqlalchemy" 938 | version = "2.0.23" 939 | extras = ["asyncio"] 940 | requires_python = ">=3.7" 941 | summary = "Database Abstraction Library" 942 | dependencies = [ 943 | "SQLAlchemy==2.0.23", 944 | "greenlet!=0.4.17", 945 | ] 946 | files = [ 947 | {file = "SQLAlchemy-2.0.23-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:638c2c0b6b4661a4fd264f6fb804eccd392745c5887f9317feb64bb7cb03b3ea"}, 948 | {file = "SQLAlchemy-2.0.23-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3b5036aa326dc2df50cba3c958e29b291a80f604b1afa4c8ce73e78e1c9f01d"}, 949 | {file = "SQLAlchemy-2.0.23-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:787af80107fb691934a01889ca8f82a44adedbf5ef3d6ad7d0f0b9ac557e0c34"}, 950 | {file = "SQLAlchemy-2.0.23-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c14eba45983d2f48f7546bb32b47937ee2cafae353646295f0e99f35b14286ab"}, 951 | {file = "SQLAlchemy-2.0.23-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0666031df46b9badba9bed00092a1ffa3aa063a5e68fa244acd9f08070e936d3"}, 952 | {file = "SQLAlchemy-2.0.23-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:89a01238fcb9a8af118eaad3ffcc5dedaacbd429dc6fdc43fe430d3a941ff965"}, 953 | {file = "SQLAlchemy-2.0.23-cp310-cp310-win32.whl", hash = "sha256:cabafc7837b6cec61c0e1e5c6d14ef250b675fa9c3060ed8a7e38653bd732ff8"}, 954 | {file = "SQLAlchemy-2.0.23-cp310-cp310-win_amd64.whl", hash = "sha256:87a3d6b53c39cd173990de2f5f4b83431d534a74f0e2f88bd16eabb5667e65c6"}, 955 | {file = "SQLAlchemy-2.0.23-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d5578e6863eeb998980c212a39106ea139bdc0b3f73291b96e27c929c90cd8e1"}, 956 | {file = "SQLAlchemy-2.0.23-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62d9e964870ea5ade4bc870ac4004c456efe75fb50404c03c5fd61f8bc669a72"}, 957 | {file = "SQLAlchemy-2.0.23-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c80c38bd2ea35b97cbf7c21aeb129dcbebbf344ee01a7141016ab7b851464f8e"}, 958 | {file = "SQLAlchemy-2.0.23-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75eefe09e98043cff2fb8af9796e20747ae870c903dc61d41b0c2e55128f958d"}, 959 | {file = "SQLAlchemy-2.0.23-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd45a5b6c68357578263d74daab6ff9439517f87da63442d244f9f23df56138d"}, 960 | {file = "SQLAlchemy-2.0.23-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a86cb7063e2c9fb8e774f77fbf8475516d270a3e989da55fa05d08089d77f8c4"}, 961 | {file = "SQLAlchemy-2.0.23-cp311-cp311-win32.whl", hash = "sha256:b41f5d65b54cdf4934ecede2f41b9c60c9f785620416e8e6c48349ab18643855"}, 962 | {file = "SQLAlchemy-2.0.23-cp311-cp311-win_amd64.whl", hash = "sha256:9ca922f305d67605668e93991aaf2c12239c78207bca3b891cd51a4515c72e22"}, 963 | {file = "SQLAlchemy-2.0.23-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d0f7fb0c7527c41fa6fcae2be537ac137f636a41b4c5a4c58914541e2f436b45"}, 964 | {file = "SQLAlchemy-2.0.23-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c424983ab447dab126c39d3ce3be5bee95700783204a72549c3dceffe0fc8f4"}, 965 | {file = "SQLAlchemy-2.0.23-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f508ba8f89e0a5ecdfd3761f82dda2a3d7b678a626967608f4273e0dba8f07ac"}, 966 | {file = "SQLAlchemy-2.0.23-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6463aa765cf02b9247e38b35853923edbf2f6fd1963df88706bc1d02410a5577"}, 967 | {file = "SQLAlchemy-2.0.23-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e599a51acf3cc4d31d1a0cf248d8f8d863b6386d2b6782c5074427ebb7803bda"}, 968 | {file = "SQLAlchemy-2.0.23-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fd54601ef9cc455a0c61e5245f690c8a3ad67ddb03d3b91c361d076def0b4c60"}, 969 | {file = "SQLAlchemy-2.0.23-cp312-cp312-win32.whl", hash = "sha256:42d0b0290a8fb0165ea2c2781ae66e95cca6e27a2fbe1016ff8db3112ac1e846"}, 970 | {file = "SQLAlchemy-2.0.23-cp312-cp312-win_amd64.whl", hash = "sha256:227135ef1e48165f37590b8bfc44ed7ff4c074bf04dc8d6f8e7f1c14a94aa6ca"}, 971 | {file = "SQLAlchemy-2.0.23-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:64ac935a90bc479fee77f9463f298943b0e60005fe5de2aa654d9cdef46c54df"}, 972 | {file = "SQLAlchemy-2.0.23-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c4722f3bc3c1c2fcc3702dbe0016ba31148dd6efcd2a2fd33c1b4897c6a19693"}, 973 | {file = "SQLAlchemy-2.0.23-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af79c06825e2836de21439cb2a6ce22b2ca129bad74f359bddd173f39582bf5"}, 974 | {file = "SQLAlchemy-2.0.23-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:683ef58ca8eea4747737a1c35c11372ffeb84578d3aab8f3e10b1d13d66f2bc4"}, 975 | {file = "SQLAlchemy-2.0.23-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d4041ad05b35f1f4da481f6b811b4af2f29e83af253bf37c3c4582b2c68934ab"}, 976 | {file = "SQLAlchemy-2.0.23-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aeb397de65a0a62f14c257f36a726945a7f7bb60253462e8602d9b97b5cbe204"}, 977 | {file = "SQLAlchemy-2.0.23-cp38-cp38-win32.whl", hash = "sha256:42ede90148b73fe4ab4a089f3126b2cfae8cfefc955c8174d697bb46210c8306"}, 978 | {file = "SQLAlchemy-2.0.23-cp38-cp38-win_amd64.whl", hash = "sha256:964971b52daab357d2c0875825e36584d58f536e920f2968df8d581054eada4b"}, 979 | {file = "SQLAlchemy-2.0.23-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:616fe7bcff0a05098f64b4478b78ec2dfa03225c23734d83d6c169eb41a93e55"}, 980 | {file = "SQLAlchemy-2.0.23-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e680527245895aba86afbd5bef6c316831c02aa988d1aad83c47ffe92655e74"}, 981 | {file = "SQLAlchemy-2.0.23-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9585b646ffb048c0250acc7dad92536591ffe35dba624bb8fd9b471e25212a35"}, 982 | {file = "SQLAlchemy-2.0.23-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4895a63e2c271ffc7a81ea424b94060f7b3b03b4ea0cd58ab5bb676ed02f4221"}, 983 | {file = "SQLAlchemy-2.0.23-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cc1d21576f958c42d9aec68eba5c1a7d715e5fc07825a629015fe8e3b0657fb0"}, 984 | {file = "SQLAlchemy-2.0.23-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:967c0b71156f793e6662dd839da54f884631755275ed71f1539c95bbada9aaab"}, 985 | {file = "SQLAlchemy-2.0.23-cp39-cp39-win32.whl", hash = "sha256:0a8c6aa506893e25a04233bc721c6b6cf844bafd7250535abb56cb6cc1368884"}, 986 | {file = "SQLAlchemy-2.0.23-cp39-cp39-win_amd64.whl", hash = "sha256:f3420d00d2cb42432c1d0e44540ae83185ccbbc67a6054dcc8ab5387add6620b"}, 987 | {file = "SQLAlchemy-2.0.23-py3-none-any.whl", hash = "sha256:31952bbc527d633b9479f5f81e8b9dfada00b91d6baba021a869095f1a97006d"}, 988 | {file = "SQLAlchemy-2.0.23.tar.gz", hash = "sha256:c1bda93cbbe4aa2aa0aa8655c5aeda505cd219ff3e8da91d1d329e143e4aff69"}, 989 | ] 990 | 991 | [[package]] 992 | name = "sqlapagination" 993 | version = "0.0.1" 994 | requires_python = ">=3.7,<4.0" 995 | summary = "" 996 | dependencies = [ 997 | "SQLAlchemy>=1.4.0", 998 | ] 999 | files = [ 1000 | {file = "sqlapagination-0.0.1-py3-none-any.whl", hash = "sha256:f1aead1eae81d9f485e51b463cacf6d31cb8799ef31967487d9e8273063762e4"}, 1001 | {file = "sqlapagination-0.0.1.tar.gz", hash = "sha256:b300eadfdccff18483afb7a032b91a2c5e3b27f8c74985720d6dd39be1c9993e"}, 1002 | ] 1003 | 1004 | [[package]] 1005 | name = "taskgroup" 1006 | version = "0.0.0a4" 1007 | summary = "backport of asyncio.TaskGroup, asyncio.Runner and asyncio.timeout" 1008 | dependencies = [ 1009 | "exceptiongroup", 1010 | ] 1011 | files = [ 1012 | {file = "taskgroup-0.0.0a4-py2.py3-none-any.whl", hash = "sha256:5c1bd0e4c06114e7a4128583ab75c987597d5378a33948a3b74c662b90f61277"}, 1013 | {file = "taskgroup-0.0.0a4.tar.gz", hash = "sha256:eb08902d221e27661950f2a0320ddf3f939f579279996f81fe30779bca3a159c"}, 1014 | ] 1015 | 1016 | [[package]] 1017 | name = "tenacity" 1018 | version = "8.0.1" 1019 | requires_python = ">=3.6" 1020 | summary = "Retry code until it succeeds" 1021 | files = [ 1022 | {file = "tenacity-8.0.1-py3-none-any.whl", hash = "sha256:f78f4ea81b0fabc06728c11dc2a8c01277bfc5181b321a4770471902e3eb844a"}, 1023 | {file = "tenacity-8.0.1.tar.gz", hash = "sha256:43242a20e3e73291a28bcbcacfd6e000b02d3857a9a9fff56b297a27afdc932f"}, 1024 | ] 1025 | 1026 | [[package]] 1027 | name = "tomli" 1028 | version = "2.0.1" 1029 | requires_python = ">=3.7" 1030 | summary = "A lil' TOML parser" 1031 | files = [ 1032 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 1033 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 1034 | ] 1035 | 1036 | [[package]] 1037 | name = "tox" 1038 | version = "4.11.3" 1039 | requires_python = ">=3.8" 1040 | summary = "tox is a generic virtualenv management and test command line tool" 1041 | dependencies = [ 1042 | "cachetools>=5.3.1", 1043 | "chardet>=5.2", 1044 | "colorama>=0.4.6", 1045 | "filelock>=3.12.3", 1046 | "packaging>=23.1", 1047 | "platformdirs>=3.10", 1048 | "pluggy>=1.3", 1049 | "pyproject-api>=1.6.1", 1050 | "tomli>=2.0.1; python_version < \"3.11\"", 1051 | "virtualenv>=20.24.3", 1052 | ] 1053 | files = [ 1054 | {file = "tox-4.11.3-py3-none-any.whl", hash = "sha256:599af5e5bb0cad0148ac1558a0b66f8fff219ef88363483b8d92a81e4246f28f"}, 1055 | {file = "tox-4.11.3.tar.gz", hash = "sha256:5039f68276461fae6a9452a3b2c7295798f00a0e92edcd9a3b78ba1a73577951"}, 1056 | ] 1057 | 1058 | [[package]] 1059 | name = "tox-pdm" 1060 | version = "0.7.0" 1061 | requires_python = ">=3.7" 1062 | summary = "A plugin for tox that utilizes PDM as the package manager and installer" 1063 | dependencies = [ 1064 | "tomli; python_version < \"3.11\"", 1065 | "tox>=4.0", 1066 | ] 1067 | files = [ 1068 | {file = "tox_pdm-0.7.0-py3-none-any.whl", hash = "sha256:13b36536b29c94943ff2d2ae65eaf81ca6c7afbb5b7ba2ad7cb3738fc726d662"}, 1069 | {file = "tox_pdm-0.7.0.tar.gz", hash = "sha256:8ecc5d04df73a329f435574222de632dc0ded36b79f27e55d6dab92e9d1741f5"}, 1070 | ] 1071 | 1072 | [[package]] 1073 | name = "typing-extensions" 1074 | version = "4.8.0" 1075 | requires_python = ">=3.8" 1076 | summary = "Backported and Experimental Type Hints for Python 3.8+" 1077 | files = [ 1078 | {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, 1079 | {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, 1080 | ] 1081 | 1082 | [[package]] 1083 | name = "virtualenv" 1084 | version = "20.24.6" 1085 | requires_python = ">=3.7" 1086 | summary = "Virtual Python Environment builder" 1087 | dependencies = [ 1088 | "distlib<1,>=0.3.7", 1089 | "filelock<4,>=3.12.2", 1090 | "platformdirs<4,>=3.9.1", 1091 | ] 1092 | files = [ 1093 | {file = "virtualenv-20.24.6-py3-none-any.whl", hash = "sha256:520d056652454c5098a00c0f073611ccbea4c79089331f60bf9d7ba247bb7381"}, 1094 | {file = "virtualenv-20.24.6.tar.gz", hash = "sha256:02ece4f56fbf939dbbc33c0715159951d6bf14aaf5457b092e4548e1382455af"}, 1095 | ] 1096 | 1097 | [[package]] 1098 | name = "werkzeug" 1099 | version = "3.0.1" 1100 | requires_python = ">=3.8" 1101 | summary = "The comprehensive WSGI web application library." 1102 | dependencies = [ 1103 | "MarkupSafe>=2.1.1", 1104 | ] 1105 | files = [ 1106 | {file = "werkzeug-3.0.1-py3-none-any.whl", hash = "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10"}, 1107 | {file = "werkzeug-3.0.1.tar.gz", hash = "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc"}, 1108 | ] 1109 | 1110 | [[package]] 1111 | name = "wsproto" 1112 | version = "1.2.0" 1113 | requires_python = ">=3.7.0" 1114 | summary = "WebSockets state-machine based protocol implementation" 1115 | dependencies = [ 1116 | "h11<1,>=0.9.0", 1117 | ] 1118 | files = [ 1119 | {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, 1120 | {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, 1121 | ] 1122 | 1123 | [[package]] 1124 | name = "zipp" 1125 | version = "3.17.0" 1126 | requires_python = ">=3.8" 1127 | summary = "Backport of pathlib-compatible object wrapper for zip files" 1128 | files = [ 1129 | {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, 1130 | {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, 1131 | ] 1132 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "quart-sqlalchemy" 3 | version = "3.0.4" 4 | description = "SQLAlchemy for humans, with framework adapter for Quart." 5 | authors = [ 6 | {name = "Joe Black", email = "me@joeblack.nyc"}, 7 | ] 8 | dependencies = [ 9 | "quart<0.20.0,>=0.18.3", 10 | "werkzeug<3.1.0,>=2.2.0", 11 | "blinker<1.7,>=1.5", 12 | "SQLAlchemy[asyncio]<2.1.0,>=2.0.0", 13 | "SQLAlchemy-Utils", 14 | "anyio>=3.0.0,<4", 15 | "pydantic~=1.10.13", 16 | "tenacity~=8.0.1", 17 | "sqlapagination", 18 | "exceptiongroup", 19 | ] 20 | requires-python = ">=3.8" 21 | readme = "README.rst" 22 | license = {text = "MIT"} 23 | 24 | [project.urls] 25 | "Homepage" = "https://github.com/joeblackwaslike/quart-sqlalchemy" 26 | "Bug Tracker" = "https://github.com/joeblackwaslike/quart-sqlalchemy/issues" 27 | 28 | 29 | [build-system] 30 | requires = ["pdm-backend"] 31 | build-backend = "pdm.backend" 32 | 33 | 34 | [tool.pdm.dev-dependencies] 35 | dev = [ 36 | "pytest>=7.4.3", 37 | # "pytest-asyncio~=0.20.3", 38 | "pytest-asyncio @ https://github.com/joeblackwaslike/pytest-asyncio/releases/download/v0.20.4.dev42/pytest_asyncio-0.20.4.dev42-py3-none-any.whl", 39 | "pytest-mock>=3.12.0", 40 | "pytest-cov>=4.1.0", 41 | "coverage[toml]>=7.3.2", 42 | "aiosqlite>=0.19.0", 43 | "pre-commit>=3.5.0", 44 | "tox>=4.11.3", 45 | "tox-pdm>=0.7.0", 46 | "mypy>=1.7.0", 47 | "ruff>=0.1.5", 48 | ] 49 | 50 | 51 | [tool.pdm.build] 52 | source-includes = [ 53 | "docs/", 54 | "tests/", 55 | "CHANGES.rst", 56 | "pdm.lock", 57 | "tox.ini", 58 | ] 59 | excludes = [ 60 | "docs/_build", 61 | ] 62 | 63 | [tool.pytest.ini_options] 64 | addopts = "-rsx --tb=short --loop-scope session" 65 | testpaths = ["tests"] 66 | filterwarnings = ["error"] 67 | asyncio_mode = "auto" 68 | py311_task = true 69 | log_cli = true 70 | 71 | [tool.coverage.run] 72 | branch = true 73 | source = ["src", "tests"] 74 | 75 | [tool.coverage.paths] 76 | source = ["src", "*/site-packages"] 77 | 78 | [tool.isort] 79 | profile = "black" 80 | src_paths = ["src", "tests", "examples"] 81 | force_single_line = true 82 | use_parentheses = true 83 | atomic = true 84 | lines_after_imports = 2 85 | line_length = 100 86 | order_by_type = false 87 | known_first_party = ["quart_sqlalchemy", "tests"] 88 | 89 | [tool.mypy] 90 | python_version = "3.7" 91 | plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"] 92 | files = ["src/quart_sqlalchemy", "tests"] 93 | show_error_codes = true 94 | pretty = true 95 | strict = true 96 | # db.Model attribute doesn't recognize subclassing 97 | disable_error_code = ["name-defined"] 98 | # db.Model is Any 99 | disallow_subclassing_any = false 100 | allow_untyped_globals = true 101 | allow_untyped_defs = true 102 | allow_untyped_calls = true 103 | 104 | [[tool.mypy.overrides]] 105 | module = [ 106 | "cryptography.*", 107 | "importlib_metadata.*", 108 | ] 109 | ignore_missing_imports = true 110 | 111 | [tool.pylint.messages_control] 112 | max-line-length = 100 113 | disable = ["missing-docstring", "protected-access"] 114 | 115 | [tool.flakeheaven] 116 | baseline = ".flakeheaven_baseline" 117 | exclude = ["W503"] 118 | 119 | [tool.flakeheaven.plugins] 120 | "flake8-*" = ["+*"] 121 | "flake8-docstrings" = [ 122 | "+*", 123 | "-D100", 124 | "-D101", 125 | "-D102", 126 | "-D103", 127 | "-D106", 128 | "-D107", 129 | "-D401", 130 | ] 131 | "flake8-quotes" = [ 132 | "+*", 133 | "-Q000", 134 | ] 135 | "flake8-isort" = [ 136 | "+*", 137 | "-I001", 138 | "-I003", 139 | "-I005", 140 | ] 141 | "flake8-bandit" = [ 142 | "+*", 143 | "-S101", 144 | ] 145 | "mccabe" = ["+*"] 146 | "pycodestyle" = ["+*"] 147 | "pyflakes" = [ 148 | "+*", 149 | ] 150 | "wemake-python-styleguide" = [ 151 | "+*", 152 | "-WPS110", 153 | "-WPS111", 154 | "-WPS115", 155 | "-WPS118", 156 | "-WPS120", # allow variables with trailing underscore 157 | "-WPS201", 158 | "-WPS204", 159 | "-WPS210", 160 | "-WPS211", 161 | "-WPS214", 162 | "-WPS221", 163 | "-WPS224", 164 | "-WPS225", # allow multiple except in try block 165 | "-WPS226", 166 | "-WPS230", 167 | "-WPS231", 168 | "-WPS232", 169 | "-WPS238", # allow multiple raises in function 170 | "-WPS305", # allow f-strings 171 | "-WPS306", 172 | "-WPS326", 173 | "-WPS337", # allow multi-line conditionals 174 | "-WPS338", 175 | "-WPS420", # allow pass keyword 176 | "-WPS429", 177 | "-WPS430", # allow nested functions 178 | "-WPS431", 179 | "-WPS432", 180 | "-WPS433", 181 | "-WPS435", 182 | "-WPS437", 183 | "-WPS463", # Unsure what it means "Found a getter without a return value" 184 | "-WPS473", 185 | "-WPS503", 186 | "-WPS505", 187 | "-WPS604", # allow pass inside 'class' body 188 | ] 189 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Config goes in pyproject.toml unless a tool doesn't support that. 2 | 3 | [flake8] 4 | # B = bugbear 5 | # E = pycodestyle errors 6 | # F = flake8 pyflakes 7 | # W = pycodestyle warnings 8 | # B9 = bugbear opinions 9 | # ISC = implicit-str-concat 10 | 11 | show-source = true 12 | max-line-length = 100 13 | min-name-length = 2 14 | max-name-length = 20 15 | max-methods = 12 16 | 17 | nested-classes-whitelist = 18 | Meta 19 | Params 20 | Config 21 | Defaults 22 | 23 | ignore = 24 | # allow f strings 25 | WPS305 26 | WPS430 27 | WPS463 28 | 29 | allowed-domain-names = 30 | value 31 | val 32 | vals 33 | values 34 | result 35 | results 36 | 37 | exclude = 38 | .git 39 | .github 40 | .mypy_cache 41 | .pytest_cache 42 | __pycache__ 43 | __pypackages__ 44 | venv 45 | .venv 46 | artwork 47 | build 48 | dist 49 | docs 50 | examples 51 | old 52 | 53 | extend-select = 54 | # bugbear 55 | B 56 | # bugbear opinions 57 | B9 58 | # implicit str concat 59 | ISC 60 | 61 | extend-ignore = 62 | # slice notation whitespace, invalid 63 | E203 64 | # line length, handled by bugbear B950 65 | E501 66 | # bare except, handled by bugbear B001 67 | E722 68 | # zip with strict=, requires python >= 3.10 69 | B905 70 | # string formatting opinion, B028 renamed to B907 71 | B028 72 | B907 73 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.0.4" 2 | 3 | from .bind import AsyncBind 4 | from .bind import Bind 5 | from .bind import BindContext 6 | from .config import AsyncBindConfig 7 | from .config import AsyncSessionmakerOptions 8 | from .config import AsyncSessionOptions 9 | from .config import BindConfig 10 | from .config import ConfigBase 11 | from .config import CoreExecutionOptions 12 | from .config import EngineConfig 13 | from .config import ORMExecutionOptions 14 | from .config import SessionmakerOptions 15 | from .config import SessionOptions 16 | from .config import SQLAlchemyConfig 17 | from .model import Base 18 | from .retry import retry_config 19 | from .retry import retrying_async_session 20 | from .retry import retrying_session 21 | from .session import AsyncSession 22 | from .session import Session 23 | from .sqla import SQLAlchemy 24 | 25 | 26 | __all__ = [ 27 | "AsyncBind", 28 | "AsyncBindConfig", 29 | "AsyncSession", 30 | "AsyncSessionmakerOptions", 31 | "AsyncSessionOptions", 32 | "Base", 33 | "Bind", 34 | "BindConfig", 35 | "BindContext", 36 | "ConfigBase", 37 | "CoreExecutionOptions", 38 | "EngineConfig", 39 | "ORMExecutionOptions", 40 | "Session", 41 | "SessionOptions", 42 | "SessionmakerOptions", 43 | "SQLAlchemy", 44 | "SQLAlchemyConfig", 45 | "retry_config", 46 | "retrying_async_session", 47 | "retrying_session", 48 | ] 49 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/bind.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import typing as t 5 | from contextlib import contextmanager 6 | 7 | import sqlalchemy 8 | import sqlalchemy.event 9 | import sqlalchemy.exc 10 | import sqlalchemy.ext 11 | import sqlalchemy.ext.asyncio 12 | import sqlalchemy.orm 13 | import sqlalchemy.util 14 | 15 | from . import signals 16 | from .config import BindConfig 17 | from .model import setup_soft_delete_for_session 18 | from .testing import AsyncTestTransaction 19 | from .testing import TestTransaction 20 | 21 | 22 | sa = sqlalchemy 23 | 24 | 25 | class BindBase: 26 | config: BindConfig 27 | metadata: sa.MetaData 28 | engine: sa.Engine 29 | Session: sa.orm.sessionmaker 30 | 31 | def __init__( 32 | self, 33 | config: BindConfig, 34 | metadata: sa.MetaData, 35 | ): 36 | self.config = config 37 | self.metadata = metadata 38 | 39 | @property 40 | def url(self) -> str: 41 | if not hasattr(self, "engine"): 42 | raise RuntimeError("Database not initialized yet. Call initialize() first.") 43 | return str(self.engine.url) 44 | 45 | @property 46 | def is_async(self) -> bool: 47 | if not hasattr(self, "engine"): 48 | raise RuntimeError("Database not initialized yet. Call initialize() first.") 49 | return self.engine.url.get_dialect().is_async 50 | 51 | @property 52 | def is_read_only(self): 53 | return self.config.read_only 54 | 55 | 56 | class BindContext(BindBase): 57 | pass 58 | 59 | 60 | class Bind(BindBase): 61 | def __init__( 62 | self, 63 | config: BindConfig, 64 | metadata: sa.MetaData, 65 | initialize: bool = True, 66 | ): 67 | self.config = config 68 | self.metadata = metadata 69 | 70 | if initialize: 71 | self.initialize() 72 | 73 | def initialize(self): 74 | if hasattr(self, "engine"): 75 | self.engine.dispose() 76 | 77 | self.engine = self.create_engine( 78 | self.config.engine.dict(exclude_unset=True, exclude_none=True), 79 | prefix="", 80 | ) 81 | self.Session = self.create_session_factory( 82 | self.config.session.dict(exclude_unset=True, exclude_none=True), 83 | ) 84 | return self 85 | 86 | @contextmanager 87 | def context( 88 | self, 89 | engine_execution_options: t.Optional[t.Dict[str, t.Any]] = None, 90 | session_execution__options: t.Optional[t.Dict[str, t.Any]] = None, 91 | ) -> t.Generator[BindContext, None, None]: 92 | context = BindContext(self.config, self.metadata) 93 | context.engine = self.engine.execution_options(**engine_execution_options or {}) 94 | context.Session = self.create_session_factory(session_execution__options or {}) 95 | context.Session.configure(bind=context.engine) 96 | 97 | signals.bind_context_entered.send( 98 | self, 99 | engine_execution_options=engine_execution_options, 100 | session_execution__options=session_execution__options, 101 | context=context, 102 | ) 103 | yield context 104 | 105 | signals.bind_context_exited.send( 106 | self, 107 | engine_execution_options=engine_execution_options, 108 | session_execution__options=session_execution__options, 109 | context=context, 110 | ) 111 | 112 | def create_session_factory( 113 | self, options: dict[str, t.Any] 114 | ) -> sa.orm.sessionmaker[sa.orm.Session]: 115 | signals.before_bind_session_factory_created.send(self, options=options) 116 | session_factory = sa.orm.sessionmaker(bind=self.engine, **options) 117 | signals.after_bind_session_factory_created.send( 118 | self, options=options, session_factory=session_factory 119 | ) 120 | return session_factory 121 | 122 | def create_engine(self, config: t.Dict[str, t.Any], prefix: str = "") -> sa.Engine: 123 | signals.before_bind_engine_created.send(self, config=config, prefix=prefix) 124 | engine = sa.engine_from_config(config, prefix=prefix) 125 | signals.after_bind_engine_created.send(self, config=config, prefix=prefix, engine=engine) 126 | return engine 127 | 128 | def test_transaction(self, savepoint: bool = False): 129 | return TestTransaction(self, savepoint=savepoint) 130 | 131 | def _call_metadata(self, method: str): 132 | with self.engine.connect() as conn: 133 | with conn.begin(): 134 | return getattr(self.metadata, method)(bind=conn) 135 | 136 | def create_all(self): 137 | return self._call_metadata("create_all") 138 | 139 | def drop_all(self): 140 | return self._call_metadata("drop_all") 141 | 142 | def reflect(self): 143 | return self._call_metadata("reflect") 144 | 145 | def __repr__(self) -> str: 146 | return f"<{type(self).__name__} {self.engine.url}>" 147 | 148 | 149 | class AsyncBind(Bind): 150 | engine: sa.ext.asyncio.AsyncEngine 151 | Session: sa.ext.asyncio.async_sessionmaker 152 | 153 | def create_session_factory( 154 | self, options: dict[str, t.Any] 155 | ) -> sa.ext.asyncio.async_sessionmaker[sa.ext.asyncio.AsyncSession]: 156 | """ 157 | It took some research to figure out the following trick which combines sync and async 158 | sessionmakers to make the async_sessionmaker a valid target for sqlalchemy events. 159 | 160 | Details can be found at: 161 | https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#examples-of-event-listeners-with-async-engines-sessions-sessionmakers 162 | """ 163 | signals.before_bind_session_factory_created.send(self, options=options) 164 | 165 | sync_sessionmaker = sa.orm.sessionmaker() 166 | session_factory = sa.ext.asyncio.async_sessionmaker( 167 | bind=self.engine, 168 | sync_session_class=sync_sessionmaker, 169 | **options, 170 | ) 171 | 172 | signals.after_bind_session_factory_created.send( 173 | self, options=options, session_factory=session_factory 174 | ) 175 | return session_factory 176 | 177 | def create_engine( 178 | self, config: dict[str, t.Any], prefix: str = "" 179 | ) -> sa.ext.asyncio.AsyncEngine: 180 | signals.before_bind_engine_created.send(self, config=config, prefix=prefix) 181 | engine = sa.ext.asyncio.async_engine_from_config(config, prefix=prefix) 182 | signals.after_bind_engine_created.send(self, config=config, prefix=prefix, engine=engine) 183 | return engine 184 | 185 | def test_transaction(self, savepoint: bool = False): 186 | return AsyncTestTransaction(self, savepoint=savepoint) 187 | 188 | async def _call_metadata(self, method: str): 189 | async with self.engine.connect() as conn: 190 | async with conn.begin(): 191 | 192 | def sync_call(conn: sa.Connection, method: str): 193 | getattr(self.metadata, method)(bind=conn) 194 | 195 | return await conn.run_sync(sync_call, method) 196 | 197 | 198 | @signals.after_bind_session_factory_created.connect 199 | def register_soft_delete_support_for_session( 200 | bind: t.Union[Bind, AsyncBind], 201 | options: t.Dict[str, t.Any], 202 | session_factory: t.Union[sa.orm.sessionmaker, sa.ext.asyncio.async_sessionmaker], 203 | ) -> None: 204 | """Register the event handlers that enable soft-delete logic to be applied automatically. 205 | 206 | This functionality is opt-in by nature. Opt-in involves adding the SoftDeleteMixin to the 207 | ORM models that should support soft-delete. You can learn more by checking out the 208 | model.mixins module. 209 | """ 210 | if all( 211 | [ 212 | isinstance(session_factory, sa.ext.asyncio.async_sessionmaker), 213 | "sync_session_class" in session_factory.kw, 214 | ] 215 | ): 216 | session_factory = session_factory.kw["sync_session_class"] 217 | 218 | setup_soft_delete_for_session(session_factory) # type: ignore 219 | 220 | 221 | # Beware of Dragons! 222 | # 223 | # The following handlers aren't at all crucial to understanding how this package works, they are 224 | # mostly based on well known sqlalchemy recipes and their impact can be fully understood from 225 | # their docstrings alone. 226 | 227 | 228 | @signals.after_bind_engine_created.connect 229 | def register_engine_connection_cross_process_safety_handlers( 230 | sender: Bind, 231 | config: t.Dict[str, t.Any], 232 | prefix: str, 233 | engine: t.Union[sa.Engine, sa.ext.asyncio.AsyncEngine], 234 | ) -> None: 235 | """Register event handlers to invalidate connections shared across process boundaries. 236 | 237 | SQLAlchemy connections aren't safe to share across processes and most sqlalchemy engines 238 | contain a connection pool full of them. This will cause issues when these connections are 239 | used concurrently in multiple processes that are bizarre and become difficult to trace the 240 | origin of. This quart application utilizes multiple processes, one for each API worker, 241 | typically handled by the ASGI server, in our case hypercorn. Another place where we run into 242 | multiple processes is during testing. We use the pytest-xdist plugin to split our tests 243 | across multiple cores and drastically reduce the time required to complete. 244 | 245 | Both of these use cases dictate our application needs to be concerned with what objects 246 | could possibly be shared across processes and follow any recommendations made concerning 247 | that library/service around process forking/spawning. Usually the only resources we need 248 | to worry about are file descriptors (including sockets and network connections). 249 | 250 | SQLAlchemy has a section of their docs dedicated to this exact concern, see that page for 251 | more details: https://docs.sqlalchemy.org/en/20/core/pooling.html#pooling-multiprocessing 252 | """ 253 | 254 | # Use the sync_engine when AsyncEngine 255 | if isinstance(engine, sa.ext.asyncio.AsyncEngine): 256 | engine = engine.sync_engine 257 | 258 | def close_connections_for_forking(): 259 | engine.dispose(close=False) 260 | 261 | os.register_at_fork(before=close_connections_for_forking) 262 | 263 | def connect(dbapi_connection, connection_record): 264 | connection_record.info["pid"] = os.getpid() 265 | 266 | if not sa.event.contains(engine, "connect", connect): 267 | sa.event.listen(engine, "connect", connect) 268 | 269 | def checkout(dbapi_connection, connection_record, connection_proxy): 270 | pid = os.getpid() 271 | if connection_record.info["pid"] != pid: 272 | connection_record.dbapi_connection = connection_proxy.dbapi_connection = None 273 | raise sa.exc.DisconnectionError( 274 | "Connection record belongs to pid {}, attempting to check out in pid {}".format( 275 | connection_record.info["pid"], pid 276 | ) 277 | ) 278 | 279 | if not sa.event.contains(engine, "checkout", checkout): 280 | sa.event.listen(engine, "checkout", checkout) 281 | 282 | 283 | @signals.after_bind_engine_created.connect 284 | def register_engine_connection_sqlite_specific_transaction_fix( 285 | sender: Bind, 286 | config: t.Dict[str, t.Any], 287 | prefix: str, 288 | engine: t.Union[sa.Engine, sa.ext.asyncio.AsyncEngine], 289 | ) -> None: 290 | """Register event handlers to fix dbapi broken transaction for sqlite dialects. 291 | 292 | The pysqlite DBAPI driver has several long-standing bugs which impact the correctness of its 293 | transactional behavior. In its default mode of operation, SQLite features such as 294 | SERIALIZABLE isolation, transactional DDL, and SAVEPOINT support are non-functional, and in 295 | order to use these features, workarounds must be taken. 296 | 297 | The issue is essentially that the driver attempts to second-guess the user’s intent, failing 298 | to start transactions and sometimes ending them prematurely, in an effort to minimize the 299 | SQLite databases’s file locking behavior, even though SQLite itself uses “shared” locks for 300 | read-only activities. 301 | 302 | SQLAlchemy chooses to not alter this behavior by default, as it is the long-expected behavior 303 | of the pysqlite driver; if and when the pysqlite driver attempts to repair these issues, that 304 | will be more of a driver towards defaults for SQLAlchemy. 305 | 306 | The good news is that with a few events, we can implement transactional support fully, by 307 | disabling pysqlite’s feature entirely and emitting BEGIN ourselves. This is achieved using 308 | two event listeners: 309 | 310 | To learn more about this recipe, check out the sqlalchemy docs link below: 311 | https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#pysqlite-serializable 312 | """ 313 | 314 | # Use the sync_engine when AsyncEngine 315 | if isinstance(engine, sa.ext.asyncio.AsyncEngine): 316 | engine = engine.sync_engine 317 | 318 | if engine.dialect.name != "sqlite": 319 | return 320 | 321 | def do_connect(dbapi_connection, connection_record): 322 | # disable pysqlite's emitting of the BEGIN statement entirely. 323 | # also stops it from emitting COMMIT before any DDL. 324 | dbapi_connection.isolation_level = None 325 | 326 | if not sa.event.contains(engine, "connect", do_connect): 327 | sa.event.listen(engine, "connect", do_connect) 328 | 329 | def do_begin(conn_): 330 | # emit our own BEGIN 331 | conn_.exec_driver_sql("BEGIN") 332 | 333 | if not sa.event.contains(engine, "begin", do_begin): 334 | sa.event.listen(engine, "begin", do_begin) 335 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | import types 6 | import typing as t 7 | 8 | import sqlalchemy 9 | import sqlalchemy.event 10 | import sqlalchemy.exc 11 | import sqlalchemy.ext 12 | import sqlalchemy.ext.asyncio 13 | import sqlalchemy.orm 14 | import sqlalchemy.util 15 | import typing_extensions as tx 16 | from pydantic import BaseModel 17 | from pydantic import Field 18 | from pydantic import root_validator 19 | from sqlalchemy.orm.session import JoinTransactionMode 20 | from sqlalchemy.sql.compiler import Compiled 21 | 22 | from .model import Base 23 | from .types import BoundParamStyle 24 | from .types import DMLStrategy 25 | from .types import SessionBind 26 | from .types import SessionBindKey 27 | from .types import SynchronizeSession 28 | from .types import TransactionIsolationLevel 29 | 30 | 31 | sa = sqlalchemy 32 | 33 | 34 | def validate_dialect( 35 | config_class: BaseModel, 36 | values: t.Dict[str, t.Any], 37 | kind: tx.Literal["sync", "async"], 38 | ) -> t.Dict[str, t.Any]: 39 | try: 40 | engine = getattr(values, "engine") 41 | except AttributeError: 42 | engine = values.get("engine", {}) 43 | 44 | try: 45 | url = getattr(engine, "url") 46 | except AttributeError: 47 | url = engine.get("url", "sqlite://") 48 | url = sa.make_url(url) 49 | is_async = url.get_dialect().is_async 50 | 51 | if any( 52 | [ 53 | kind == "sync" and is_async is True, 54 | kind == "async" and is_async is False, 55 | ] 56 | ): 57 | raise ValueError(f"Async dialect required for {config_class.__name__}") 58 | 59 | return values 60 | 61 | 62 | class ConfigBase(BaseModel): 63 | class Config: 64 | arbitrary_types_allowed = True 65 | 66 | @classmethod 67 | def default(cls): 68 | return cls() 69 | 70 | 71 | class CoreExecutionOptions(ConfigBase): 72 | """ 73 | https://docs.sqlalchemy.org/en/20/core/connections.html#sqlalchemy.engine.Connection.execution_options 74 | """ 75 | 76 | isolation_level: t.Optional[TransactionIsolationLevel] = None 77 | compiled_cache: t.Optional[t.Dict[t.Any, Compiled]] = Field(default_factory=dict) 78 | logging_token: t.Optional[str] = None 79 | no_parameters: bool = False 80 | stream_results: bool = False 81 | max_row_buffer: int = 1000 82 | yield_per: t.Optional[int] = None 83 | insertmanyvalues_page_size: int = 1000 84 | schema_translate_map: t.Optional[t.Dict[str, str]] = None 85 | 86 | 87 | class ORMExecutionOptions(ConfigBase): 88 | """ 89 | https://docs.sqlalchemy.org/en/20/orm/queryguide/api.html#orm-queryguide-execution-options 90 | """ 91 | 92 | isolation_level: t.Optional[TransactionIsolationLevel] = None 93 | stream_results: bool = False 94 | yield_per: t.Optional[int] = None 95 | populate_existing: bool = False 96 | autoflush: bool = True 97 | identity_token: t.Optional[str] = None 98 | synchronize_session: SynchronizeSession = "auto" 99 | dml_strategy: DMLStrategy = "auto" 100 | 101 | 102 | class EngineConfig(ConfigBase): 103 | """ 104 | https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.create_engine 105 | """ 106 | 107 | url: t.Union[sa.URL, str] = "sqlite://" 108 | echo: bool = False 109 | echo_pool: bool = False 110 | connect_args: t.Dict[str, t.Any] = Field(default_factory=dict) 111 | execution_options: CoreExecutionOptions = Field(default_factory=CoreExecutionOptions) 112 | enable_from_linting: bool = True 113 | hide_parameters: bool = False 114 | insertmanyvalues_page_size: int = 1000 115 | isolation_level: t.Optional[TransactionIsolationLevel] = None 116 | json_deserializer: t.Callable[[str], t.Any] = json.loads 117 | json_serializer: t.Callable[[t.Any], str] = json.dumps 118 | label_length: t.Optional[int] = None 119 | logging_name: t.Optional[str] = None 120 | max_identifier_length: t.Optional[int] = None 121 | max_overflow: int = 10 122 | module: t.Optional[types.ModuleType] = None 123 | paramstyle: t.Optional[BoundParamStyle] = None 124 | pool: t.Optional[sa.Pool] = None 125 | poolclass: t.Optional[t.Type[sa.Pool]] = None 126 | pool_logging_name: t.Optional[str] = None 127 | pool_pre_ping: bool = False 128 | pool_size: int = 5 129 | pool_recycle: int = -1 130 | pool_reset_on_return: t.Optional[tx.Literal["values", "rollback"]] = None 131 | pool_timeout: int = 40 132 | pool_use_lifo: bool = False 133 | plugins: t.Sequence[str] = Field(default_factory=list) 134 | query_cache_size: int = 500 135 | user_insertmanyvalues: bool = True 136 | 137 | @classmethod 138 | def default(cls): 139 | return cls(url="sqlite://") 140 | 141 | @root_validator 142 | def apply_driver_defaults(cls, values): 143 | url = sa.make_url(values["url"]) 144 | driver = url.drivername 145 | 146 | if driver.startswith("sqlite"): 147 | if url.database is None or url.database in {"", ":memory:"}: 148 | values["poolclass"] = sa.StaticPool 149 | 150 | if "connect_args" not in values: 151 | values["connect_args"] = {} 152 | 153 | values["connect_args"]["check_same_thread"] = False 154 | else: 155 | # the url might look like sqlite:///file:path?uri=true 156 | is_uri = bool(url.query.get("uri", False)) 157 | mode = url.query.get("mode", "") 158 | 159 | if is_uri and mode == "memory": 160 | return values 161 | 162 | db_str = url.database[5:] if is_uri else url.database 163 | if not os.path.isabs(db_str): 164 | if is_uri: 165 | db_str = f"file:{db_str}" 166 | 167 | values["url"] = url.set(database=db_str) 168 | elif driver.startswith("mysql"): 169 | values.setdefault("pool_pre_ping", True) 170 | # set queue defaults only when using queue pool 171 | if "pool_class" not in values or values["pool_class"] is sa.QueuePool: 172 | values.setdefault("pool_recycle", 7200) 173 | 174 | if "charset" not in url.query: 175 | values["url"] = url.update_query_dict({"charset": "utf8mb4"}) 176 | 177 | return values 178 | 179 | 180 | class SessionOptions(ConfigBase): 181 | """ 182 | https://docs.sqlalchemy.org/en/20/orm/session_api.html#sqlalchemy.orm.Session 183 | """ 184 | 185 | autoflush: bool = True 186 | autobegin: bool = True 187 | expire_on_commit: bool = False 188 | bind: t.Optional[SessionBind] = None 189 | binds: t.Optional[t.Dict[SessionBindKey, SessionBind]] = None 190 | twophase: bool = False 191 | info: t.Optional[t.Dict[t.Any, t.Any]] = None 192 | join_transaction_mode: JoinTransactionMode = "conditional_savepoint" 193 | 194 | 195 | class SessionmakerOptions(SessionOptions): 196 | """ 197 | https://docs.sqlalchemy.org/en/20/orm/session_api.html#sqlalchemy.orm.sessionmaker 198 | """ 199 | 200 | class_: t.Type[sa.orm.Session] = sa.orm.Session 201 | 202 | 203 | class AsyncSessionOptions(SessionOptions): 204 | """ 205 | https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncSession 206 | """ 207 | 208 | sync_session_class: t.Type[sa.orm.Session] = sa.orm.Session 209 | 210 | 211 | class AsyncSessionmakerOptions(AsyncSessionOptions): 212 | """ 213 | https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.async_sessionmaker 214 | """ 215 | 216 | class_: t.Type[sa.ext.asyncio.AsyncSession] = sa.ext.asyncio.AsyncSession 217 | 218 | 219 | class BindConfig(ConfigBase): 220 | read_only: bool = False 221 | session: SessionmakerOptions = Field(default_factory=SessionmakerOptions.default) 222 | engine: EngineConfig = Field(default_factory=EngineConfig.default) 223 | 224 | @root_validator 225 | def validate_dialect(cls, values): 226 | return validate_dialect(cls, values, "sync") 227 | 228 | 229 | class AsyncBindConfig(BindConfig): 230 | session: AsyncSessionmakerOptions = Field(default_factory=AsyncSessionmakerOptions.default) 231 | 232 | @root_validator 233 | def validate_dialect(cls, values): 234 | return validate_dialect(cls, values, "async") 235 | 236 | 237 | def default(): 238 | dict(default=dict()) 239 | 240 | 241 | class SQLAlchemyConfig(ConfigBase): 242 | class Meta: 243 | web_config_field_map = { 244 | "SQLALCHEMY_MODEL_CLASS": "model_class", 245 | "SQLALCHEMY_BINDS": "binds", 246 | } 247 | 248 | model_class: t.Type[t.Any] = Base 249 | binds: t.Dict[str, t.Union[AsyncBindConfig, BindConfig]] = Field( 250 | default_factory=lambda: dict(default=BindConfig()) 251 | ) 252 | 253 | @classmethod 254 | def from_framework(cls, values: t.Dict[str, t.Any]): 255 | key_map = cls.Meta.web_config_field_map 256 | return cls(**{key_map.get(key, key): val for key, val in values.items()}) 257 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/framework/__init__.py: -------------------------------------------------------------------------------- 1 | from .extension import QuartSQLAlchemy 2 | 3 | 4 | __all__ = ["QuartSQLAlchemy"] 5 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/framework/cli.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | import urllib.parse 4 | 5 | import click 6 | from quart import current_app 7 | from quart.cli import AppGroup 8 | 9 | 10 | db_cli = AppGroup("db") 11 | 12 | 13 | @db_cli.command("info", with_appcontext=True) 14 | @click.option("--uri-only", is_flag=True, default=False, help="Only output the connection uri") 15 | def db_info(uri_only=False): 16 | db = current_app.extensions["sqlalchemy"].db 17 | uri = urllib.parse.unquote(str(db.engine.url)) 18 | db_info = dict(db.engine.url._asdict()) 19 | 20 | if uri_only: 21 | click.echo(uri) 22 | sys.exit(0) 23 | 24 | click.echo("Database Connection Info") 25 | click.echo(json.dumps(db_info, indent=2)) 26 | click.echo("\n") 27 | click.echo("Connection URI") 28 | click.echo(uri) 29 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/framework/extension.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from quart import Quart 4 | 5 | from .. import signals 6 | from ..config import SQLAlchemyConfig 7 | from ..sqla import SQLAlchemy 8 | from .cli import db_cli 9 | 10 | 11 | class QuartSQLAlchemy(SQLAlchemy): 12 | def __init__( 13 | self, 14 | config: SQLAlchemyConfig, 15 | app: t.Optional[Quart] = None, 16 | ): 17 | super().__init__(config) 18 | if app is not None: 19 | self.init_app(app) 20 | 21 | def init_app(self, app: Quart) -> None: 22 | if "sqlalchemy" in app.extensions: 23 | raise RuntimeError( 24 | f"A {type(self).__name__} instance has already been registered on this app" 25 | ) 26 | 27 | signals.before_framework_extension_initialization.send(self, app=app) 28 | 29 | app.extensions["sqlalchemy"] = self 30 | 31 | @app.shell_context_processor 32 | def export_sqlalchemy_objects(): 33 | return dict( 34 | db=self, 35 | **{m.class_.__name__: m.class_ for m in self.Model._sa_registry.mappers}, 36 | ) 37 | 38 | app.cli.add_command(db_cli) 39 | 40 | signals.before_framework_extension_initialization.send(self, app=app) 41 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/model/__init__.py: -------------------------------------------------------------------------------- 1 | from .columns import CreatedTimestamp 2 | from .columns import Json 3 | from .columns import PrimaryKey 4 | from .columns import UpdatedTimestamp 5 | from .custom_types import PydanticType 6 | from .custom_types import TZDateTime 7 | from .mixins import DynamicArgsMixin 8 | from .mixins import IdentityMixin 9 | from .mixins import RecursiveDictMixin 10 | from .mixins import ReprMixin 11 | from .mixins import setup_soft_delete_for_session 12 | from .mixins import SimpleDictMixin 13 | from .mixins import SoftDeleteMixin 14 | from .mixins import TableNameMixin 15 | from .mixins import TimestampMixin 16 | from .mixins import VersionMixin 17 | from .model import Base 18 | 19 | 20 | __all__ = [ 21 | "Base", 22 | "CreatedTimestamp", 23 | "DynamicArgsMixin", 24 | "IdentityMixin", 25 | "Json", 26 | "PrimaryKey", 27 | "PydanticType", 28 | "RecursiveDictMixin", 29 | "ReprMixin", 30 | "setup_soft_delete_for_session", 31 | "SimpleDictMixin", 32 | "SoftDeleteMixin", 33 | "TableNameMixin", 34 | "TimestampMixin", 35 | "TZDateTime", 36 | "UpdatedTimestamp", 37 | "VersionMixin", 38 | ] 39 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/model/columns.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from datetime import datetime 5 | 6 | import sqlalchemy 7 | import sqlalchemy.event 8 | import sqlalchemy.exc 9 | import sqlalchemy.ext 10 | import sqlalchemy.ext.asyncio 11 | import sqlalchemy.orm 12 | import sqlalchemy.util 13 | import sqlalchemy_utils 14 | import typing_extensions as tx 15 | 16 | 17 | sa = sqlalchemy 18 | sau = sqlalchemy_utils 19 | 20 | 21 | PrimaryKey = tx.Annotated[int, sa.orm.mapped_column(sa.Identity(), primary_key=True)] 22 | CreatedTimestamp = tx.Annotated[ 23 | datetime, 24 | sa.orm.mapped_column( 25 | default=sa.func.now(), 26 | server_default=sa.FetchedValue(), 27 | ), 28 | ] 29 | UpdatedTimestamp = tx.Annotated[ 30 | datetime, 31 | sa.orm.mapped_column( 32 | default=sa.func.now(), 33 | onupdate=sa.func.now(), 34 | server_default=sa.FetchedValue(), 35 | server_onupdate=sa.FetchedValue(), 36 | ), 37 | ] 38 | Json = tx.Annotated[ 39 | t.Dict[t.Any, t.Any], 40 | sa.orm.mapped_column(sau.JSONType, default_factory=dict), 41 | ] 42 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/model/custom_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from datetime import datetime 5 | from datetime import timezone 6 | 7 | import sqlalchemy 8 | import sqlalchemy.dialects.postgresql 9 | import sqlalchemy.engine.interfaces 10 | import sqlalchemy.sql.type_api 11 | import sqlalchemy.types 12 | from pydantic import BaseModel 13 | from pydantic import parse_obj_as 14 | 15 | 16 | sa = sqlalchemy 17 | 18 | 19 | class PydanticType(sa.types.TypeDecorator): 20 | """Pydantic type. 21 | 22 | SAVING: 23 | - Uses SQLAlchemy JSON type under the hood. 24 | - Accepts the pydantic model and converts it to a dict on save. 25 | - SQLAlchemy engine JSON-encodes the dict to a string. 26 | 27 | RETRIEVING: 28 | - Pulls the string from the database. 29 | - SQLAlchemy engine JSON-decodes the string to a dict. 30 | - Uses the dict to create a pydantic model. 31 | """ 32 | 33 | impl = sa.types.JSON 34 | cache_ok = False 35 | 36 | pydantic_type: BaseModel 37 | 38 | def __init__(self, pydantic_type: BaseModel): 39 | super().__init__() 40 | self.pydantic_type = pydantic_type 41 | 42 | def load_dialect_impl( 43 | self, 44 | dialect: sa.engine.interfaces.Dialect, 45 | ) -> sa.sql.type_api.TypeEngine[t.Any]: 46 | # Use JSONB for PostgreSQL and JSON for other databases. 47 | if dialect.name == "postgresql": 48 | return dialect.type_descriptor(sa.dialects.postgresql.JSONB()) 49 | else: 50 | return dialect.type_descriptor(sa.JSON()) 51 | 52 | def process_bind_param( 53 | self, 54 | value: t.Optional[BaseModel], 55 | dialect: sa.engine.interfaces.Dialect, 56 | ) -> t.Any: 57 | """Receive a bound parameter value to be converted/serialized.""" 58 | return value.dict() if value else None 59 | # If you use FasAPI, you can replace the line above with their jsonable_encoder(). 60 | # E.g., 61 | # from fastapi.encoders import jsonable_encoder 62 | # return jsonable_encoder(value) if value else None 63 | 64 | def process_result_value( 65 | self, 66 | value: t.Optional[str], 67 | dialect: sa.engine.interfaces.Dialect, 68 | ) -> t.Optional[BaseModel]: 69 | """Receive a result-row column value to be converted/deserialized.""" 70 | return parse_obj_as(self.pydantic_type, value) if value else None 71 | 72 | 73 | class TZDateTime(sa.types.TypeDecorator): 74 | impl = sa.types.DateTime 75 | cache_ok = True 76 | 77 | def process_bind_param( 78 | self, 79 | value: t.Optional[datetime], 80 | dialect: sa.engine.interfaces.Dialect, 81 | ) -> t.Any: 82 | if value is not None: 83 | if not value.tzinfo: 84 | raise TypeError("tzinfo is required") 85 | value = value.astimezone(timezone.utc).replace(tzinfo=None) 86 | return value 87 | 88 | def process_result_value( 89 | self, 90 | value: t.Optional[str], 91 | dialect: sa.engine.interfaces.Dialect, 92 | ) -> t.Optional[datetime]: 93 | if value is not None: 94 | value = value.replace(tzinfo=timezone.utc) 95 | return value 96 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/model/mixins.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from datetime import datetime 5 | 6 | import sqlalchemy 7 | import sqlalchemy.event 8 | import sqlalchemy.exc 9 | import sqlalchemy.ext 10 | import sqlalchemy.ext.asyncio 11 | import sqlalchemy.orm 12 | import sqlalchemy.util 13 | from sqlalchemy.orm import Mapped 14 | 15 | from ..util import camel_to_snake_case 16 | 17 | 18 | sa = sqlalchemy 19 | 20 | 21 | class TableNameMixin: 22 | @sa.orm.declared_attr.directive 23 | def __tablename__(cls) -> str: 24 | return camel_to_snake_case(cls.__name__) 25 | 26 | 27 | class ReprMixin: 28 | def __repr__(self) -> str: 29 | state = sa.inspect(self) 30 | if state is None: 31 | return super().__repr__() 32 | 33 | if state.transient: 34 | pk = f"(transient {id(self)})" 35 | elif state.pending: 36 | pk = f"(pending {id(self)})" 37 | else: 38 | pk = ", ".join(map(str, state.identity)) 39 | 40 | return f"<{type(self).__name__} {pk}>" 41 | 42 | 43 | class ComparableMixin: 44 | def __eq__(self, other): 45 | if type(self).__name__ != type(other).__name__: 46 | return False 47 | 48 | for key, column in sa.inspect(type(self)).columns.items(): 49 | if column.primary_key: 50 | continue 51 | 52 | if not (getattr(self, key) == getattr(other, key)): 53 | return False 54 | return True 55 | 56 | 57 | class TotalOrderMixin: 58 | def __lt__(self, other): 59 | if type(self).__name__ != type(other).__name__: 60 | return False 61 | 62 | for key, column in sa.inspect(type(self)).columns.items(): 63 | if column.primary_key: 64 | continue 65 | 66 | if not (getattr(self, key) == getattr(other, key)): 67 | return False 68 | return True 69 | 70 | 71 | class SimpleDictMixin: 72 | __abstract__ = True 73 | __table__: sa.Table 74 | 75 | def to_dict(self): 76 | return {c.name: getattr(self, c.name) for c in self.__table__.columns} 77 | 78 | 79 | class RecursiveDictMixin: 80 | __abstract__ = True 81 | 82 | def model_to_dict( 83 | self, 84 | obj: t.Optional[t.Any] = None, 85 | max_depth: int = 3, 86 | _children_seen: t.Optional[set] = None, 87 | _relations_seen: t.Optional[set] = None, 88 | ): 89 | """Convert model to python dict, with recursion. 90 | 91 | Args: 92 | obj (self): 93 | SQLAlchemy model inheriting from DeclarativeBase. 94 | max_depth (int): 95 | Maximum depth for recursion on relationships, defaults to 3. 96 | 97 | Returns: 98 | (dict) representation of the SQLAlchemy model. 99 | """ 100 | if obj is None: 101 | obj = self 102 | if _children_seen is None: 103 | _children_seen = set() 104 | if _relations_seen is None: 105 | _relations_seen = set() 106 | 107 | mapper = sa.inspect(obj).mapper 108 | columns = [column.key for column in mapper.columns] 109 | 110 | def get_key_value(c): 111 | return ( 112 | (c, getattr(obj, c).isoformat()) 113 | if isinstance(getattr(obj, c), datetime) 114 | else (c, getattr(obj, c)) 115 | ) 116 | 117 | data = dict(map(get_key_value, columns)) 118 | 119 | if max_depth > 0: 120 | for name, relation in mapper.relationships.items(): 121 | if name in _relations_seen: 122 | continue 123 | 124 | if relation.backref: 125 | _relations_seen.add(name) 126 | 127 | relationship_children = getattr(obj, name) 128 | if relationship_children is not None: 129 | if relation.uselist: 130 | children = [] 131 | for child in ( 132 | c for c in relationship_children if c not in _children_seen 133 | ): 134 | _children_seen.add(child) 135 | children.append( 136 | self.model_to_dict( 137 | child, 138 | max_depth=max_depth - 1, 139 | _children_seen=_children_seen, 140 | _relations_seen=_relations_seen, 141 | ) 142 | ) 143 | data[name] = children 144 | else: 145 | data[name] = self.model_to_dict( 146 | relationship_children, 147 | max_depth=max_depth - 1, 148 | _children_seen=_children_seen, 149 | _relations_seen=_relations_seen, 150 | ) 151 | 152 | return data 153 | 154 | 155 | class IdentityMixin: 156 | id: Mapped[int] = sa.orm.mapped_column( 157 | sa.Identity(), primary_key=True, autoincrement=True 158 | ) 159 | 160 | 161 | class SoftDeleteMixin: 162 | """Use as a mixin in a class to opt-in to the soft-delete feature. 163 | 164 | At initialization time, the `soft_delete_filter` function below is registered on the 165 | `do_orm_execute` event. 166 | 167 | The expected effects of using this mixin are the addition of an is_active column by default, and 168 | 169 | Example: 170 | class User(db.Model, SoftDeleteMixin): 171 | id: Mapped[int] = sa.orm.mapped_column(primary_key=True) 172 | email: Mapped[str] = sa.orm.mapped_column() 173 | 174 | db.create_all() 175 | 176 | u = User(email="joe@magic.link") 177 | db.session.add(u) 178 | db.session.commit() 179 | 180 | statement = select(User).where(name="joe@magic.link") 181 | 182 | # returns user 183 | result = db.session.execute(statement).scalars().one() 184 | 185 | # Mark inactive 186 | u.is_active = False 187 | db.session.add(u) 188 | db.session.commit() 189 | 190 | # User not found! 191 | result = db.session.execute(statement).scalars().one() 192 | 193 | # User found (when manually adding include_inactive execution option). 194 | # Now you can reactivate them if you like. 195 | result = db.session.execute(statement.execution_options(include_inactive=True)).scalars().one() 196 | 197 | see: https://docs.sqlalchemy.org/en/20/orm/versioning.html 198 | """ 199 | 200 | __abstract__ = True 201 | 202 | is_active: Mapped[bool] = sa.orm.mapped_column(default=True) 203 | 204 | 205 | class TimestampMixin: 206 | __abstract__ = True 207 | 208 | created_at: Mapped[datetime] = sa.orm.mapped_column(default=sa.func.now()) 209 | updated_at: Mapped[datetime] = sa.orm.mapped_column( 210 | default=sa.func.now(), onupdate=sa.func.now() 211 | ) 212 | 213 | 214 | class VersionMixin: 215 | __abstract__ = True 216 | 217 | version_id: Mapped[int] = sa.orm.mapped_column(nullable=False) 218 | 219 | @sa.orm.declared_attr.directive 220 | def __mapper_args__(cls) -> dict[str, t.Any]: 221 | return dict( 222 | version_id_col=cls.version_id, 223 | ) 224 | 225 | 226 | class EagerDefaultsMixin: 227 | """ 228 | https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.Mapper.params.eager_defaults 229 | """ 230 | 231 | __abstract__ = True 232 | 233 | @sa.orm.declared_attr.directive 234 | def __mapper_args__(cls) -> dict[str, t.Any]: 235 | return dict( 236 | eager_defaults=True, 237 | ) 238 | 239 | 240 | def soft_delete_filter(execute_state: sa.orm.ORMExecuteState) -> None: 241 | if execute_state.is_select and not execute_state.execution_options.get( 242 | "include_inactive", False 243 | ): 244 | execute_state.statement = execute_state.statement.options( 245 | sa.orm.with_loader_criteria( 246 | SoftDeleteMixin, 247 | lambda cls: cls.is_active == sa.true(), 248 | include_aliases=True, 249 | ) 250 | ) 251 | 252 | 253 | def setup_soft_delete_for_session(session: t.Type[sa.orm.Session]) -> None: 254 | if not sa.event.contains( 255 | session, 256 | "do_orm_execute", 257 | soft_delete_filter, 258 | ): 259 | sa.event.listen( 260 | session, 261 | "do_orm_execute", 262 | soft_delete_filter, 263 | propagate=True, 264 | ) 265 | 266 | 267 | def accumulate_mappings(class_, attribute) -> t.Dict[str, t.Any]: 268 | accumulated = {} 269 | for base_class in class_.__mro__[::-1]: 270 | if base_class is class_: 271 | continue 272 | args = getattr(base_class, attribute, {}) 273 | accumulated.update(args) 274 | 275 | return accumulated 276 | 277 | 278 | def accumulate_tuples_with_mapping(class_, attribute) -> t.Sequence[t.Any]: 279 | accumulated_map = {} 280 | accumulated_args = [] 281 | 282 | for base_class in class_.__mro__[::-1]: 283 | if base_class is class_: 284 | continue 285 | args = getattr(base_class, attribute, ()) 286 | for arg in args: 287 | if isinstance(arg, t.Mapping): 288 | accumulated_map.update(arg) 289 | else: 290 | accumulated_args.append(arg) 291 | 292 | if accumulated_map: 293 | accumulated_args.append(accumulated_map) 294 | return tuple(accumulated_args) 295 | 296 | 297 | class DynamicArgsMixin: 298 | __abstract__ = True 299 | 300 | @sa.orm.declared_attr.directive 301 | def __mapper_args__(cls) -> t.Dict[str, t.Any]: 302 | return accumulate_mappings(cls, "__mapper_args__") 303 | 304 | @sa.orm.declared_attr.directive 305 | def __table_args__(cls) -> t.Sequence[t.Any]: 306 | return accumulate_tuples_with_mapping(cls, "__table_args__") 307 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/model/model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | 5 | import sqlalchemy 6 | import sqlalchemy.event 7 | import sqlalchemy.exc 8 | import sqlalchemy.ext 9 | import sqlalchemy.ext.asyncio 10 | import sqlalchemy.orm 11 | import sqlalchemy.util 12 | import typing_extensions as tx 13 | 14 | from .mixins import ComparableMixin 15 | from .mixins import DynamicArgsMixin 16 | from .mixins import ReprMixin 17 | from .mixins import TableNameMixin 18 | 19 | 20 | sa = sqlalchemy 21 | 22 | 23 | class Base(DynamicArgsMixin, ReprMixin, ComparableMixin, TableNameMixin): 24 | __abstract__ = True 25 | 26 | type_annotation_map = { 27 | enum.Enum: sa.Enum(enum.Enum, native_enum=False, validate_strings=True), 28 | tx.Literal: sa.Enum(enum.Enum, native_enum=False, validate_strings=True), 29 | } 30 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeblackwaslike/quart-sqlalchemy/d08114dda99204f626d8434a23cb833a97c5fbce/src/quart_sqlalchemy/py.typed -------------------------------------------------------------------------------- /src/quart_sqlalchemy/retry.py: -------------------------------------------------------------------------------- 1 | """This module is a best effort first-iteration attempt to add retry logic to sqlalchemy. 2 | 3 | It's expected when working with a remote database to encounter exceptions related to deadlocks, 4 | transaction isolation measures, and overall connectivity issues. These exceptions are almost 5 | always from the DB-API level and mostly inherit from: 6 | * sqlalchemy.exc.OperationalError 7 | * sqlalchemy.exc.InternalError 8 | 9 | The tenacity library can be used here to add some retry logic to sqlalchemy transactions. 10 | 11 | There are a few ways to use tenacity: 12 | * You can use the t`enacity.retry` decorator to decorate the callable with retry logic. 13 | * You can use the `tenacity.Retrying` or `tenacity.AsyncRetrying` context managers to add 14 | retry logic to a block of code. 15 | 16 | WARNING: The following code in this module is experimental and needs to be completed before anything here 17 | can be relied on. It probably doesn't work in it's current form. Use the examples under Usage instead. 18 | * RetryingSession 19 | * retrying_session 20 | * retrying_async_session 21 | 22 | 23 | Usage: 24 | 25 | Example decorator usage: 26 | 27 | ```python 28 | @tenacity.retry(**config) 29 | def add_user_post(db, user_id, post_values): 30 | with db.bind.Session() as session: 31 | with session.begin(): 32 | user = session.scalars(sa.select(User).where(User.id == user_id)).one() 33 | post = Post(user=user, **post_values) 34 | session.add(post) 35 | session.flush() 36 | session.refresh(post) 37 | return post 38 | ``` 39 | 40 | Example async decorator usage: 41 | 42 | ```python 43 | @tenacity.retry(**config) 44 | async def add_user_post(db, user_id, post_values): 45 | async_bind = db.get_bind("async") 46 | async with async_bind.Session() as session: 47 | async with session.begin(): 48 | user = (await session.scalars(sa.select(User).where(User.id == user_id))).one() 49 | post = Post(user=user, **post_values) 50 | session.add(post) 51 | await session.commit() 52 | await session.refresh(post) 53 | return post 54 | ``` 55 | 56 | Example context manager usage: 57 | 58 | ```python 59 | try: 60 | for attempt in tenacity.Retrying(**config): 61 | with attempt: 62 | with db.bind.Session() as session: 63 | with session.begin(): 64 | obj = session.scalars(sa.select(User).where(User.id == 1)).one() 65 | post = Post(title="new post", user=obj) 66 | session.add(Post) 67 | except tenacity.RetryError: 68 | pass 69 | ``` 70 | 71 | Example async context manager usage: 72 | 73 | ```python 74 | async_bind = db.get_bind("async") 75 | try: 76 | async for attempt in tenacity.AsyncRetrying(**config): 77 | with attempt: 78 | async with async_bind.Session() as session: 79 | async with session.begin(): 80 | obj = (await session.scalars(sa.select(User).where(User.id == 1))).one() 81 | post = Post(title="new post", user=obj) 82 | session.add(Post) 83 | await session.commit() 84 | except tenacity.RetryError: 85 | pass 86 | ``` 87 | 88 | Check out the docs: https://tenacity.readthedocs.io/en/latest/ 89 | Check out the repo: https://github.com/jd/tenacity 90 | """ 91 | 92 | import logging 93 | from contextlib import asynccontextmanager 94 | from contextlib import AsyncExitStack 95 | from contextlib import contextmanager 96 | from contextlib import ExitStack 97 | 98 | import sqlalchemy 99 | import sqlalchemy.exc 100 | import sqlalchemy.orm 101 | import tenacity 102 | 103 | 104 | sa = sqlalchemy 105 | 106 | logger = logging.getLogger(__name__) 107 | 108 | 109 | _RETRY_ERRORS = ( 110 | sa.exc.InternalError, 111 | sa.exc.InvalidRequestError, 112 | sa.exc.OperationalError, 113 | ) 114 | 115 | 116 | retry_config = dict( 117 | reraise=True, 118 | retry=tenacity.retry_if_exception_type(_RETRY_ERRORS) 119 | | tenacity.retry_if_exception_message(match="Too many connections"), 120 | stop=tenacity.stop_after_attempt(3) | tenacity.stop_after_delay(5), 121 | wait=tenacity.wait_exponential(max=6, exp_base=1.5), 122 | before_sleep=tenacity.before_sleep_log(logger, logging.INFO), 123 | ) 124 | 125 | retryable = tenacity.retry(**retry_config) 126 | retry_context = tenacity.Retrying(**retry_config) 127 | 128 | 129 | class RetryingSession(sa.orm.Session): 130 | @tenacity.retry(**retry_config) 131 | def _execute_internal(self, *args, **kwargs): 132 | return super()._execute_internal(*args, **kwargs) 133 | 134 | 135 | @contextmanager 136 | def retrying_session(bind, begin=True, **kwargs): 137 | try: 138 | for attempt in tenacity.Retrying(**retry_config, **kwargs): 139 | with attempt: 140 | print("attempt", attempt.retry_state.attempt_number) 141 | with bind.Session() as session: 142 | with ExitStack() as stack: 143 | if begin: 144 | stack.enter_context(session.begin()) 145 | yield session 146 | except tenacity.RetryError: 147 | pass 148 | 149 | 150 | @asynccontextmanager 151 | async def retrying_async_session(bind, begin=True, **kwargs): 152 | try: 153 | async for attempt in tenacity.AsyncRetrying(**retry_config, **kwargs): 154 | with attempt: 155 | async with bind.Session() as session: 156 | async with AsyncExitStack() as stack: 157 | if begin: 158 | await stack.enter_async_context(session.begin()) 159 | yield session 160 | except tenacity.RetryError: 161 | pass 162 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import sqlalchemy 6 | import sqlalchemy.exc 7 | import sqlalchemy.ext.asyncio 8 | import sqlalchemy.orm 9 | from quart import abort 10 | from sqlapagination import KeySetPage 11 | from sqlapagination import KeySetPaginator 12 | 13 | from .types import EntityIdT 14 | from .types import EntityT 15 | from .types import ORMOption 16 | 17 | 18 | sa = sqlalchemy 19 | 20 | 21 | class Session(sa.orm.Session, t.Generic[EntityT, EntityIdT]): 22 | """A SQLAlchemy :class:`~sqlalchemy.orm.Session` class. 23 | 24 | To customize ``db.session``, subclass this and pass it as the ``class_`` key in the 25 | ``session_options``. 26 | """ 27 | 28 | def get_or_404( 29 | self, 30 | entity: EntityT, 31 | id_: EntityIdT, 32 | options: t.Sequence[ORMOption] = (), 33 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 34 | for_update: bool = False, 35 | include_inactive: bool = False, 36 | description: t.Optional[str] = None, 37 | **kwargs, 38 | ) -> EntityT: 39 | """Like :meth:`session.get() ` but aborts with a 40 | ``404 Not Found`` error instead of returning ``None``. 41 | 42 | :param entity: The model class to query. 43 | :param id_: The primary key to query. 44 | :param description: A custom message to show on the error page. 45 | 46 | .. versionadded:: 3.0 47 | """ 48 | execution_options = execution_options or {} 49 | if include_inactive: 50 | execution_options.setdefault("include_inactive", include_inactive) 51 | 52 | statement = sa.select(self.model).where(self.model.id == id_).limit(1) # type: ignore 53 | 54 | for option in options: 55 | statement = statement.options(option) 56 | 57 | if for_update: 58 | statement = statement.with_for_update() 59 | 60 | result = self.scalars(statement, execution_options=execution_options).one_or_none() 61 | 62 | if result is None: 63 | abort(404, description=description) 64 | 65 | return result 66 | 67 | def first_or_404( 68 | self, 69 | statement: sa.sql.Select[t.Any], 70 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 71 | include_inactive: bool = False, 72 | description: t.Optional[str] = None, 73 | **kwargs: t.Any, 74 | ) -> EntityT: 75 | """Like :meth:`Result.scalar() `, but aborts 76 | with a ``404 Not Found`` error instead of returning ``None``. 77 | 78 | :param statement: The ``select`` statement to execute. 79 | :param description: A custom message to show on the error page. 80 | 81 | .. versionadded:: 3.0 82 | """ 83 | execution_options = execution_options or {} 84 | if include_inactive: 85 | execution_options.setdefault("include_inactive", include_inactive) 86 | 87 | result = self.scalars(statement, execution_options=execution_options, **kwargs).first() 88 | 89 | if result is None: 90 | abort(404, description=description) 91 | 92 | return result 93 | 94 | def one_or_404( 95 | self, 96 | statement: sa.sql.Select[t.Any], 97 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 98 | include_inactive: bool = False, 99 | description: t.Optional[str] = None, 100 | **kwargs, 101 | ) -> EntityT: 102 | """Like :meth:`Result.scalar_one() `, 103 | but aborts with a ``404 Not Found`` error instead of raising ``sa.exc.NoResultFound`` 104 | or ``sa.exc.MultipleResultsFound``. 105 | 106 | :param statement: The ``select`` statement to execute. 107 | :param description: A custom message to show on the error page. 108 | 109 | .. versionadded:: 3.0 110 | """ 111 | execution_options = execution_options or {} 112 | if include_inactive: 113 | execution_options.setdefault("include_inactive", include_inactive) 114 | 115 | try: 116 | return self.scalars(statement, execution_options=execution_options, **kwargs).one() 117 | except (sa.exc.NoResultFound, sa.exc.MultipleResultsFound): 118 | abort(404, description=description) 119 | 120 | def paginate( 121 | self, 122 | selectable: sa.sql.Select[t.Any], 123 | page_size: int = 10, 124 | bookmark: t.Optional[t.Dict[str, t.Any]] = None, 125 | ) -> KeySetPage: 126 | """Apply keyset pagination to a select statment based on the current page and 127 | number of items per page, returning a :class:`.KeySetPage` object. 128 | 129 | The statement should select a model class, like ``select(User)``, and have 130 | an order_by clause containing a key unique to the table. The easiest way to 131 | achieve the latter is ensure the order_by clause contains the primary_key as 132 | the last column. 133 | 134 | WARNING: Not yet tested, experimental, use with caution! 135 | """ 136 | paginator = KeySetPaginator( 137 | selectable, 138 | page_size=page_size, 139 | bookmark=bookmark, 140 | ) 141 | statement = paginator.get_modified_sql_statement() 142 | result = self.scalars(statement).all() 143 | return paginator.parse_result(result) 144 | 145 | 146 | class AsyncSession(sa.ext.asyncio.AsyncSession, t.Generic[EntityT]): 147 | async def get_or_404( 148 | self, 149 | entity: EntityT, 150 | id_: EntityIdT, 151 | options: t.Sequence[ORMOption] = (), 152 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 153 | for_update: bool = False, 154 | include_inactive: bool = False, 155 | description: t.Optional[str] = None, 156 | **kwargs, 157 | ) -> EntityT: 158 | """Like :meth:`session.get() ` but aborts with a 159 | ``404 Not Found`` error instead of returning ``None``. 160 | 161 | :param entity: The model class to query. 162 | :param id_: The primary key to query. 163 | :param description: A custom message to show on the error page. 164 | 165 | .. versionadded:: 3.0 166 | """ 167 | execution_options = execution_options or {} 168 | if include_inactive: 169 | execution_options.setdefault("include_inactive", include_inactive) 170 | 171 | statement = sa.select(self.model).where(self.model.id == id_).limit(1) # type: ignore 172 | 173 | for option in options: 174 | statement = statement.options(option) 175 | 176 | if for_update: 177 | statement = statement.with_for_update() 178 | 179 | result = (await self.scalars(statement, execution_options=execution_options)).one_or_none() 180 | 181 | if result is None: 182 | abort(404, description=description) 183 | 184 | return result 185 | 186 | async def first_or_404( 187 | self, 188 | statement: sa.sql.Select[t.Any], 189 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 190 | include_inactive: bool = False, 191 | description: t.Optional[str] = None, 192 | **kwargs: t.Any, 193 | ) -> EntityT: 194 | """Like :meth:`Result.scalar() `, but aborts 195 | with a ``404 Not Found`` error instead of returning ``None``. 196 | 197 | :param statement: The ``select`` statement to execute. 198 | :param description: A custom message to show on the error page. 199 | 200 | .. versionadded:: 3.0 201 | """ 202 | execution_options = execution_options or {} 203 | if include_inactive: 204 | execution_options.setdefault("include_inactive", include_inactive) 205 | 206 | result = ( 207 | await self.scalars(statement, execution_options=execution_options, **kwargs) 208 | ).first() 209 | 210 | if result is None: 211 | abort(404, description=description) 212 | 213 | return result 214 | 215 | async def one_or_404( 216 | self, 217 | statement: sa.sql.Select[t.Any], 218 | execution_options: t.Optional[t.Dict[str, t.Any]] = None, 219 | include_inactive: bool = False, 220 | description: t.Optional[str] = None, 221 | **kwargs, 222 | ) -> EntityT: 223 | """Like :meth:`Result.scalar_one() `, 224 | but aborts with a ``404 Not Found`` error instead of raising ``sa.exc.NoResultFound`` 225 | or ``sa.exc.MultipleResultsFound``. 226 | 227 | :param statement: The ``select`` statement to execute. 228 | :param description: A custom message to show on the error page. 229 | 230 | .. versionadded:: 3.0 231 | """ 232 | execution_options = execution_options or {} 233 | if include_inactive: 234 | execution_options.setdefault("include_inactive", include_inactive) 235 | 236 | try: 237 | return ( 238 | await self.scalars(statement, execution_options=execution_options, **kwargs) 239 | ).one() 240 | except (sa.exc.NoResultFound, sa.exc.MultipleResultsFound): 241 | abort(404, description=description) 242 | 243 | async def paginate( 244 | self, 245 | selectable: sa.sql.Select[t.Any], 246 | page_size: int = 10, 247 | bookmark: t.Optional[t.Dict[str, t.Any]] = None, 248 | ) -> KeySetPage: 249 | """Apply keyset pagination to a select statment based on the current page and 250 | number of items per page, returning a :class:`.KeySetPage` object. 251 | 252 | The statement should select a model class, like ``select(User)``, and have 253 | an order_by clause containing a key unique to the table. The easiest way to 254 | achieve the latter is ensure the order_by clause contains the primary_key as 255 | the last column. 256 | 257 | WARNING: Not yet tested, experimental, use with caution! 258 | """ 259 | paginator = KeySetPaginator( 260 | selectable, 261 | page_size=page_size, 262 | bookmark=bookmark, 263 | ) 264 | statement = paginator.get_modified_sql_statement() 265 | result = (await self.scalars(statement)).all() 266 | return paginator.parse_result(result) 267 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/signals.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sqlalchemy 4 | import sqlalchemy.orm 5 | from blinker import Namespace 6 | 7 | 8 | sa = sqlalchemy 9 | 10 | _signals = Namespace() 11 | 12 | 13 | before_bind_engine_created = _signals.signal( 14 | "quart-sqlalchemy.bind.engine.created.before", 15 | doc="""Called before a bind creates an engine. 16 | 17 | Handlers should have the following signature: 18 | def handler( 19 | sender: t.Union[Bind, AsyncBind], 20 | config: Dict[str, Any], 21 | prefix: str, 22 | ) -> None: 23 | ... 24 | """, 25 | ) 26 | after_bind_engine_created = _signals.signal( 27 | "quart-sqlalchemy.bind.engine.created.after", 28 | doc="""Called after a bind creates an engine. 29 | 30 | Handlers should have the following signature: 31 | def handler( 32 | sender: t.Union[Bind, AsyncBind], 33 | config: Dict[str, Any], 34 | prefix: str, 35 | engine: sa.Engine, 36 | ) -> None: 37 | ... 38 | """, 39 | ) 40 | 41 | before_bind_session_factory_created = _signals.signal( 42 | "quart-sqlalchemy.bind.session_factory.created.before", 43 | doc="""Called before a bind creates a session_factory. 44 | 45 | Handlers should have the following signature: 46 | def handler(sender: t.Union[Bind, AsyncBind], options: Dict[str, Any]) -> None: 47 | ... 48 | """, 49 | ) 50 | after_bind_session_factory_created = _signals.signal( 51 | "quart-sqlalchemy.bind.session_factory.created.after", 52 | doc="""Called after a bind creates a session_factory. 53 | 54 | Handlers should have the following signature: 55 | def handler( 56 | sender: t.Union[Bind, AsyncBind], 57 | options: Dict[str, Any], 58 | session_factory: t.Union[sa.orm.sessionmaker, sa.ext.asyncio.async_sessionmaker], 59 | ) -> None: 60 | ... 61 | """, 62 | ) 63 | 64 | 65 | bind_context_entered = _signals.signal( 66 | "quart-sqlalchemy.bind.context.entered", 67 | doc="""Called when a bind context is entered. 68 | 69 | Handlers should have the following signature: 70 | def handler( 71 | sender: t.Union[Bind, AsyncBind], 72 | engine_execution_options: Dict[str, Any], 73 | session_execution_options: Dict[str, Any], 74 | context: BindContext, 75 | ) -> None: 76 | ... 77 | """, 78 | ) 79 | 80 | bind_context_exited = _signals.signal( 81 | "quart-sqlalchemy.bind.context.exited", 82 | doc="""Called when a bind context is exited. 83 | 84 | Handlers should have the following signature: 85 | def handler( 86 | sender: t.Union[Bind, AsyncBind], 87 | engine_execution_options: Dict[str, Any], 88 | session_execution_options: Dict[str, Any], 89 | context: BindContext, 90 | ) -> None: 91 | ... 92 | """, 93 | ) 94 | 95 | 96 | before_framework_extension_initialization = _signals.signal( 97 | "quart-sqlalchemy.framework.extension.initialization.before", 98 | doc="""Fired before SQLAlchemy.init_app(app) is called. 99 | 100 | Handler signature: 101 | def handle(sender: QuartSQLAlchemy, app: Quart): 102 | ... 103 | """, 104 | ) 105 | after_framework_extension_initialization = _signals.signal( 106 | "quart-sqlalchemy.framework.extension.initialization.after", 107 | doc="""Fired after SQLAlchemy.init_app(app) is called. 108 | 109 | Handler signature: 110 | def handle(sender: QuartSQLAlchemy, app: Quart): 111 | ... 112 | """, 113 | ) 114 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/sqla.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import sqlalchemy 6 | import sqlalchemy.event 7 | import sqlalchemy.exc 8 | import sqlalchemy.ext 9 | import sqlalchemy.ext.asyncio 10 | import sqlalchemy.orm 11 | import sqlalchemy.util 12 | 13 | from .bind import AsyncBind 14 | from .bind import Bind 15 | from .config import AsyncBindConfig 16 | from .config import SQLAlchemyConfig 17 | 18 | 19 | sa = sqlalchemy 20 | 21 | 22 | class SQLAlchemy: 23 | config: SQLAlchemyConfig 24 | binds: t.Dict[str, t.Union[Bind, AsyncBind]] 25 | Model: t.Type[sa.orm.DeclarativeBase] 26 | 27 | def __init__(self, config: SQLAlchemyConfig, initialize: bool = True): 28 | self.config = config 29 | 30 | if initialize: 31 | self.initialize() 32 | 33 | def initialize(self): 34 | if issubclass(self.config.model_class, sa.orm.DeclarativeBase): 35 | Model = self.config.model_class # type: ignore 36 | else: 37 | 38 | class Model(self.config.model_class, sa.orm.DeclarativeBase): 39 | pass 40 | 41 | self.Model = Model 42 | 43 | self.binds = {} 44 | for name, bind_config in self.config.binds.items(): 45 | is_async = isinstance(bind_config, AsyncBindConfig) 46 | if is_async: 47 | self.binds[name] = AsyncBind(bind_config, self.metadata) 48 | else: 49 | self.binds[name] = Bind(bind_config, self.metadata) 50 | 51 | @classmethod 52 | def default(cls): 53 | return cls(SQLAlchemyConfig()) 54 | 55 | @property 56 | def bind(self) -> Bind: 57 | return self.get_bind() 58 | 59 | @property 60 | def metadata(self) -> sa.MetaData: 61 | return self.Model.metadata 62 | 63 | def get_bind(self, bind: str = "default"): 64 | return self.binds[bind] 65 | 66 | def create_all(self, bind: str = "default"): 67 | return self.binds[bind].create_all() 68 | 69 | def drop_all(self, bind: str = "default"): 70 | return self.binds[bind].drop_all() 71 | 72 | def __repr__(self) -> str: 73 | return f"<{type(self).__name__} {self.bind.engine.url}>" 74 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/testing/__init__.py: -------------------------------------------------------------------------------- 1 | from .transaction import AsyncTestTransaction 2 | from .transaction import TestTransaction 3 | 4 | 5 | __all__ = ["AsyncTestTransaction", "TestTransaction"] 6 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/testing/transaction.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import sqlalchemy 4 | import sqlalchemy.ext.asyncio 5 | import sqlalchemy.orm 6 | from exceptiongroup import ExceptionGroup 7 | 8 | 9 | if t.TYPE_CHECKING: 10 | from ..bind import AsyncBind 11 | from ..bind import Bind 12 | 13 | sa = sqlalchemy 14 | 15 | 16 | class TestTransaction: 17 | bind: "Bind" 18 | connection: sa.Connection 19 | trans: sa.Transaction 20 | nested: t.Optional[sa.NestedTransaction] = None 21 | 22 | def __init__(self, bind: "Bind", savepoint: bool = False): 23 | self.savepoint = savepoint 24 | self.bind = bind 25 | 26 | def Session(self, **options): 27 | options.update(bind=self.connection) 28 | if self.savepoint: 29 | options.update(join_transaction_mode="create_savepoint") 30 | return self.bind.Session(**options) 31 | 32 | def begin(self): 33 | self.connection = self.bind.engine.connect() 34 | self.trans = self.connection.begin() 35 | 36 | if self.savepoint: 37 | self.nested = self.connection.begin_nested() 38 | 39 | def close(self, exc: t.Optional[Exception] = None) -> None: 40 | exceptions = [] 41 | if exc: 42 | exceptions.append(exc) 43 | 44 | if hasattr(self, "nested"): 45 | try: 46 | self.trans.rollback() 47 | except Exception as trans_err: 48 | exceptions.append(trans_err) 49 | 50 | if hasattr(self, "connection"): 51 | try: 52 | self.connection.close() 53 | except Exception as conn_err: 54 | exceptions.append(conn_err) 55 | 56 | if exceptions: 57 | raise ExceptionGroup( 58 | f"Exceptions were raised inside a {type(self).__name__}", exceptions 59 | ) 60 | 61 | def __enter__(self): 62 | self.begin() 63 | return self 64 | 65 | def __exit__(self, exc_type, exc_val, exc_tb): 66 | self.close(exc_val) 67 | 68 | def __repr__(self): 69 | if hasattr(self, "bind") and self.bind is not None: 70 | url = str(self.bind.url) 71 | else: 72 | url = "no app context" 73 | return f"<{type(self).__name__} {url}>" 74 | 75 | 76 | class AsyncTestTransaction(TestTransaction): 77 | bind: "AsyncBind" 78 | connection: sa.ext.asyncio.AsyncConnection 79 | trans: sa.ext.asyncio.AsyncTransaction 80 | nested: t.Optional[sa.ext.asyncio.AsyncTransaction] = None 81 | 82 | def __init__(self, bind: "AsyncBind", savepoint: bool = False): 83 | super().__init__(bind, savepoint=savepoint) 84 | 85 | async def begin(self): 86 | self.connection = await self.bind.engine.connect() 87 | self.trans = await self.connection.begin() 88 | 89 | if self.savepoint: 90 | self.nested = await self.connection.begin_nested() 91 | 92 | async def close(self, exc: t.Optional[Exception] = None) -> None: 93 | exceptions = [] 94 | if exc: 95 | exceptions.append(exc) 96 | 97 | if hasattr(self, "nested"): 98 | try: 99 | await self.trans.rollback() 100 | except Exception as trans_err: 101 | exceptions.append(trans_err) 102 | 103 | if hasattr(self, "connection"): 104 | try: 105 | await self.connection.close() 106 | except Exception as conn_err: 107 | exceptions.append(conn_err) 108 | 109 | if exceptions: 110 | raise ExceptionGroup( 111 | f"Exceptions were raised inside a {type(self).__name__}", exceptions 112 | ) 113 | 114 | async def __aenter__(self): 115 | await self.begin() 116 | return self 117 | 118 | async def __aexit__(self, exc_type, exc_val, exc_tb): 119 | await self.close(exc_val) 120 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import sqlalchemy 6 | import sqlalchemy.ext.asyncio 7 | import sqlalchemy.orm 8 | import sqlalchemy.sql 9 | import typing_extensions as tx 10 | from sqlalchemy.orm.interfaces import ORMOption as _ORMOption 11 | from sqlalchemy.sql._typing import _ColumnExpressionArgument 12 | from sqlalchemy.sql._typing import _ColumnsClauseArgument 13 | from sqlalchemy.sql._typing import _DMLTableArgument 14 | 15 | 16 | sa = sqlalchemy 17 | 18 | SessionT = t.TypeVar("SessionT", bound=sa.orm.Session) 19 | EntityT = t.TypeVar("EntityT", bound=sa.orm.DeclarativeBase) 20 | EntityIdT = t.TypeVar("EntityIdT", bound=t.Any) 21 | 22 | ColumnExpr = _ColumnExpressionArgument 23 | Selectable = _ColumnsClauseArgument 24 | DMLTable = _DMLTableArgument 25 | ORMOption = _ORMOption 26 | 27 | TransactionIsolationLevel = tx.Literal[ 28 | "AUTOCOMMIT", 29 | "READ COMMITTED", 30 | "READ UNCOMMITTED", 31 | "REPEATABLE READ", 32 | "SERIALIZABLE", 33 | ] 34 | BoundParamStyle = tx.Literal["qmark", "numeric", "named", "format"] 35 | SessionBindKey = t.Union[t.Type[t.Any], sa.orm.Mapper[t.Any], sa.sql.TableClause, str] 36 | SessionBind = t.Union[sa.Engine, sa.Connection] 37 | SynchronizeSession = tx.Literal[False, "auto", "evaluate", "fetch"] 38 | DMLStrategy = tx.Literal["bulk", "raw", "orm", "auto"] 39 | 40 | SABind = t.Union[ 41 | sa.Engine, sa.Connection, sa.ext.asyncio.AsyncEngine, sa.ext.asyncio.AsyncConnection 42 | ] 43 | -------------------------------------------------------------------------------- /src/quart_sqlalchemy/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import re 5 | import typing as t 6 | 7 | import sqlalchemy 8 | import sqlalchemy.event 9 | import sqlalchemy.exc 10 | import sqlalchemy.ext 11 | import sqlalchemy.ext.asyncio 12 | import sqlalchemy.orm 13 | import sqlalchemy.util 14 | 15 | 16 | sa = sqlalchemy 17 | 18 | 19 | T = t.TypeVar("T") 20 | 21 | 22 | class lazy_property(t.Generic[T]): 23 | """Lazily-evaluated property decorator. 24 | 25 | This class is an implementation of a non-overriding descriptor. Translation: it implements 26 | the __get__ method but not the __set__ method. That allows it to be shadowed by directly 27 | setting a value in the instance dictionary of the same name. That means the first time this 28 | descriptor is accessed, the value is computed using the provided factory. That value is then 29 | set directly in the instance dictionary, meaning all further reads will be shadowed by the 30 | value that was set in the instance dictionary. Think of this like the perfect lazy-loaded 31 | attribute. 32 | 33 | Since `factory` is a callable, this class can be used as a decorator, much like the builtin 34 | `property`. The decorated method will be replaced with this descriptor, and used to lazily 35 | compute and cache the value on first access. 36 | 37 | Usage: 38 | >>> class MyClass: 39 | ... @lazy_property 40 | ... def expensive_computation(self): 41 | ... print('Computing value') 42 | ... time.sleep(3) 43 | ... return 'large prime number' 44 | ... 45 | ... my_class = MyClass() 46 | >>> my_class.expensive_computation 47 | Computing value 48 | 'large prime number' 49 | >>> my_class.expensive_computation 50 | 'large prime number' 51 | 52 | Ref: https://docs.python.org/3/howto/descriptor.html#definition-and-introduction 53 | """ 54 | 55 | def __init__(self, factory: t.Callable[[t.Any], T]): 56 | self.factory = factory 57 | self.name = factory.__name__ 58 | # update self (descriptor) to look like the factory function 59 | functools.update_wrapper(self, factory) 60 | 61 | def __get__(self, instance: t.Any, type_: t.Optional[t.Any] = None) -> T: 62 | if instance is None: 63 | return self 64 | 65 | instance.__dict__[self.name] = self.factory(instance) 66 | return instance.__dict__[self.name] 67 | 68 | 69 | def sqlachanges(sa_object): 70 | """ 71 | Returns the changes made to this object so far this session, in {'propertyname': [listofvalues] } format. 72 | """ 73 | attrs = sa.inspect(sa_object).attrs 74 | return {a.key: list(reversed(a.history.sum())) for a in attrs if len(a.history.sum()) > 1} 75 | 76 | 77 | def camel_to_snake_case(name: str) -> str: 78 | """Convert a ``CamelCase`` name to ``snake_case``.""" 79 | name = re.sub(r"((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))", r"_\1", name) 80 | return name.lower().lstrip("_") 81 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeblackwaslike/quart-sqlalchemy/d08114dda99204f626d8434a23cb833a97c5fbce/tests/__init__.py -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | import typing as t 5 | from datetime import datetime 6 | 7 | import pytest 8 | import sqlalchemy 9 | import sqlalchemy.orm 10 | from quart import Quart 11 | from sqlalchemy.orm import Mapped 12 | 13 | from quart_sqlalchemy import SQLAlchemyConfig 14 | from quart_sqlalchemy.framework import QuartSQLAlchemy 15 | 16 | from . import constants 17 | 18 | 19 | sa = sqlalchemy 20 | 21 | 22 | class SimpleTestBase: 23 | @pytest.fixture(scope="class") 24 | def app(self, request): 25 | app = Quart(request.module.__name__) 26 | app.config.from_mapping({"TESTING": True}) 27 | return app 28 | 29 | @pytest.fixture(scope="class") 30 | def sqlalchemy_config(self): 31 | return SQLAlchemyConfig.parse_obj(constants.simple_mapping_config) 32 | 33 | @pytest.fixture(scope="class") 34 | def db(self, sqlalchemy_config, app: Quart) -> QuartSQLAlchemy: 35 | return QuartSQLAlchemy(sqlalchemy_config, app) 36 | # yield db 37 | # db.drop_all() 38 | 39 | @pytest.fixture(scope="class") 40 | def models(self, app: Quart, db: QuartSQLAlchemy) -> t.Mapping[str, t.Type[t.Any]]: 41 | class Todo(db.Model): 42 | id: Mapped[int] = sa.orm.mapped_column( 43 | sa.Identity(), primary_key=True, autoincrement=True 44 | ) 45 | title: Mapped[str] = sa.orm.mapped_column(default="default") 46 | user_id: Mapped[t.Optional[int]] = sa.orm.mapped_column(sa.ForeignKey("user.id")) 47 | 48 | user: Mapped[t.Optional["User"]] = sa.orm.relationship( 49 | back_populates="todos", lazy="noload", uselist=False 50 | ) 51 | 52 | class User(db.Model): 53 | id: Mapped[int] = sa.orm.mapped_column( 54 | sa.Identity(), 55 | primary_key=True, 56 | autoincrement=True, 57 | ) 58 | name: Mapped[str] = sa.orm.mapped_column(default="default") 59 | 60 | created_at: Mapped[datetime] = sa.orm.mapped_column( 61 | default=sa.func.now(), 62 | server_default=sa.FetchedValue(), 63 | ) 64 | 65 | time_updated: Mapped[datetime] = sa.orm.mapped_column( 66 | default=sa.func.now(), 67 | onupdate=sa.func.now(), 68 | server_default=sa.FetchedValue(), 69 | server_onupdate=sa.FetchedValue(), 70 | ) 71 | 72 | todos: Mapped[t.List[Todo]] = sa.orm.relationship(lazy="noload", back_populates="user") 73 | 74 | return dict(todo=Todo, user=User) 75 | 76 | @pytest.fixture(scope="class", autouse=True) 77 | def create_drop_all(self, db: QuartSQLAlchemy, models): 78 | db.create_all() 79 | yield 80 | db.drop_all() 81 | 82 | @pytest.fixture(scope="class") 83 | def Todo(self, models: t.Mapping[str, t.Type[t.Any]]) -> t.Type[sa.orm.DeclarativeBase]: 84 | return models["todo"] 85 | 86 | @pytest.fixture(scope="class") 87 | def User(self, models: t.Mapping[str, t.Type[t.Any]]) -> t.Type[sa.orm.DeclarativeBase]: 88 | return models["user"] 89 | 90 | @pytest.fixture(scope="class") 91 | def _user_fixtures(self, User: t.Type[t.Any], Todo: t.Type[t.Any]): 92 | users = [] 93 | for i in range(5): 94 | user = User(name=f"user: {i}") 95 | for j in range(random.randint(0, 6)): 96 | todo = Todo(title=f"todo: {j}") 97 | user.todos.append(todo) 98 | users.append(user) 99 | return users 100 | 101 | @pytest.fixture(scope="class") 102 | def _add_fixtures( 103 | self, db: QuartSQLAlchemy, User: t.Type[t.Any], Todo: t.Type[t.Any], _user_fixtures 104 | ) -> None: 105 | with db.bind.Session() as s: 106 | with s.begin(): 107 | s.add_all(_user_fixtures) 108 | 109 | @pytest.fixture(scope="class", autouse=True) 110 | def db_fixtures( 111 | self, db: QuartSQLAlchemy, User: t.Type[t.Any], Todo: t.Type[t.Any], _add_fixtures 112 | ) -> t.Dict[t.Type[t.Any], t.Sequence[t.Any]]: 113 | with db.bind.Session() as s: 114 | users = s.scalars(sa.select(User).options(sa.orm.selectinload(User.todos))).all() 115 | todos = s.scalars(sa.select(Todo).options(sa.orm.selectinload(Todo.user))).all() 116 | 117 | return {User: users, Todo: todos} 118 | 119 | 120 | class AsyncTestBase(SimpleTestBase): 121 | @pytest.fixture(scope="class") 122 | def sqlalchemy_config(self): 123 | return SQLAlchemyConfig.parse_obj(constants.async_mapping_config) 124 | 125 | @pytest.fixture(scope="class", autouse=True) 126 | async def create_drop_all(self, db: QuartSQLAlchemy, models) -> t.AsyncGenerator[None, None]: 127 | await db.create_all() 128 | yield 129 | await db.drop_all() 130 | 131 | @pytest.fixture(scope="class") 132 | async def _add_fixtures( 133 | self, db: QuartSQLAlchemy, User: t.Type[t.Any], Todo: t.Type[t.Any], _user_fixtures 134 | ) -> None: 135 | async with db.bind.Session() as s: 136 | async with s.begin(): 137 | s.add_all(_user_fixtures) 138 | 139 | @pytest.fixture(scope="class", autouse=True) 140 | async def db_fixtures( 141 | self, db: QuartSQLAlchemy, User: t.Type[t.Any], Todo: t.Type[t.Any], _add_fixtures 142 | ) -> t.Dict[t.Type[t.Any], t.Sequence[t.Any]]: 143 | async with db.bind.Session() as s: 144 | users = ( 145 | await s.scalars(sa.select(User).options(sa.orm.selectinload(User.todos))) 146 | ).all() 147 | todos = (await s.scalars(sa.select(Todo).options(sa.orm.selectinload(Todo.user)))).all() 148 | 149 | return {User: users, Todo: todos} 150 | 151 | 152 | class ComplexTestBase(SimpleTestBase): 153 | @pytest.fixture(scope="class") 154 | def sqlalchemy_config(self): 155 | return SQLAlchemyConfig.parse_obj(constants.complex_mapping_config) 156 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | import sqlalchemy 7 | import sqlalchemy.orm 8 | from quart import Quart 9 | from sqlalchemy.orm import Mapped 10 | 11 | from quart_sqlalchemy import SQLAlchemyConfig 12 | from quart_sqlalchemy.framework import QuartSQLAlchemy 13 | 14 | from . import constants 15 | 16 | 17 | sa = sqlalchemy 18 | 19 | 20 | @pytest.fixture(scope="session") 21 | def app(request: pytest.FixtureRequest) -> Quart: 22 | app = Quart(request.module.__name__) 23 | app.config.from_mapping({"TESTING": True}) 24 | return app 25 | 26 | 27 | @pytest.fixture(scope="session") 28 | def sqlalchemy_config(): 29 | return SQLAlchemyConfig.parse_obj(constants.simple_mapping_config) 30 | 31 | 32 | @pytest.fixture(scope="session") 33 | def db(sqlalchemy_config, app: Quart) -> QuartSQLAlchemy: 34 | return QuartSQLAlchemy(sqlalchemy_config, app) 35 | 36 | 37 | @pytest.fixture(name="Todo", scope="session") 38 | def _todo_fixture( 39 | app: Quart, db: QuartSQLAlchemy 40 | ) -> t.Generator[t.Type[sa.orm.DeclarativeBase], None, None]: 41 | class Todo(db.Model): 42 | id: Mapped[int] = sa.orm.mapped_column(sa.Identity(), primary_key=True, autoincrement=True) 43 | title: Mapped[str] = sa.orm.mapped_column(default="default") 44 | 45 | db.create_all() 46 | 47 | yield Todo 48 | 49 | db.drop_all() 50 | -------------------------------------------------------------------------------- /tests/constants.py: -------------------------------------------------------------------------------- 1 | from quart_sqlalchemy import Base 2 | 3 | 4 | simple_mapping_config = { 5 | "model_class": Base, 6 | "binds": { 7 | "default": { 8 | "engine": {"url": "sqlite:///file:mem.db?mode=memory&cache=shared&uri=true"}, 9 | "session": {"expire_on_commit": False}, 10 | } 11 | }, 12 | } 13 | 14 | complex_mapping_config = { 15 | "model_class": Base, 16 | "binds": { 17 | "default": { 18 | "engine": {"url": "sqlite:///file:mem.db?mode=memory&cache=shared&uri=true"}, 19 | "session": {"expire_on_commit": False}, 20 | }, 21 | "read-replica": { 22 | "engine": {"url": "sqlite:///file:mem.db?mode=memory&cache=shared&uri=true"}, 23 | "session": {"expire_on_commit": False}, 24 | "read_only": True, 25 | }, 26 | "async": { 27 | "engine": {"url": "sqlite+aiosqlite:///file:mem.db?mode=memory&cache=shared&uri=true"}, 28 | "session": {"expire_on_commit": False}, 29 | }, 30 | }, 31 | } 32 | 33 | async_mapping_config = { 34 | "model_class": Base, 35 | "binds": { 36 | "default": { 37 | "engine": {"url": "sqlite+aiosqlite:///file:mem.db?mode=memory&cache=shared&uri=true"}, 38 | "session": {"expire_on_commit": False}, 39 | } 40 | }, 41 | } 42 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeblackwaslike/quart-sqlalchemy/d08114dda99204f626d8434a23cb833a97c5fbce/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/bind_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | import sqlalchemy 7 | import sqlalchemy.orm 8 | 9 | from quart_sqlalchemy import SQLAlchemy 10 | 11 | from .. import base 12 | 13 | 14 | sa = sqlalchemy 15 | 16 | 17 | class TestAsyncBind(base.AsyncTestBase): 18 | async def test_async_transactional_orm_flow(self, db: SQLAlchemy, Todo: t.Type[t.Any]): 19 | async with db.bind.Session() as s: 20 | async with s.begin(): 21 | todo = Todo(title="hello") 22 | s.add(todo) 23 | await s.flush() 24 | await s.refresh(todo) 25 | 26 | async with db.bind.Session() as s: 27 | async with s.begin(): 28 | select_todo = (await s.scalars(sa.select(Todo).where(Todo.id == todo.id))).one() 29 | assert todo == select_todo 30 | 31 | 32 | class TestBindContext(base.ComplexTestBase): 33 | def test_bind_context_execution_isolation_level(self, db: SQLAlchemy, Todo: t.Type[t.Any]): 34 | with db.bind.context(engine_execution_options=dict(isolation_level="SERIALIZABLE")) as ctx: 35 | engine_execution_options = ctx.engine.get_execution_options() 36 | assert engine_execution_options["isolation_level"] == "SERIALIZABLE" 37 | 38 | with ctx.Session() as s: 39 | with s.begin(): 40 | todo = Todo(title="hello") 41 | s.add(todo) 42 | s.flush() 43 | s.refresh(todo) 44 | 45 | 46 | class TestTestTransaction(base.ComplexTestBase): 47 | def test_test_transaction_for_orm(self, db: SQLAlchemy, Todo: t.Type[t.Any]): 48 | with db.bind.test_transaction(savepoint=True) as tx: 49 | with tx.Session() as s: 50 | todo = Todo(title="hello") 51 | s.add(todo) 52 | s.commit() 53 | s.refresh(todo) 54 | 55 | with tx.Session() as s: 56 | select_todo = s.scalars(sa.select(Todo).where(Todo.id == todo.id)).one() 57 | 58 | assert select_todo == todo 59 | 60 | with db.bind.Session() as s: 61 | with pytest.raises(sa.orm.exc.NoResultFound): 62 | s.scalars(sa.select(Todo).where(Todo.id == todo.id)).one() 63 | -------------------------------------------------------------------------------- /tests/integration/framework/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeblackwaslike/quart-sqlalchemy/d08114dda99204f626d8434a23cb833a97c5fbce/tests/integration/framework/__init__.py -------------------------------------------------------------------------------- /tests/integration/framework/extension_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sqlalchemy 3 | import sqlalchemy.event 4 | import sqlalchemy.exc 5 | import sqlalchemy.ext 6 | import sqlalchemy.ext.asyncio 7 | import sqlalchemy.orm 8 | import sqlalchemy.util 9 | from quart import Quart 10 | 11 | from quart_sqlalchemy.framework import QuartSQLAlchemy 12 | 13 | from ...base import SimpleTestBase 14 | 15 | 16 | sa = sqlalchemy 17 | 18 | 19 | class TestQuartSQLAlchemy(SimpleTestBase): 20 | def test_init_app(self, db: QuartSQLAlchemy, app: Quart): 21 | assert app.extensions["sqlalchemy"] == db 22 | 23 | def test_init_app_raises_runtime_error_when_already_initialized(self, db: QuartSQLAlchemy): 24 | app = Quart(__name__) 25 | db.init_app(app) 26 | with pytest.raises(RuntimeError): 27 | db.init_app(app) 28 | -------------------------------------------------------------------------------- /tests/integration/framework/smoke_test.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import pytest 4 | import sqlalchemy 5 | import sqlalchemy.event 6 | import sqlalchemy.exc 7 | import sqlalchemy.ext 8 | import sqlalchemy.ext.asyncio 9 | import sqlalchemy.orm 10 | import sqlalchemy.util 11 | 12 | from quart_sqlalchemy.framework import QuartSQLAlchemy 13 | 14 | from ...base import SimpleTestBase 15 | 16 | 17 | sa = sqlalchemy 18 | 19 | 20 | class TestQuartSQLAlchemySmoke(SimpleTestBase): 21 | def test_simple_transactional_orm_flow(self, db: QuartSQLAlchemy, Todo: t.Any): 22 | with db.bind.Session() as s: 23 | with s.begin(): 24 | todo = Todo() 25 | s.add(todo) 26 | s.flush() 27 | s.refresh(todo) 28 | 29 | with db.bind.Session() as s: 30 | with s.begin(): 31 | select_todo = s.scalars(sa.select(Todo).where(Todo.id == todo.id)).one() 32 | assert todo == select_todo 33 | 34 | s.delete(select_todo) 35 | 36 | with db.bind.Session() as s: 37 | with pytest.raises(sa.exc.NoResultFound): 38 | s.scalars(sa.select(Todo).where(Todo.id == todo.id)).one() 39 | 40 | def test_simple_transactional_core_flow(self, db: QuartSQLAlchemy, Todo: t.Any): 41 | with db.bind.engine.connect() as conn: 42 | with conn.begin(): 43 | result = conn.execute(sa.insert(Todo)) 44 | insert_row = result.inserted_primary_key 45 | 46 | select_row = conn.execute(sa.select(Todo).where(Todo.id == insert_row.id)).one() 47 | assert select_row.id == insert_row.id 48 | 49 | with db.bind.engine.connect() as conn: 50 | with conn.begin(): 51 | result = conn.execute(sa.delete(Todo).where(Todo.id == select_row.id)) 52 | 53 | assert result.rowcount == 1 54 | assert result.lastrowid == select_row.id 55 | 56 | with db.bind.engine.connect() as conn: 57 | with pytest.raises(sa.exc.NoResultFound): 58 | conn.execute(sa.select(Todo).where(Todo.id == insert_row.id)).one() 59 | 60 | def test_orm_models_comparable(self, db: QuartSQLAlchemy, Todo: t.Any): 61 | with db.bind.Session() as s: 62 | with s.begin(): 63 | todo = Todo() 64 | s.add(todo) 65 | s.flush() 66 | s.refresh(todo) 67 | 68 | with db.bind.Session() as s: 69 | select_todo = s.scalars(sa.select(Todo).where(Todo.id == todo.id)).one() 70 | assert todo == select_todo 71 | -------------------------------------------------------------------------------- /tests/integration/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeblackwaslike/quart-sqlalchemy/d08114dda99204f626d8434a23cb833a97c5fbce/tests/integration/model/__init__.py -------------------------------------------------------------------------------- /tests/integration/model/mixins_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | import sqlalchemy 7 | import sqlalchemy.orm 8 | from sqlalchemy.orm import Mapped 9 | 10 | from quart_sqlalchemy import SQLAlchemy 11 | from quart_sqlalchemy.model import Base 12 | from quart_sqlalchemy.model import SoftDeleteMixin 13 | 14 | from ...base import SimpleTestBase 15 | 16 | 17 | sa = sqlalchemy 18 | 19 | 20 | class TestSoftDeleteFeature(SimpleTestBase): 21 | @pytest.fixture 22 | def Post(self, db: SQLAlchemy, User: t.Type[t.Any]) -> t.Generator[t.Type[Base], None, None]: 23 | class Post(SoftDeleteMixin, db.Model): 24 | id: Mapped[int] = sa.orm.mapped_column(primary_key=True) 25 | title: Mapped[str] = sa.orm.mapped_column() 26 | user_id: Mapped[t.Optional[int]] = sa.orm.mapped_column(sa.ForeignKey("user.id")) 27 | 28 | user: Mapped[t.Optional[User]] = sa.orm.relationship(backref="posts") 29 | 30 | db.create_all() 31 | yield Post 32 | 33 | def test_inactive_filtered(self, db: SQLAlchemy, Post: t.Type[t.Any]): 34 | with db.bind.Session() as s: 35 | with s.begin(): 36 | post = Post(title="hello") 37 | s.add(post) 38 | s.flush() 39 | s.refresh(post) 40 | 41 | with db.bind.Session() as s: 42 | with s.begin(): 43 | post.is_active = False 44 | s.add(post) 45 | 46 | with db.bind.Session() as s: 47 | posts = s.scalars(sa.select(Post)).all() 48 | assert len(posts) == 0 49 | 50 | posts = s.scalars(sa.select(Post).execution_options(include_inactive=True)).all() 51 | assert len(posts) == 1 52 | select_post = posts.pop() 53 | 54 | assert select_post.id == post.id 55 | assert select_post.is_active is False 56 | -------------------------------------------------------------------------------- /tests/integration/retry_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | import sqlalchemy 7 | import sqlalchemy.exc 8 | import sqlalchemy.orm 9 | import tenacity 10 | from sqlalchemy.orm import Mapped 11 | 12 | from quart_sqlalchemy import SQLAlchemy 13 | from quart_sqlalchemy.retry import retry_config 14 | from quart_sqlalchemy.retry import retrying_async_session 15 | from quart_sqlalchemy.retry import retrying_session 16 | 17 | from .. import base 18 | 19 | 20 | sa = sqlalchemy 21 | 22 | 23 | class TestRetryingSessions(base.ComplexTestBase): 24 | def test_retrying_session(self, db: SQLAlchemy, Todo: t.Type[t.Any], mocker): 25 | side_effects = [ 26 | sa.exc.InvalidRequestError, 27 | sa.exc.InvalidRequestError, 28 | sa.exc.InvalidRequestError, 29 | ] 30 | 31 | session_add = mocker.Mock( 32 | side_effect=[ 33 | sa.exc.InvalidRequestError, 34 | sa.exc.InvalidRequestError, 35 | sa.exc.InvalidRequestError, 36 | ] 37 | ) 38 | 39 | # bind = db.get_bind("retry") 40 | 41 | # # conn_mock = mocker.patch.dict(bind.Session.kw, "bind") 42 | 43 | # with bind.Session() as s: 44 | # todo = Todo(title="hello") 45 | # s.add(todo) 46 | # s.commit() 47 | 48 | def test_retrying_session_class(self, db: SQLAlchemy, Todo: t.Type[t.Any], mocker): 49 | class Unique(db.Model): 50 | id: Mapped[int] = sa.orm.mapped_column( 51 | sa.Identity(), primary_key=True, autoincrement=True 52 | ) 53 | name: Mapped[str] = sa.orm.mapped_column(unique=True) 54 | 55 | __table_args__ = (sa.UniqueConstraint("name", "name"),) 56 | 57 | db.create_all() 58 | 59 | with retrying_session(db.bind) as s: 60 | todo = Todo(title="hello") 61 | 62 | s.add(todo) 63 | 64 | # with db.bind.Session() as session: 65 | # for _ in range(5): 66 | # uni = Unique(name="Joe") 67 | # session.add(uni) 68 | # session.commit() 69 | # objs = [Unique(name="Joe"), Unique(name="Joe")] 70 | # session.add_all(objs) 71 | 72 | # with retrying_async_session(ctx.Session) as s: 73 | # select_todo = await s.scalars(sa.select(Todo).where(Todo.id == todo.id)).one() 74 | 75 | # assert select_todo == todo 76 | 77 | 78 | # class TestBindContext(base.ComplexTestBase): 79 | # def test_bind_context_execution_isolation_level(self, db: SQLAlchemy, Todo: t.Type[t.Any]): 80 | # def add_todo(): 81 | # with db.bind.Session() as s: 82 | # with s.begin(): 83 | # todo = Todo(title="hello") 84 | # s.add(todo) 85 | # s.flush() 86 | # s.refresh(todo) 87 | 88 | 89 | # class TestTestTransaction(base.ComplexTestBase): 90 | # def test_test_transaction(self, db: SQLAlchemy, Todo: t.Type[t.Any]): 91 | # with db.bind.test_transaction(savepoint=True) as tx: 92 | # with tx.Session() as s: 93 | # todo = Todo(title="hello") 94 | # s.add(todo) 95 | # s.commit() 96 | # s.refresh(todo) 97 | 98 | # with tx.Session() as s: 99 | # select_todo = s.scalars(sa.select(Todo).where(Todo.id == todo.id)).one() 100 | 101 | # assert select_todo == todo 102 | 103 | # with db.bind.Session() as s: 104 | # with pytest.raises(sa.orm.exc.NoResultFound): 105 | # s.scalars(sa.select(Todo).where(Todo.id == todo.id)).one() 106 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py39,py310,py311,py312,q183 3 | skip_missing_interpreters = true 4 | skip_install = true 5 | 6 | [testenv] 7 | commands = 8 | pdm install --dev 9 | pytest -v -rsx --tb=short --asyncio-mode=auto --py311-task true --loop-scope session {posargs} tests 10 | 11 | [testenv:q183] 12 | commands = 13 | pdm install --dev 14 | pip install aiofiles==23.2.1 blinker==1.5 click==8.1.7 flask==2.2.1 quart==0.18.3 werkzeug==2.2.0 jinja2==3.1.2 15 | pytest -v -rsx --tb=short --asyncio-mode=auto --py311-task true --loop-scope session {posargs} tests -------------------------------------------------------------------------------- /workspace.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "python.terminal.activateEnvInCurrentTerminal": true, 9 | "python.formatting.provider": "black", 10 | "[python]": { 11 | "editor.formatOnSave": true, 12 | "editor.codeActionsOnSave": { 13 | "source.organizeImports": true 14 | } 15 | }, 16 | "esbonio.sphinx.confDir": "" 17 | } 18 | } 19 | --------------------------------------------------------------------------------