├── .flake8 ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── conftest.py ├── examples └── user-addresses.py ├── poetry.lock ├── pyproject.toml ├── sqlalchemy_nested_mutable ├── __init__.py ├── _compat.py ├── _typing.py ├── mutable.py ├── py.typed ├── testing │ └── utils.py └── trackable.py └── tests ├── test_custom_sqltype.py ├── test_mutable_dict.py ├── test_mutable_list.py ├── test_mutable_pydantic_type.py └── test_sa_default_behaviour.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .*, 4 | __pycache__, 5 | ignore = 6 | E501, 7 | W503 8 | max-line-length = 100 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [wonderbeyond] 4 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "PyPI Publish" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | flake8-lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out source repository 13 | uses: actions/checkout@v2 14 | - name: Set up Python environment 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.10" 18 | - name: flake8 Lint 19 | uses: py-actions/flake8@v2 20 | Publish: 21 | runs-on: ubuntu-latest 22 | environment: release 23 | permissions: 24 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-python@v4 28 | with: 29 | python-version: "3.11" 30 | - uses: abatilo/actions-poetry@v2 31 | with: 32 | poetry-version: "1.2.2" 33 | - run: | 34 | poetry build 35 | - name: Publish package distributions to PyPI 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | .tox 4 | *.egg-info 5 | tags 6 | .idea 7 | .ropeproject 8 | .cache 9 | .mypy_cache 10 | /dist 11 | /build 12 | docs/_build 13 | 14 | # vim temporary files 15 | *~ 16 | .*.sw? 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "autouse", 4 | "sqltype" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Wonder 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SQLAlchemy-Nested-Mutable 2 | ========================= 3 | 4 | An advanced SQLAlchemy column type factory that helps map compound Python types (e.g. `list`, `dict`, *Pydantic Model* and their hybrids) to database types (e.g. `ARRAY`, `JSONB`), 5 | And keep track of mutations in deeply nested data structures so that SQLAlchemy can emit proper *UPDATE* statements. 6 | 7 | SQLAlchemy-Nested-Mutable is highly inspired by SQLAlchemy-JSON[[0]](https://github.com/edelooff/sqlalchemy-json)[[1]](https://variable-scope.com/posts/mutation-tracking-in-nested-json-structures-using-sqlalchemy). 8 | However, it does not limit the mapped Python type to be `dict` or `list`. 9 | 10 | --- 11 | 12 | ## Why this package? 13 | 14 | * By default, SQLAlchemy does not track in-place mutations for non-scalar data types 15 | such as `list` and `dict` (which are usually mapped with `ARRAY` and `JSON/JSONB`). 16 | 17 | * Even though SQLAlchemy provides [an extension](https://docs.sqlalchemy.org/en/20/orm/extensions/mutable.html) 18 | to track mutations on compound objects, it's too shallow, i.e. it only tracks mutations on the first level of the compound object. 19 | 20 | * There exists the [SQLAlchemy-JSON](https://github.com/edelooff/sqlalchemy-json) package 21 | to help track mutations on nested `dict` or `list` data structures. 22 | However, the db type is limited to `JSON(B)`. 23 | 24 | * Also, I would like the mapped Python types can be subclasses of the Pydantic BaseModel, 25 | which have strong schemas, with the db type be schema-less JSON. 26 | 27 | 28 | ## Installation 29 | 30 | ```shell 31 | pip install sqlalchemy-nested-mutable 32 | ``` 33 | 34 | ## Usage 35 | 36 | > NOTE the example below is first updated in `examples/user-addresses.py` and then updated here. 37 | 38 | ```python 39 | from typing import Optional, List 40 | 41 | import pydantic 42 | import sqlalchemy as sa 43 | from sqlalchemy.orm import Session, DeclarativeBase, Mapped, mapped_column 44 | from sqlalchemy_nested_mutable import MutablePydanticBaseModel 45 | 46 | 47 | class Base(DeclarativeBase): 48 | pass 49 | 50 | 51 | class Addresses(MutablePydanticBaseModel): 52 | """A container for storing various addresses of users. 53 | 54 | NOTE: for working with pydantic model, use a subclass of `MutablePydanticBaseModel` for column mapping. 55 | However, the nested models (e.g. `AddressItem` below) should be direct subclasses of `pydantic.BaseModel`. 56 | """ 57 | 58 | class AddressItem(pydantic.BaseModel): 59 | street: str 60 | city: str 61 | area: Optional[str] 62 | 63 | preferred: AddressItem 64 | work: Optional[AddressItem] 65 | home: Optional[AddressItem] 66 | others: List[AddressItem] = [] 67 | 68 | 69 | class User(Base): 70 | __tablename__ = "user_account" 71 | 72 | id: Mapped[int] = mapped_column(primary_key=True) 73 | name: Mapped[str] = mapped_column(sa.String(30)) 74 | addresses: Mapped[Addresses] = mapped_column(Addresses.as_mutable(), nullable=True) 75 | 76 | 77 | engine = sa.create_engine("sqlite://") 78 | Base.metadata.create_all(engine) 79 | 80 | with Session(engine) as s: 81 | s.add(u := User(name="foo", addresses={"preferred": {"street": "bar", "city": "baz"}})) 82 | assert isinstance(u.addresses, MutablePydanticBaseModel) 83 | s.commit() 84 | 85 | u.addresses.preferred.street = "bar2" 86 | s.commit() 87 | assert u.addresses.preferred.street == "bar2" 88 | 89 | u.addresses.others.append(Addresses.AddressItem.parse_obj({"street": "bar3", "city": "baz3"})) 90 | s.commit() 91 | assert isinstance(u.addresses.others[0], Addresses.AddressItem) 92 | 93 | print(u.addresses.dict()) 94 | ``` 95 | 96 | For more usage, please refer to the following test files: 97 | 98 | * tests/test_mutable_list.py 99 | * tests/test_mutable_dict.py 100 | * tests/test_mutable_pydantic_type.py 101 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pytest 4 | import sqlalchemy as sa 5 | from sqlalchemy.orm import sessionmaker 6 | from pytest_docker_service import docker_container 7 | 8 | from sqlalchemy_nested_mutable.testing.utils import wait_pg_ready 9 | 10 | PG_PORT = "5432/tcp" 11 | 12 | pg_container = docker_container( 13 | scope="session", 14 | image_name="postgres:15", 15 | container_name=f"pytest-svc-pg15-{random.randint(0, 1e8)}", 16 | ports={PG_PORT: None}, 17 | environment={ 18 | "POSTGRES_USER": "test", 19 | "POSTGRES_PASSWORD": "test", 20 | "POSTGRES_DB": "test", 21 | }) 22 | 23 | 24 | @pytest.fixture(scope="session") 25 | def pg_dbinfo(pg_container): 26 | port = pg_container.port_map[PG_PORT] 27 | port = int(port[0] if isinstance(port, list) else port) 28 | dbinfo = { 29 | "port": port, 30 | "host": '127.0.0.1', 31 | "user": "test", 32 | "password": "test", 33 | "database": "test", 34 | } 35 | wait_pg_ready(dbinfo) 36 | print(f"Prepared PostgreSQL: {dbinfo}") 37 | yield dbinfo 38 | 39 | 40 | @pytest.fixture(scope="session") 41 | def session(pg_dbinfo): 42 | engine = sa.create_engine( 43 | "postgresql://{user}:{password}@{host}:{port}/{database}".format(**pg_dbinfo) 44 | ) 45 | with sessionmaker(bind=engine)() as session: 46 | yield session 47 | -------------------------------------------------------------------------------- /examples/user-addresses.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | import pydantic 4 | import sqlalchemy as sa 5 | from sqlalchemy.orm import Session, DeclarativeBase, Mapped, mapped_column 6 | from sqlalchemy_nested_mutable import MutablePydanticBaseModel 7 | 8 | 9 | class Base(DeclarativeBase): 10 | pass 11 | 12 | 13 | class Addresses(MutablePydanticBaseModel): 14 | """A container for storing various addresses of users. 15 | 16 | NOTE: for working with pydantic model, use a subclass of `MutablePydanticBaseModel` for column mapping. 17 | However, the nested models (e.g. `AddressItem` below) should be direct subclasses of `pydantic.BaseModel`. 18 | """ 19 | 20 | class AddressItem(pydantic.BaseModel): 21 | street: str 22 | city: str 23 | area: Optional[str] 24 | 25 | preferred: AddressItem 26 | work: Optional[AddressItem] 27 | home: Optional[AddressItem] 28 | others: List[AddressItem] = [] 29 | 30 | 31 | class User(Base): 32 | __tablename__ = "user_account" 33 | 34 | id: Mapped[int] = mapped_column(primary_key=True) 35 | name: Mapped[str] = mapped_column(sa.String(30)) 36 | addresses: Mapped[Addresses] = mapped_column(Addresses.as_mutable(), nullable=True) 37 | 38 | 39 | engine = sa.create_engine("sqlite://") 40 | Base.metadata.create_all(engine) 41 | 42 | with Session(engine) as s: 43 | s.add(u := User(name="foo", addresses={"preferred": {"street": "bar", "city": "baz"}})) 44 | assert isinstance(u.addresses, MutablePydanticBaseModel) 45 | s.commit() 46 | 47 | u.addresses.preferred.street = "bar2" 48 | s.commit() 49 | assert u.addresses.preferred.street == "bar2" 50 | 51 | u.addresses.others.append(Addresses.AddressItem.parse_obj({"street": "bar3", "city": "baz3"})) 52 | s.commit() 53 | assert isinstance(u.addresses.others[0], Addresses.AddressItem) 54 | 55 | print(u.addresses.dict()) 56 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "black" 5 | version = "23.3.0" 6 | description = "The uncompromising code formatter." 7 | category = "dev" 8 | optional = false 9 | python-versions = ">=3.7" 10 | files = [ 11 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, 12 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, 13 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, 14 | {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, 15 | {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, 16 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, 17 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, 18 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, 19 | {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, 20 | {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, 21 | {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"}, 22 | {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"}, 23 | {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"}, 24 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"}, 25 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"}, 26 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"}, 27 | {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"}, 28 | {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"}, 29 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, 30 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, 31 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, 32 | {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, 33 | {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, 34 | {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, 35 | {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, 36 | ] 37 | 38 | [package.dependencies] 39 | click = ">=8.0.0" 40 | mypy-extensions = ">=0.4.3" 41 | packaging = ">=22.0" 42 | pathspec = ">=0.9.0" 43 | platformdirs = ">=2" 44 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 45 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 46 | 47 | [package.extras] 48 | colorama = ["colorama (>=0.4.3)"] 49 | d = ["aiohttp (>=3.7.4)"] 50 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 51 | uvloop = ["uvloop (>=0.15.2)"] 52 | 53 | [[package]] 54 | name = "cachetools" 55 | version = "5.3.1" 56 | description = "Extensible memoizing collections and decorators" 57 | category = "dev" 58 | optional = false 59 | python-versions = ">=3.7" 60 | files = [ 61 | {file = "cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590"}, 62 | {file = "cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b"}, 63 | ] 64 | 65 | [[package]] 66 | name = "certifi" 67 | version = "2023.5.7" 68 | description = "Python package for providing Mozilla's CA Bundle." 69 | category = "dev" 70 | optional = false 71 | python-versions = ">=3.6" 72 | files = [ 73 | {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, 74 | {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, 75 | ] 76 | 77 | [[package]] 78 | name = "chardet" 79 | version = "5.1.0" 80 | description = "Universal encoding detector for Python 3" 81 | category = "dev" 82 | optional = false 83 | python-versions = ">=3.7" 84 | files = [ 85 | {file = "chardet-5.1.0-py3-none-any.whl", hash = "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9"}, 86 | {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"}, 87 | ] 88 | 89 | [[package]] 90 | name = "charset-normalizer" 91 | version = "3.1.0" 92 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 93 | category = "dev" 94 | optional = false 95 | python-versions = ">=3.7.0" 96 | files = [ 97 | {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, 98 | {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, 99 | {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, 100 | {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, 101 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, 102 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, 103 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, 104 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, 105 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, 106 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, 107 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, 108 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, 109 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, 110 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, 111 | {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, 112 | {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, 113 | {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, 114 | {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, 115 | {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, 116 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, 117 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, 118 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, 119 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, 120 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, 121 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, 122 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, 123 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, 124 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, 125 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, 126 | {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, 127 | {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, 128 | {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, 129 | {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, 130 | {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, 131 | {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, 132 | {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, 133 | {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, 134 | {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, 135 | {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, 136 | {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, 137 | {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, 138 | {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, 139 | {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, 140 | {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, 141 | {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, 142 | {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, 143 | {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, 144 | {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, 145 | {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, 146 | {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, 147 | {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, 148 | {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, 149 | {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, 150 | {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, 151 | {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, 152 | {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, 153 | {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, 154 | {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, 155 | {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, 156 | {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, 157 | {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, 158 | {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, 159 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, 160 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, 161 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, 162 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, 163 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, 164 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, 165 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, 166 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, 167 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, 168 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, 169 | {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, 170 | {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, 171 | {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, 172 | ] 173 | 174 | [[package]] 175 | name = "click" 176 | version = "8.1.3" 177 | description = "Composable command line interface toolkit" 178 | category = "dev" 179 | optional = false 180 | python-versions = ">=3.7" 181 | files = [ 182 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 183 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 184 | ] 185 | 186 | [package.dependencies] 187 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 188 | 189 | [[package]] 190 | name = "colorama" 191 | version = "0.4.6" 192 | description = "Cross-platform colored terminal text." 193 | category = "dev" 194 | optional = false 195 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 196 | files = [ 197 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 198 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 199 | ] 200 | 201 | [[package]] 202 | name = "distlib" 203 | version = "0.3.6" 204 | description = "Distribution utilities" 205 | category = "dev" 206 | optional = false 207 | python-versions = "*" 208 | files = [ 209 | {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, 210 | {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, 211 | ] 212 | 213 | [[package]] 214 | name = "docker" 215 | version = "6.1.3" 216 | description = "A Python library for the Docker Engine API." 217 | category = "dev" 218 | optional = false 219 | python-versions = ">=3.7" 220 | files = [ 221 | {file = "docker-6.1.3-py3-none-any.whl", hash = "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9"}, 222 | {file = "docker-6.1.3.tar.gz", hash = "sha256:aa6d17830045ba5ef0168d5eaa34d37beeb113948c413affe1d5991fc11f9a20"}, 223 | ] 224 | 225 | [package.dependencies] 226 | packaging = ">=14.0" 227 | pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} 228 | requests = ">=2.26.0" 229 | urllib3 = ">=1.26.0" 230 | websocket-client = ">=0.32.0" 231 | 232 | [package.extras] 233 | ssh = ["paramiko (>=2.4.3)"] 234 | 235 | [[package]] 236 | name = "exceptiongroup" 237 | version = "1.1.1" 238 | description = "Backport of PEP 654 (exception groups)" 239 | category = "dev" 240 | optional = false 241 | python-versions = ">=3.7" 242 | files = [ 243 | {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, 244 | {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, 245 | ] 246 | 247 | [package.extras] 248 | test = ["pytest (>=6)"] 249 | 250 | [[package]] 251 | name = "filelock" 252 | version = "3.12.0" 253 | description = "A platform independent file lock." 254 | category = "dev" 255 | optional = false 256 | python-versions = ">=3.7" 257 | files = [ 258 | {file = "filelock-3.12.0-py3-none-any.whl", hash = "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9"}, 259 | {file = "filelock-3.12.0.tar.gz", hash = "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"}, 260 | ] 261 | 262 | [package.extras] 263 | docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 264 | testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] 265 | 266 | [[package]] 267 | name = "greenlet" 268 | version = "2.0.2" 269 | description = "Lightweight in-process concurrent programming" 270 | category = "main" 271 | optional = false 272 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" 273 | files = [ 274 | {file = "greenlet-2.0.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d"}, 275 | {file = "greenlet-2.0.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9"}, 276 | {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, 277 | {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, 278 | {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, 279 | {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, 280 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, 281 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, 282 | {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470"}, 283 | {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a"}, 284 | {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, 285 | {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, 286 | {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, 287 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, 288 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, 289 | {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, 290 | {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19"}, 291 | {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3"}, 292 | {file = "greenlet-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5"}, 293 | {file = "greenlet-2.0.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6"}, 294 | {file = "greenlet-2.0.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43"}, 295 | {file = "greenlet-2.0.2-cp35-cp35m-win32.whl", hash = "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a"}, 296 | {file = "greenlet-2.0.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394"}, 297 | {file = "greenlet-2.0.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0"}, 298 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3"}, 299 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db"}, 300 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099"}, 301 | {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75"}, 302 | {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf"}, 303 | {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292"}, 304 | {file = "greenlet-2.0.2-cp36-cp36m-win32.whl", hash = "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9"}, 305 | {file = "greenlet-2.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f"}, 306 | {file = "greenlet-2.0.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b"}, 307 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1"}, 308 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7"}, 309 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca"}, 310 | {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73"}, 311 | {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86"}, 312 | {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33"}, 313 | {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, 314 | {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, 315 | {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, 316 | {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, 317 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, 318 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, 319 | {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857"}, 320 | {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a"}, 321 | {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, 322 | {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, 323 | {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, 324 | {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, 325 | {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, 326 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, 327 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b"}, 328 | {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b"}, 329 | {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8"}, 330 | {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9"}, 331 | {file = "greenlet-2.0.2-cp39-cp39-win32.whl", hash = "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5"}, 332 | {file = "greenlet-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564"}, 333 | {file = "greenlet-2.0.2.tar.gz", hash = "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0"}, 334 | ] 335 | 336 | [package.extras] 337 | docs = ["Sphinx", "docutils (<0.18)"] 338 | test = ["objgraph", "psutil"] 339 | 340 | [[package]] 341 | name = "idna" 342 | version = "3.4" 343 | description = "Internationalized Domain Names in Applications (IDNA)" 344 | category = "dev" 345 | optional = false 346 | python-versions = ">=3.5" 347 | files = [ 348 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 349 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 350 | ] 351 | 352 | [[package]] 353 | name = "iniconfig" 354 | version = "2.0.0" 355 | description = "brain-dead simple config-ini parsing" 356 | category = "dev" 357 | optional = false 358 | python-versions = ">=3.7" 359 | files = [ 360 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 361 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 362 | ] 363 | 364 | [[package]] 365 | name = "mypy-extensions" 366 | version = "1.0.0" 367 | description = "Type system extensions for programs checked with the mypy type checker." 368 | category = "dev" 369 | optional = false 370 | python-versions = ">=3.5" 371 | files = [ 372 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 373 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 374 | ] 375 | 376 | [[package]] 377 | name = "packaging" 378 | version = "23.1" 379 | description = "Core utilities for Python packages" 380 | category = "dev" 381 | optional = false 382 | python-versions = ">=3.7" 383 | files = [ 384 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 385 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 386 | ] 387 | 388 | [[package]] 389 | name = "pathspec" 390 | version = "0.11.1" 391 | description = "Utility library for gitignore style pattern matching of file paths." 392 | category = "dev" 393 | optional = false 394 | python-versions = ">=3.7" 395 | files = [ 396 | {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, 397 | {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, 398 | ] 399 | 400 | [[package]] 401 | name = "platformdirs" 402 | version = "3.5.1" 403 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 404 | category = "dev" 405 | optional = false 406 | python-versions = ">=3.7" 407 | files = [ 408 | {file = "platformdirs-3.5.1-py3-none-any.whl", hash = "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"}, 409 | {file = "platformdirs-3.5.1.tar.gz", hash = "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f"}, 410 | ] 411 | 412 | [package.extras] 413 | docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 414 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] 415 | 416 | [[package]] 417 | name = "pluggy" 418 | version = "1.0.0" 419 | description = "plugin and hook calling mechanisms for python" 420 | category = "dev" 421 | optional = false 422 | python-versions = ">=3.6" 423 | files = [ 424 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 425 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 426 | ] 427 | 428 | [package.extras] 429 | dev = ["pre-commit", "tox"] 430 | testing = ["pytest", "pytest-benchmark"] 431 | 432 | [[package]] 433 | name = "psycopg2-binary" 434 | version = "2.9.6" 435 | description = "psycopg2 - Python-PostgreSQL Database Adapter" 436 | category = "main" 437 | optional = false 438 | python-versions = ">=3.6" 439 | files = [ 440 | {file = "psycopg2-binary-2.9.6.tar.gz", hash = "sha256:1f64dcfb8f6e0c014c7f55e51c9759f024f70ea572fbdef123f85318c297947c"}, 441 | {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d26e0342183c762de3276cca7a530d574d4e25121ca7d6e4a98e4f05cb8e4df7"}, 442 | {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c48d8f2db17f27d41fb0e2ecd703ea41984ee19362cbce52c097963b3a1b4365"}, 443 | {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe9dc0a884a8848075e576c1de0290d85a533a9f6e9c4e564f19adf8f6e54a7"}, 444 | {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a76e027f87753f9bd1ab5f7c9cb8c7628d1077ef927f5e2446477153a602f2c"}, 445 | {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6460c7a99fc939b849431f1e73e013d54aa54293f30f1109019c56a0b2b2ec2f"}, 446 | {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae102a98c547ee2288637af07393dd33f440c25e5cd79556b04e3fca13325e5f"}, 447 | {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9972aad21f965599ed0106f65334230ce826e5ae69fda7cbd688d24fa922415e"}, 448 | {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a40c00dbe17c0af5bdd55aafd6ff6679f94a9be9513a4c7e071baf3d7d22a70"}, 449 | {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:cacbdc5839bdff804dfebc058fe25684cae322987f7a38b0168bc1b2df703fb1"}, 450 | {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7f0438fa20fb6c7e202863e0d5ab02c246d35efb1d164e052f2f3bfe2b152bd0"}, 451 | {file = "psycopg2_binary-2.9.6-cp310-cp310-win32.whl", hash = "sha256:b6c8288bb8a84b47e07013bb4850f50538aa913d487579e1921724631d02ea1b"}, 452 | {file = "psycopg2_binary-2.9.6-cp310-cp310-win_amd64.whl", hash = "sha256:61b047a0537bbc3afae10f134dc6393823882eb263088c271331602b672e52e9"}, 453 | {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:964b4dfb7c1c1965ac4c1978b0f755cc4bd698e8aa2b7667c575fb5f04ebe06b"}, 454 | {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afe64e9b8ea66866a771996f6ff14447e8082ea26e675a295ad3bdbffdd72afb"}, 455 | {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15e2ee79e7cf29582ef770de7dab3d286431b01c3bb598f8e05e09601b890081"}, 456 | {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfa74c903a3c1f0d9b1c7e7b53ed2d929a4910e272add6700c38f365a6002820"}, 457 | {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b83456c2d4979e08ff56180a76429263ea254c3f6552cd14ada95cff1dec9bb8"}, 458 | {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0645376d399bfd64da57148694d78e1f431b1e1ee1054872a5713125681cf1be"}, 459 | {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e99e34c82309dd78959ba3c1590975b5d3c862d6f279f843d47d26ff89d7d7e1"}, 460 | {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4ea29fc3ad9d91162c52b578f211ff1c931d8a38e1f58e684c45aa470adf19e2"}, 461 | {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:4ac30da8b4f57187dbf449294d23b808f8f53cad6b1fc3623fa8a6c11d176dd0"}, 462 | {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e78e6e2a00c223e164c417628572a90093c031ed724492c763721c2e0bc2a8df"}, 463 | {file = "psycopg2_binary-2.9.6-cp311-cp311-win32.whl", hash = "sha256:1876843d8e31c89c399e31b97d4b9725a3575bb9c2af92038464231ec40f9edb"}, 464 | {file = "psycopg2_binary-2.9.6-cp311-cp311-win_amd64.whl", hash = "sha256:b4b24f75d16a89cc6b4cdff0eb6a910a966ecd476d1e73f7ce5985ff1328e9a6"}, 465 | {file = "psycopg2_binary-2.9.6-cp36-cp36m-win32.whl", hash = "sha256:498807b927ca2510baea1b05cc91d7da4718a0f53cb766c154c417a39f1820a0"}, 466 | {file = "psycopg2_binary-2.9.6-cp36-cp36m-win_amd64.whl", hash = "sha256:0d236c2825fa656a2d98bbb0e52370a2e852e5a0ec45fc4f402977313329174d"}, 467 | {file = "psycopg2_binary-2.9.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:34b9ccdf210cbbb1303c7c4db2905fa0319391bd5904d32689e6dd5c963d2ea8"}, 468 | {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84d2222e61f313c4848ff05353653bf5f5cf6ce34df540e4274516880d9c3763"}, 469 | {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30637a20623e2a2eacc420059be11527f4458ef54352d870b8181a4c3020ae6b"}, 470 | {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8122cfc7cae0da9a3077216528b8bb3629c43b25053284cc868744bfe71eb141"}, 471 | {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38601cbbfe600362c43714482f43b7c110b20cb0f8172422c616b09b85a750c5"}, 472 | {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c7e62ab8b332147a7593a385d4f368874d5fe4ad4e341770d4983442d89603e3"}, 473 | {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2ab652e729ff4ad76d400df2624d223d6e265ef81bb8aa17fbd63607878ecbee"}, 474 | {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:c83a74b68270028dc8ee74d38ecfaf9c90eed23c8959fca95bd703d25b82c88e"}, 475 | {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d4e6036decf4b72d6425d5b29bbd3e8f0ff1059cda7ac7b96d6ac5ed34ffbacd"}, 476 | {file = "psycopg2_binary-2.9.6-cp37-cp37m-win32.whl", hash = "sha256:a8c28fd40a4226b4a84bdf2d2b5b37d2c7bd49486b5adcc200e8c7ec991dfa7e"}, 477 | {file = "psycopg2_binary-2.9.6-cp37-cp37m-win_amd64.whl", hash = "sha256:51537e3d299be0db9137b321dfb6a5022caaab275775680e0c3d281feefaca6b"}, 478 | {file = "psycopg2_binary-2.9.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf4499e0a83b7b7edcb8dabecbd8501d0d3a5ef66457200f77bde3d210d5debb"}, 479 | {file = "psycopg2_binary-2.9.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7e13a5a2c01151f1208d5207e42f33ba86d561b7a89fca67c700b9486a06d0e2"}, 480 | {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e0f754d27fddcfd74006455b6e04e6705d6c31a612ec69ddc040a5468e44b4e"}, 481 | {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d57c3fd55d9058645d26ae37d76e61156a27722097229d32a9e73ed54819982a"}, 482 | {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71f14375d6f73b62800530b581aed3ada394039877818b2d5f7fc77e3bb6894d"}, 483 | {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:441cc2f8869a4f0f4bb408475e5ae0ee1f3b55b33f350406150277f7f35384fc"}, 484 | {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:65bee1e49fa6f9cf327ce0e01c4c10f39165ee76d35c846ade7cb0ec6683e303"}, 485 | {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:af335bac6b666cc6aea16f11d486c3b794029d9df029967f9938a4bed59b6a19"}, 486 | {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:cfec476887aa231b8548ece2e06d28edc87c1397ebd83922299af2e051cf2827"}, 487 | {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:65c07febd1936d63bfde78948b76cd4c2a411572a44ac50719ead41947d0f26b"}, 488 | {file = "psycopg2_binary-2.9.6-cp38-cp38-win32.whl", hash = "sha256:4dfb4be774c4436a4526d0c554af0cc2e02082c38303852a36f6456ece7b3503"}, 489 | {file = "psycopg2_binary-2.9.6-cp38-cp38-win_amd64.whl", hash = "sha256:02c6e3cf3439e213e4ee930308dc122d6fb4d4bea9aef4a12535fbd605d1a2fe"}, 490 | {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e9182eb20f41417ea1dd8e8f7888c4d7c6e805f8a7c98c1081778a3da2bee3e4"}, 491 | {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a6979cf527e2603d349a91060f428bcb135aea2be3201dff794813256c274f1"}, 492 | {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8338a271cb71d8da40b023a35d9c1e919eba6cbd8fa20a54b748a332c355d896"}, 493 | {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3ed340d2b858d6e6fb5083f87c09996506af483227735de6964a6100b4e6a54"}, 494 | {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f81e65376e52f03422e1fb475c9514185669943798ed019ac50410fb4c4df232"}, 495 | {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfb13af3c5dd3a9588000910178de17010ebcccd37b4f9794b00595e3a8ddad3"}, 496 | {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4c727b597c6444a16e9119386b59388f8a424223302d0c06c676ec8b4bc1f963"}, 497 | {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4d67fbdaf177da06374473ef6f7ed8cc0a9dc640b01abfe9e8a2ccb1b1402c1f"}, 498 | {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0892ef645c2fabb0c75ec32d79f4252542d0caec1d5d949630e7d242ca4681a3"}, 499 | {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:02c0f3757a4300cf379eb49f543fb7ac527fb00144d39246ee40e1df684ab514"}, 500 | {file = "psycopg2_binary-2.9.6-cp39-cp39-win32.whl", hash = "sha256:c3dba7dab16709a33a847e5cd756767271697041fbe3fe97c215b1fc1f5c9848"}, 501 | {file = "psycopg2_binary-2.9.6-cp39-cp39-win_amd64.whl", hash = "sha256:f6a88f384335bb27812293fdb11ac6aee2ca3f51d3c7820fe03de0a304ab6249"}, 502 | ] 503 | 504 | [[package]] 505 | name = "pydantic" 506 | version = "1.10.8" 507 | description = "Data validation and settings management using python type hints" 508 | category = "main" 509 | optional = false 510 | python-versions = ">=3.7" 511 | files = [ 512 | {file = "pydantic-1.10.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1243d28e9b05003a89d72e7915fdb26ffd1d39bdd39b00b7dbe4afae4b557f9d"}, 513 | {file = "pydantic-1.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0ab53b609c11dfc0c060d94335993cc2b95b2150e25583bec37a49b2d6c6c3f"}, 514 | {file = "pydantic-1.10.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9613fadad06b4f3bc5db2653ce2f22e0de84a7c6c293909b48f6ed37b83c61f"}, 515 | {file = "pydantic-1.10.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df7800cb1984d8f6e249351139667a8c50a379009271ee6236138a22a0c0f319"}, 516 | {file = "pydantic-1.10.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0c6fafa0965b539d7aab0a673a046466d23b86e4b0e8019d25fd53f4df62c277"}, 517 | {file = "pydantic-1.10.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e82d4566fcd527eae8b244fa952d99f2ca3172b7e97add0b43e2d97ee77f81ab"}, 518 | {file = "pydantic-1.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:ab523c31e22943713d80d8d342d23b6f6ac4b792a1e54064a8d0cf78fd64e800"}, 519 | {file = "pydantic-1.10.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:666bdf6066bf6dbc107b30d034615d2627e2121506c555f73f90b54a463d1f33"}, 520 | {file = "pydantic-1.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:35db5301b82e8661fa9c505c800d0990bc14e9f36f98932bb1d248c0ac5cada5"}, 521 | {file = "pydantic-1.10.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90c1e29f447557e9e26afb1c4dbf8768a10cc676e3781b6a577841ade126b85"}, 522 | {file = "pydantic-1.10.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93e766b4a8226e0708ef243e843105bf124e21331694367f95f4e3b4a92bbb3f"}, 523 | {file = "pydantic-1.10.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:88f195f582851e8db960b4a94c3e3ad25692c1c1539e2552f3df7a9e972ef60e"}, 524 | {file = "pydantic-1.10.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:34d327c81e68a1ecb52fe9c8d50c8a9b3e90d3c8ad991bfc8f953fb477d42fb4"}, 525 | {file = "pydantic-1.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:d532bf00f381bd6bc62cabc7d1372096b75a33bc197a312b03f5838b4fb84edd"}, 526 | {file = "pydantic-1.10.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7d5b8641c24886d764a74ec541d2fc2c7fb19f6da2a4001e6d580ba4a38f7878"}, 527 | {file = "pydantic-1.10.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b1f6cb446470b7ddf86c2e57cd119a24959af2b01e552f60705910663af09a4"}, 528 | {file = "pydantic-1.10.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c33b60054b2136aef8cf190cd4c52a3daa20b2263917c49adad20eaf381e823b"}, 529 | {file = "pydantic-1.10.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1952526ba40b220b912cdc43c1c32bcf4a58e3f192fa313ee665916b26befb68"}, 530 | {file = "pydantic-1.10.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bb14388ec45a7a0dc429e87def6396f9e73c8c77818c927b6a60706603d5f2ea"}, 531 | {file = "pydantic-1.10.8-cp37-cp37m-win_amd64.whl", hash = "sha256:16f8c3e33af1e9bb16c7a91fc7d5fa9fe27298e9f299cff6cb744d89d573d62c"}, 532 | {file = "pydantic-1.10.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ced8375969673929809d7f36ad322934c35de4af3b5e5b09ec967c21f9f7887"}, 533 | {file = "pydantic-1.10.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93e6bcfccbd831894a6a434b0aeb1947f9e70b7468f274154d03d71fabb1d7c6"}, 534 | {file = "pydantic-1.10.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:191ba419b605f897ede9892f6c56fb182f40a15d309ef0142212200a10af4c18"}, 535 | {file = "pydantic-1.10.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:052d8654cb65174d6f9490cc9b9a200083a82cf5c3c5d3985db765757eb3b375"}, 536 | {file = "pydantic-1.10.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ceb6a23bf1ba4b837d0cfe378329ad3f351b5897c8d4914ce95b85fba96da5a1"}, 537 | {file = "pydantic-1.10.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f2e754d5566f050954727c77f094e01793bcb5725b663bf628fa6743a5a9108"}, 538 | {file = "pydantic-1.10.8-cp38-cp38-win_amd64.whl", hash = "sha256:6a82d6cda82258efca32b40040228ecf43a548671cb174a1e81477195ed3ed56"}, 539 | {file = "pydantic-1.10.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e59417ba8a17265e632af99cc5f35ec309de5980c440c255ab1ca3ae96a3e0e"}, 540 | {file = "pydantic-1.10.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:84d80219c3f8d4cad44575e18404099c76851bc924ce5ab1c4c8bb5e2a2227d0"}, 541 | {file = "pydantic-1.10.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e4148e635994d57d834be1182a44bdb07dd867fa3c2d1b37002000646cc5459"}, 542 | {file = "pydantic-1.10.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12f7b0bf8553e310e530e9f3a2f5734c68699f42218bf3568ef49cd9b0e44df4"}, 543 | {file = "pydantic-1.10.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42aa0c4b5c3025483240a25b09f3c09a189481ddda2ea3a831a9d25f444e03c1"}, 544 | {file = "pydantic-1.10.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:17aef11cc1b997f9d574b91909fed40761e13fac438d72b81f902226a69dac01"}, 545 | {file = "pydantic-1.10.8-cp39-cp39-win_amd64.whl", hash = "sha256:66a703d1983c675a6e0fed8953b0971c44dba48a929a2000a493c3772eb61a5a"}, 546 | {file = "pydantic-1.10.8-py3-none-any.whl", hash = "sha256:7456eb22ed9aaa24ff3e7b4757da20d9e5ce2a81018c1b3ebd81a0b88a18f3b2"}, 547 | {file = "pydantic-1.10.8.tar.gz", hash = "sha256:1410275520dfa70effadf4c21811d755e7ef9bb1f1d077a21958153a92c8d9ca"}, 548 | ] 549 | 550 | [package.dependencies] 551 | typing-extensions = ">=4.2.0" 552 | 553 | [package.extras] 554 | dotenv = ["python-dotenv (>=0.10.4)"] 555 | email = ["email-validator (>=1.0.3)"] 556 | 557 | [[package]] 558 | name = "pyproject-api" 559 | version = "1.5.1" 560 | description = "API to interact with the python pyproject.toml based projects" 561 | category = "dev" 562 | optional = false 563 | python-versions = ">=3.7" 564 | files = [ 565 | {file = "pyproject_api-1.5.1-py3-none-any.whl", hash = "sha256:4698a3777c2e0f6b624f8a4599131e2a25376d90fe8d146d7ac74c67c6f97c43"}, 566 | {file = "pyproject_api-1.5.1.tar.gz", hash = "sha256:435f46547a9ff22cf4208ee274fca3e2869aeb062a4834adfc99a4dd64af3cf9"}, 567 | ] 568 | 569 | [package.dependencies] 570 | packaging = ">=23" 571 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 572 | 573 | [package.extras] 574 | docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] 575 | testing = ["covdefaults (>=2.2.2)", "importlib-metadata (>=6)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "virtualenv (>=20.17.1)", "wheel (>=0.38.4)"] 576 | 577 | [[package]] 578 | name = "pytest" 579 | version = "7.3.1" 580 | description = "pytest: simple powerful testing with Python" 581 | category = "dev" 582 | optional = false 583 | python-versions = ">=3.7" 584 | files = [ 585 | {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, 586 | {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, 587 | ] 588 | 589 | [package.dependencies] 590 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 591 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 592 | iniconfig = "*" 593 | packaging = "*" 594 | pluggy = ">=0.12,<2.0" 595 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 596 | 597 | [package.extras] 598 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 599 | 600 | [[package]] 601 | name = "pytest-asyncio" 602 | version = "0.21.0" 603 | description = "Pytest support for asyncio" 604 | category = "dev" 605 | optional = false 606 | python-versions = ">=3.7" 607 | files = [ 608 | {file = "pytest-asyncio-0.21.0.tar.gz", hash = "sha256:2b38a496aef56f56b0e87557ec313e11e1ab9276fc3863f6a7be0f1d0e415e1b"}, 609 | {file = "pytest_asyncio-0.21.0-py3-none-any.whl", hash = "sha256:f2b3366b7cd501a4056858bd39349d5af19742aed2d81660b7998b6341c7eb9c"}, 610 | ] 611 | 612 | [package.dependencies] 613 | pytest = ">=7.0.0" 614 | 615 | [package.extras] 616 | docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] 617 | testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] 618 | 619 | [[package]] 620 | name = "pytest-docker-service" 621 | version = "0.2.4" 622 | description = "pytest plugin to start docker container" 623 | category = "dev" 624 | optional = false 625 | python-versions = ">=3.8.0,<4.0" 626 | files = [ 627 | {file = "pytest_docker_service-0.2.4-py3-none-any.whl", hash = "sha256:e1c36f9869b56e60b4c9641723e692e8b9d165ee75c65cf91b104f28b7d17ab9"}, 628 | {file = "pytest_docker_service-0.2.4.tar.gz", hash = "sha256:363ea1b4666dbed9577ff06f612da65f23db4aa3bba31b20dd1d4a3027449139"}, 629 | ] 630 | 631 | [package.dependencies] 632 | docker = ">=6.0.0" 633 | pytest = ">=7.1.3" 634 | tenacity = ">=8.1.0" 635 | 636 | [[package]] 637 | name = "pywin32" 638 | version = "306" 639 | description = "Python for Window Extensions" 640 | category = "dev" 641 | optional = false 642 | python-versions = "*" 643 | files = [ 644 | {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, 645 | {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, 646 | {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, 647 | {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, 648 | {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, 649 | {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, 650 | {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, 651 | {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, 652 | {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, 653 | {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, 654 | {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, 655 | {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, 656 | {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, 657 | {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, 658 | ] 659 | 660 | [[package]] 661 | name = "requests" 662 | version = "2.31.0" 663 | description = "Python HTTP for Humans." 664 | category = "dev" 665 | optional = false 666 | python-versions = ">=3.7" 667 | files = [ 668 | {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, 669 | {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, 670 | ] 671 | 672 | [package.dependencies] 673 | certifi = ">=2017.4.17" 674 | charset-normalizer = ">=2,<4" 675 | idna = ">=2.5,<4" 676 | urllib3 = ">=1.21.1,<3" 677 | 678 | [package.extras] 679 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 680 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 681 | 682 | [[package]] 683 | name = "ruff" 684 | version = "0.0.267" 685 | description = "An extremely fast Python linter, written in Rust." 686 | category = "dev" 687 | optional = false 688 | python-versions = ">=3.7" 689 | files = [ 690 | {file = "ruff-0.0.267-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:4adbbbe314d8fcc539a245065bad89446a3cef2e0c9cf70bf7bb9ed6fe31856d"}, 691 | {file = "ruff-0.0.267-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:67254ae34c38cba109fdc52e4a70887de1f850fb3971e5eeef343db67305d1c1"}, 692 | {file = "ruff-0.0.267-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbe104f21a429b77eb5ac276bd5352fd8c0e1fbb580b4c772f77ee8c76825654"}, 693 | {file = "ruff-0.0.267-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:db33deef2a5e1cf528ca51cc59dd764122a48a19a6c776283b223d147041153f"}, 694 | {file = "ruff-0.0.267-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9adf1307fa9d840d1acaa477eb04f9702032a483214c409fca9dc46f5f157fe3"}, 695 | {file = "ruff-0.0.267-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0afca3633c8e2b6c0a48ad0061180b641b3b404d68d7e6736aab301c8024c424"}, 696 | {file = "ruff-0.0.267-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2972241065b1c911bce3db808837ed10f4f6f8a8e15520a4242d291083605ab6"}, 697 | {file = "ruff-0.0.267-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f731d81cb939e757b0335b0090f18ca2e9ff8bcc8e6a1cf909245958949b6e11"}, 698 | {file = "ruff-0.0.267-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20c594eb56c19063ef5a57f89340e64c6550e169d6a29408a45130a8c3068adc"}, 699 | {file = "ruff-0.0.267-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:45d61a2b01bdf61581a2ee039503a08aa603dc74a6bbe6fb5d1ce3052f5370e5"}, 700 | {file = "ruff-0.0.267-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2107cec3699ca4d7bd41543dc1d475c97ae3a21ea9212238b5c2088fa8ee7722"}, 701 | {file = "ruff-0.0.267-py3-none-musllinux_1_2_i686.whl", hash = "sha256:786de30723c71fc46b80a173c3313fc0dbe73c96bd9da8dd1212cbc2f84cdfb2"}, 702 | {file = "ruff-0.0.267-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a898953949e37c109dd242cfcf9841e065319995ebb7cdfd213b446094a942f"}, 703 | {file = "ruff-0.0.267-py3-none-win32.whl", hash = "sha256:d12ab329474c46b96d962e2bdb92e3ad2144981fe41b89c7770f370646c0101f"}, 704 | {file = "ruff-0.0.267-py3-none-win_amd64.whl", hash = "sha256:d09aecc9f5845586ba90911d815f9772c5a6dcf2e34be58c6017ecb124534ac4"}, 705 | {file = "ruff-0.0.267-py3-none-win_arm64.whl", hash = "sha256:7df7eb5f8d791566ba97cc0b144981b9c080a5b861abaf4bb35a26c8a77b83e9"}, 706 | {file = "ruff-0.0.267.tar.gz", hash = "sha256:632cec7bbaf3c06fcf0a72a1dd029b7d8b7f424ba95a574aaa135f5d20a00af7"}, 707 | ] 708 | 709 | [[package]] 710 | name = "sqlalchemy" 711 | version = "2.0.13" 712 | description = "Database Abstraction Library" 713 | category = "main" 714 | optional = false 715 | python-versions = ">=3.7" 716 | files = [ 717 | {file = "SQLAlchemy-2.0.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ad24c85f2a1caf0cd1ae8c2fdb668777a51a02246d9039420f94bd7dbfd37ed"}, 718 | {file = "SQLAlchemy-2.0.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db24d2738add6db19d66ca820479d2f8f96d3f5a13c223f27fa28dd2f268a4bd"}, 719 | {file = "SQLAlchemy-2.0.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72746ec17a7d9c5acf2c57a6e6190ceba3dad7127cd85bb17f24e90acc0e8e3f"}, 720 | {file = "SQLAlchemy-2.0.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:755f653d693f9b8f4286d987aec0d4279821bf8d179a9de8e8a5c685e77e57d6"}, 721 | {file = "SQLAlchemy-2.0.13-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e0d20f27edfd6f35b388da2bdcd7769e4ffa374fef8994980ced26eb287e033a"}, 722 | {file = "SQLAlchemy-2.0.13-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:37de4010f53f452e94e5ed6684480432cfe6a7a8914307ef819cd028b05b98d5"}, 723 | {file = "SQLAlchemy-2.0.13-cp310-cp310-win32.whl", hash = "sha256:31f72bb300eed7bfdb373c7c046121d84fa0ae6f383089db9505ff553ac27cef"}, 724 | {file = "SQLAlchemy-2.0.13-cp310-cp310-win_amd64.whl", hash = "sha256:ec2f525273528425ed2f51861b7b88955160cb95dddb17af0914077040aff4a5"}, 725 | {file = "SQLAlchemy-2.0.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2424a84f131901fbb20a99844d47b38b517174c6e964c8efb15ea6bb9ced8c2b"}, 726 | {file = "SQLAlchemy-2.0.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f9832815257969b3ca9bf0501351e4c02c8d60cbd3ec9f9070d5b0f8852900e"}, 727 | {file = "SQLAlchemy-2.0.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a30e4db983faa5145e00ef6eaf894a2d503b3221dbf40a595f3011930d3d0bac"}, 728 | {file = "SQLAlchemy-2.0.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f717944aee40e9f48776cf85b523bb376aa2d9255a268d6d643c57ab387e7264"}, 729 | {file = "SQLAlchemy-2.0.13-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9119795d2405eb23bf7e6707e228fe38124df029494c1b3576459aa3202ea432"}, 730 | {file = "SQLAlchemy-2.0.13-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2ad9688debf1f0ae9c6e0706a4e2d33b1a01281317cee9bd1d7eef8020c5baac"}, 731 | {file = "SQLAlchemy-2.0.13-cp311-cp311-win32.whl", hash = "sha256:c61b89803a87a3b2a394089a7dadb79a6c64c89f2e8930cc187fec43b319f8d2"}, 732 | {file = "SQLAlchemy-2.0.13-cp311-cp311-win_amd64.whl", hash = "sha256:0aa2cbde85a6eab9263ab480f19e8882d022d30ebcdc14d69e6a8d7c07b0a871"}, 733 | {file = "SQLAlchemy-2.0.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9ad883ac4f5225999747f0849643c4d0ec809d9ffe0ddc81a81dd3e68d0af463"}, 734 | {file = "SQLAlchemy-2.0.13-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e481e54db8cec1457ee7c05f6d2329e3298a304a70d3b5e2e82e77170850b385"}, 735 | {file = "SQLAlchemy-2.0.13-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4e08e3831671008888bad5d160d757ef35ce34dbb73b78c3998d16aa1334c97"}, 736 | {file = "SQLAlchemy-2.0.13-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f234ba3bb339ad17803009c8251f5ee65dcf283a380817fe486823b08b26383d"}, 737 | {file = "SQLAlchemy-2.0.13-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:375b7ba88f261dbd79d044f20cbcd919d88befb63f26af9d084614f10cdf97a6"}, 738 | {file = "SQLAlchemy-2.0.13-cp37-cp37m-win32.whl", hash = "sha256:9136d596111c742d061c0f99bab95c5370016c4101a32e72c2b634ad5e0757e6"}, 739 | {file = "SQLAlchemy-2.0.13-cp37-cp37m-win_amd64.whl", hash = "sha256:7612a7366a0855a04430363fb4ab392dc6818aaece0b2e325ff30ee77af9b21f"}, 740 | {file = "SQLAlchemy-2.0.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:49c138856035cb97f0053e5e57ba90ec936b28a0b8b0020d44965c7b0c0bf03a"}, 741 | {file = "SQLAlchemy-2.0.13-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a5e9e78332a5d841422b88b8c490dfd7f761e64b3430249b66c05d02f72ceab0"}, 742 | {file = "SQLAlchemy-2.0.13-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd0febae872a4042da44e972c070f0fd49a85a0a7727ab6b85425f74348be14e"}, 743 | {file = "SQLAlchemy-2.0.13-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:566a0ac347cf4632f551e7b28bbd0d215af82e6ffaa2556f565a3b6b51dc3f81"}, 744 | {file = "SQLAlchemy-2.0.13-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e5e5dc300a0ca8755ada1569f5caccfcdca28607dfb98b86a54996b288a8ebd3"}, 745 | {file = "SQLAlchemy-2.0.13-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a25b4c4fdd633501233924f873e6f6cd8970732859ecfe4ecfb60635881f70be"}, 746 | {file = "SQLAlchemy-2.0.13-cp38-cp38-win32.whl", hash = "sha256:6777673d346071451bf7cccf8d0499024f1bd6a835fc90b4fe7af50373d92ce6"}, 747 | {file = "SQLAlchemy-2.0.13-cp38-cp38-win_amd64.whl", hash = "sha256:2f0a355264af0952570f18457102984e1f79510f856e5e0ae652e63316d1ca23"}, 748 | {file = "SQLAlchemy-2.0.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d93ebbff3dcf05274843ad8cf650b48ee634626e752c5d73614e5ec9df45f0ce"}, 749 | {file = "SQLAlchemy-2.0.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fec56c7d1b6a22c8f01557de3975d962ee40270b81b60d1cfdadf2a105d10e84"}, 750 | {file = "SQLAlchemy-2.0.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eb14a386a5b610305bec6639b35540b47f408b0a59f75999199aed5b3d40079"}, 751 | {file = "SQLAlchemy-2.0.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f3b5236079bc3e318a92bab2cc3f669cc32127075ab03ff61cacbae1c392b8"}, 752 | {file = "SQLAlchemy-2.0.13-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bf1aae95e80acea02a0a622e1c12d3fefc52ffd0fe7bda70a30d070373fbb6c3"}, 753 | {file = "SQLAlchemy-2.0.13-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cdf80359b641185ae7e580afb9f88cf560298f309a38182972091165bfe1225d"}, 754 | {file = "SQLAlchemy-2.0.13-cp39-cp39-win32.whl", hash = "sha256:f463598f9e51ccc04f0fe08500f9a0c3251a7086765350be418598b753b5561d"}, 755 | {file = "SQLAlchemy-2.0.13-cp39-cp39-win_amd64.whl", hash = "sha256:881cc388dded44ae6e17a1666364b98bd76bcdc71b869014ae725f06ba298e0e"}, 756 | {file = "SQLAlchemy-2.0.13-py3-none-any.whl", hash = "sha256:0d6979c9707f8b82366ba34b38b5a6fe32f75766b2e901f9820e271e95384070"}, 757 | {file = "SQLAlchemy-2.0.13.tar.gz", hash = "sha256:8d97b37b4e60073c38bcf94e289e3be09ef9be870de88d163f16e08f2b9ded1a"}, 758 | ] 759 | 760 | [package.dependencies] 761 | greenlet = {version = "!=0.4.17", markers = "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\""} 762 | typing-extensions = ">=4.2.0" 763 | 764 | [package.extras] 765 | aiomysql = ["aiomysql", "greenlet (!=0.4.17)"] 766 | aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing-extensions (!=3.10.0.1)"] 767 | asyncio = ["greenlet (!=0.4.17)"] 768 | asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] 769 | mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] 770 | mssql = ["pyodbc"] 771 | mssql-pymssql = ["pymssql"] 772 | mssql-pyodbc = ["pyodbc"] 773 | mypy = ["mypy (>=0.910)"] 774 | mysql = ["mysqlclient (>=1.4.0)"] 775 | mysql-connector = ["mysql-connector-python"] 776 | oracle = ["cx-oracle (>=7)"] 777 | oracle-oracledb = ["oracledb (>=1.0.1)"] 778 | postgresql = ["psycopg2 (>=2.7)"] 779 | postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] 780 | postgresql-pg8000 = ["pg8000 (>=1.29.1)"] 781 | postgresql-psycopg = ["psycopg (>=3.0.7)"] 782 | postgresql-psycopg2binary = ["psycopg2-binary"] 783 | postgresql-psycopg2cffi = ["psycopg2cffi"] 784 | pymysql = ["pymysql"] 785 | sqlcipher = ["sqlcipher3-binary"] 786 | 787 | [[package]] 788 | name = "tenacity" 789 | version = "8.2.2" 790 | description = "Retry code until it succeeds" 791 | category = "dev" 792 | optional = false 793 | python-versions = ">=3.6" 794 | files = [ 795 | {file = "tenacity-8.2.2-py3-none-any.whl", hash = "sha256:2f277afb21b851637e8f52e6a613ff08734c347dc19ade928e519d7d2d8569b0"}, 796 | {file = "tenacity-8.2.2.tar.gz", hash = "sha256:43af037822bd0029025877f3b2d97cc4d7bb0c2991000a3d59d71517c5c969e0"}, 797 | ] 798 | 799 | [package.extras] 800 | doc = ["reno", "sphinx", "tornado (>=4.5)"] 801 | 802 | [[package]] 803 | name = "tomli" 804 | version = "2.0.1" 805 | description = "A lil' TOML parser" 806 | category = "dev" 807 | optional = false 808 | python-versions = ">=3.7" 809 | files = [ 810 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 811 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 812 | ] 813 | 814 | [[package]] 815 | name = "tox" 816 | version = "4.6.0" 817 | description = "tox is a generic virtualenv management and test command line tool" 818 | category = "dev" 819 | optional = false 820 | python-versions = ">=3.7" 821 | files = [ 822 | {file = "tox-4.6.0-py3-none-any.whl", hash = "sha256:4874000453e637a87ca892f9744a2ab9a7d24064dad1b0ecbf5a4c3c146cc732"}, 823 | {file = "tox-4.6.0.tar.gz", hash = "sha256:954f1f647f67f481d239a193288983242a6152b67503c4a56b19a4aafaa29736"}, 824 | ] 825 | 826 | [package.dependencies] 827 | cachetools = ">=5.3" 828 | chardet = ">=5.1" 829 | colorama = ">=0.4.6" 830 | filelock = ">=3.12" 831 | packaging = ">=23.1" 832 | platformdirs = ">=3.5.1" 833 | pluggy = ">=1" 834 | pyproject-api = ">=1.5.1" 835 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 836 | virtualenv = ">=20.23" 837 | 838 | [package.extras] 839 | docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-argparse-cli (>=1.11)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] 840 | testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "devpi-process (>=0.3)", "diff-cover (>=7.5)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.17)", "psutil (>=5.9.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.9)", "wheel (>=0.40)"] 841 | 842 | [[package]] 843 | name = "typing-extensions" 844 | version = "4.6.3" 845 | description = "Backported and Experimental Type Hints for Python 3.7+" 846 | category = "main" 847 | optional = false 848 | python-versions = ">=3.7" 849 | files = [ 850 | {file = "typing_extensions-4.6.3-py3-none-any.whl", hash = "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26"}, 851 | {file = "typing_extensions-4.6.3.tar.gz", hash = "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"}, 852 | ] 853 | 854 | [[package]] 855 | name = "urllib3" 856 | version = "2.0.2" 857 | description = "HTTP library with thread-safe connection pooling, file post, and more." 858 | category = "dev" 859 | optional = false 860 | python-versions = ">=3.7" 861 | files = [ 862 | {file = "urllib3-2.0.2-py3-none-any.whl", hash = "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"}, 863 | {file = "urllib3-2.0.2.tar.gz", hash = "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc"}, 864 | ] 865 | 866 | [package.extras] 867 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 868 | secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] 869 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 870 | zstd = ["zstandard (>=0.18.0)"] 871 | 872 | [[package]] 873 | name = "virtualenv" 874 | version = "20.23.0" 875 | description = "Virtual Python Environment builder" 876 | category = "dev" 877 | optional = false 878 | python-versions = ">=3.7" 879 | files = [ 880 | {file = "virtualenv-20.23.0-py3-none-any.whl", hash = "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e"}, 881 | {file = "virtualenv-20.23.0.tar.gz", hash = "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924"}, 882 | ] 883 | 884 | [package.dependencies] 885 | distlib = ">=0.3.6,<1" 886 | filelock = ">=3.11,<4" 887 | platformdirs = ">=3.2,<4" 888 | 889 | [package.extras] 890 | docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] 891 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.7.1)", "time-machine (>=2.9)"] 892 | 893 | [[package]] 894 | name = "websocket-client" 895 | version = "1.5.2" 896 | description = "WebSocket client for Python with low level API options" 897 | category = "dev" 898 | optional = false 899 | python-versions = ">=3.7" 900 | files = [ 901 | {file = "websocket-client-1.5.2.tar.gz", hash = "sha256:c7d67c13b928645f259d9b847ab5b57fd2d127213ca41ebd880de1f553b7c23b"}, 902 | {file = "websocket_client-1.5.2-py3-none-any.whl", hash = "sha256:f8c64e28cd700e7ba1f04350d66422b6833b82a796b525a51e740b8cc8dab4b1"}, 903 | ] 904 | 905 | [package.extras] 906 | docs = ["Sphinx (>=3.4)", "sphinx-rtd-theme (>=0.5)"] 907 | optional = ["python-socks", "wsaccel"] 908 | test = ["websockets"] 909 | 910 | [[package]] 911 | name = "yapf" 912 | version = "0.33.0" 913 | description = "A formatter for Python code." 914 | category = "dev" 915 | optional = false 916 | python-versions = "*" 917 | files = [ 918 | {file = "yapf-0.33.0-py2.py3-none-any.whl", hash = "sha256:4c2b59bd5ffe46f3a7da48df87596877189148226ce267c16e8b44240e51578d"}, 919 | {file = "yapf-0.33.0.tar.gz", hash = "sha256:da62bdfea3df3673553351e6246abed26d9fe6780e548a5af9e70f6d2b4f5b9a"}, 920 | ] 921 | 922 | [package.dependencies] 923 | tomli = ">=2.0.1" 924 | 925 | [metadata] 926 | lock-version = "2.0" 927 | python-versions = "^3.8" 928 | content-hash = "ff279784813ca0be0eaef8f29a61f8c44a42257220deea1a9a9f52d1dcce43e9" 929 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry.group.test.dependencies] 2 | pytest = "^7.3.1" 3 | pytest-asyncio = "^0.21.0" 4 | pytest-docker-service = "^0.2.4" 5 | tox = "^4.6.0" 6 | 7 | [tool.poetry.group.dev.dependencies] 8 | yapf = "^0.33.0" 9 | black = "^23.3.0" 10 | ruff = "^0.0.267" 11 | 12 | [tool.tox] 13 | # See https://python-poetry.org/docs/master/faq/#usecase-3 14 | legacy_tox_ini = """ 15 | [tox] 16 | isolated_build = true 17 | 18 | env_list = 19 | py38 20 | py39 21 | py310 22 | py311 23 | 24 | [testenv] 25 | skip_install = true 26 | allowlist_externals = poetry 27 | commands_pre = poetry install -v --only=main,test 28 | commands = poetry run pytest -vx 29 | """ 30 | 31 | [tool.pytest.ini_options] 32 | asyncio_mode = "auto" 33 | 34 | [tool.ruff] 35 | ignore = ["E501"] 36 | 37 | [tool.poetry] 38 | name = "sqlalchemy-nested-mutable" 39 | version = "0.2.0" 40 | readme = "README.md" 41 | description = "SQLAlchemy Nested Mutable Types." 42 | authors = ["Wonder "] 43 | license = "MIT" 44 | repository = "https://github.com/wonderbeyond/sqlalchemy-nested-mutable" 45 | homepage = "https://github.com/wonderbeyond/sqlalchemy-nested-mutable" 46 | documentation = "https://github.com/wonderbeyond/sqlalchemy-nested-mutable" 47 | keywords = ["SQLAlchemy", "Nested", "Mutable", "Types", "JSON"] 48 | classifiers = [ 49 | "Intended Audience :: Developers", 50 | "Programming Language :: Python :: 3", 51 | "Topic :: Software Development :: Libraries :: Python Modules", 52 | "License :: OSI Approved :: MIT License", 53 | "Operating System :: OS Independent" 54 | ] 55 | packages = [ 56 | { include = "sqlalchemy_nested_mutable" }, 57 | ] 58 | 59 | [tool.poetry.dependencies] 60 | python = "^3.8" 61 | sqlalchemy = "^2.0" 62 | psycopg2-binary = "^2.8" 63 | pydantic = "^1.10.0" 64 | typing-extensions = "^4.5.0" 65 | 66 | [build-system] 67 | requires = ["poetry-core>=1.0.0"] 68 | build-backend = "poetry.core.masonry.api" 69 | 70 | [tool.poetry] 71 | include = ["sqlalchemy_nested_mutable/py.typed"] 72 | -------------------------------------------------------------------------------- /sqlalchemy_nested_mutable/__init__.py: -------------------------------------------------------------------------------- 1 | from .trackable import TrackedList, TrackedDict, TrackedPydanticBaseModel 2 | from .mutable import MutableList, MutableDict, MutablePydanticBaseModel 3 | 4 | 5 | __all__ = [ 6 | 'TrackedList', 7 | 'TrackedDict', 8 | 'TrackedPydanticBaseModel', 9 | 10 | 'MutableList', 11 | 'MutableDict', 12 | 'MutablePydanticBaseModel', 13 | ] 14 | -------------------------------------------------------------------------------- /sqlalchemy_nested_mutable/_compat.py: -------------------------------------------------------------------------------- 1 | try: 2 | import pydantic 3 | except ImportError: 4 | pydantic = None 5 | -------------------------------------------------------------------------------- /sqlalchemy_nested_mutable/_typing.py: -------------------------------------------------------------------------------- 1 | from typing import Any, TypeVar 2 | 3 | _T = TypeVar("_T", bound=Any) 4 | _KT = TypeVar("_KT") # Key type. 5 | _VT = TypeVar("_VT") # Value type. 6 | -------------------------------------------------------------------------------- /sqlalchemy_nested_mutable/mutable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, List, Iterable, TypeVar 4 | from typing_extensions import Self 5 | 6 | import sqlalchemy as sa 7 | from sqlalchemy.ext.mutable import Mutable 8 | from sqlalchemy.sql.type_api import TypeEngine 9 | 10 | from .trackable import TrackedObject, TrackedList, TrackedDict, TrackedPydanticBaseModel 11 | from ._typing import _T 12 | from ._compat import pydantic 13 | 14 | _P = TypeVar("_P", bound='MutablePydanticBaseModel') 15 | 16 | 17 | class MutableList(TrackedList, Mutable, List[_T]): 18 | """ 19 | A mutable list that tracks changes to itself and its children. 20 | 21 | Used as top-level mapped object. e.g. 22 | 23 | aliases: Mapped[list[str]] = mapped_column(MutableList.as_mutable(ARRAY(String(128)))) 24 | schedule: Mapped[list[list[str]]] = mapped_column(MutableList.as_mutable(ARRAY(sa.String(128), dimensions=2))) 25 | """ 26 | @classmethod 27 | def coerce(cls, key, value): 28 | return value if isinstance(value, cls) else cls(value) 29 | 30 | def __init__(self, __iterable: Iterable[_T]): 31 | super().__init__(TrackedObject.make_nested_trackable(__iterable, self)) 32 | 33 | 34 | class MutableDict(TrackedDict, Mutable): 35 | @classmethod 36 | def coerce(cls, key, value): 37 | return value if isinstance(value, cls) else cls(value) 38 | 39 | def __init__(self, source=(), **kwds): 40 | super().__init__(TrackedObject.make_nested_trackable(dict(source, **kwds), self)) 41 | 42 | 43 | if pydantic is not None: 44 | class PydanticType(sa.types.TypeDecorator, TypeEngine[_P]): 45 | """ 46 | Inspired by https://gist.github.com/imankulov/4051b7805ad737ace7d8de3d3f934d6b 47 | """ 48 | cache_ok = True 49 | impl = sa.types.JSON 50 | 51 | def __init__(self, pydantic_type: type[_P], sqltype: TypeEngine[_T] = None): 52 | super().__init__() 53 | self.pydantic_type = pydantic_type 54 | self.sqltype = sqltype 55 | 56 | def load_dialect_impl(self, dialect): 57 | from sqlalchemy.dialects.postgresql import JSONB 58 | 59 | if self.sqltype is not None: 60 | return dialect.type_descriptor(self.sqltype) 61 | 62 | if dialect.name == "postgresql": 63 | return dialect.type_descriptor(JSONB()) 64 | return dialect.type_descriptor(sa.JSON()) 65 | 66 | def __repr__(self): 67 | # NOTE: the `__repr__` is used by Alembic to generate the migration script. 68 | return f'PydanticType({self.pydantic_type.__name__})' 69 | 70 | def process_bind_param(self, value, dialect): 71 | return value.dict() if value else None 72 | 73 | def process_result_value(self, value, dialect) -> _P | None: 74 | return None if value is None else pydantic.parse_obj_as(self.pydantic_type, value) 75 | 76 | class MutablePydanticBaseModel(TrackedPydanticBaseModel, Mutable): 77 | @classmethod 78 | def coerce(cls, key, value) -> Self: 79 | return value if isinstance(value, cls) else cls.parse_obj(value) 80 | 81 | def dict(self, *args, **kwargs): 82 | res = super().dict(*args, **kwargs) 83 | res.pop('_parents', None) 84 | return res 85 | 86 | @classmethod 87 | def as_mutable(cls, sqltype: TypeEngine[_T] = None) -> TypeEngine[Self]: 88 | return super().as_mutable(PydanticType(cls, sqltype)) 89 | elif not TYPE_CHECKING: 90 | class PydanticType: 91 | def __new__(cls, *a, **k): 92 | raise RuntimeError("PydanticType requires pydantic to be installed") 93 | 94 | class MutablePydanticBaseModel: 95 | def __new__(cls, *a, **k): 96 | raise RuntimeError("MutablePydanticBaseModel requires pydantic to be installed") 97 | -------------------------------------------------------------------------------- /sqlalchemy_nested_mutable/py.typed: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /sqlalchemy_nested_mutable/testing/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import psycopg2 4 | 5 | 6 | def wait_pg_ready(dbinfo: dict, check_interval_base=0.1, back_rate=1.1, max_check_times=50): 7 | check_interval = check_interval_base 8 | for _ in range(max_check_times): 9 | try: 10 | with psycopg2.connect( 11 | host=dbinfo['host'], 12 | port=dbinfo['port'], 13 | dbname=dbinfo['database'], 14 | user=dbinfo['user'], 15 | password=dbinfo['password'], 16 | ) as conn: 17 | with conn.cursor() as cur: 18 | cur.execute('SELECT 1;') 19 | if cur.fetchone()[0] == 1: 20 | break 21 | except psycopg2.OperationalError: 22 | time.sleep(check_interval) 23 | check_interval *= back_rate 24 | continue 25 | -------------------------------------------------------------------------------- /sqlalchemy_nested_mutable/trackable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional, Union, Any, Tuple, Dict, List, Iterable, overload 4 | from typing_extensions import Self 5 | from weakref import WeakValueDictionary 6 | 7 | from sqlalchemy.util.typing import SupportsIndex, TypeGuard 8 | from sqlalchemy.ext.mutable import Mutable 9 | 10 | from ._typing import _T, _KT, _VT 11 | from ._compat import pydantic 12 | 13 | parents_track: WeakValueDictionary[int, object] = WeakValueDictionary() 14 | 15 | 16 | class TrackedObject: 17 | """ 18 | Represents an object in a nested context whose parent can be tracked. 19 | 20 | The top object in the parent link should be an instance of `Mutable`. 21 | """ 22 | def __del__(self): 23 | if (id_ := id(self)) in parents_track: 24 | del parents_track[id_] 25 | 26 | def changed(self): 27 | if (id_ := id(self)) in parents_track: 28 | parent = parents_track[id_] 29 | parent.changed() 30 | elif isinstance(self, Mutable): 31 | super().changed() 32 | 33 | @classmethod 34 | def make_nested_trackable(cls, val: _T, parent: Mutable): 35 | new_val: Any = val 36 | 37 | if isinstance(val, dict): 38 | new_val = TrackedDict((k, cls.make_nested_trackable(v, parent)) for k, v in val.items()) 39 | elif isinstance(val, list): 40 | new_val = TrackedList(cls.make_nested_trackable(o, parent) for o in val) 41 | elif isinstance(val, pydantic.BaseModel) and not isinstance(val, TrackedPydanticBaseModel): 42 | model_cls = type('Tracked' + val.__class__.__name__, (TrackedPydanticBaseModel, val.__class__), {}) 43 | model_cls.__doc__ = ( 44 | f"This class is composed of `{val.__class__.__name__}` and `TrackedPydanticBaseModel` " 45 | "to make it trackable in nested context." 46 | ) 47 | new_val = model_cls.parse_obj(val.dict()) # type: ignore 48 | 49 | if isinstance(new_val, cls): 50 | parents_track[id(new_val)] = parent 51 | 52 | return new_val 53 | 54 | 55 | class TrackedList(TrackedObject, List[_T]): 56 | def __reduce_ex__( 57 | self, proto: SupportsIndex 58 | ) -> Tuple[type, Tuple[List[int]]]: 59 | return (self.__class__, (list(self),)) 60 | 61 | # needed for backwards compatibility with 62 | # older pickles 63 | def __setstate__(self, state: Iterable[_T]) -> None: 64 | self[:] = state 65 | 66 | def is_scalar(self, value: _T | Iterable[_T]) -> TypeGuard[_T]: 67 | return not isinstance(value, Iterable) 68 | 69 | def is_iterable(self, value: _T | Iterable[_T]) -> TypeGuard[Iterable[_T]]: 70 | return isinstance(value, Iterable) 71 | 72 | def __setitem__( 73 | self, index: SupportsIndex | slice, value: _T | Iterable[_T] 74 | ) -> None: 75 | """Detect list set events and emit change events.""" 76 | super().__setitem__(index, TrackedObject.make_nested_trackable(value, self)) 77 | self.changed() 78 | 79 | def __delitem__(self, index: SupportsIndex | slice) -> None: 80 | """Detect list del events and emit change events.""" 81 | super().__delitem__(index) 82 | self.changed() 83 | 84 | def pop(self, *arg: SupportsIndex) -> _T: 85 | result = super().pop(*arg) 86 | self.changed() 87 | return result 88 | 89 | def append(self, x: _T) -> None: 90 | super().append(TrackedObject.make_nested_trackable(x, self)) 91 | self.changed() 92 | 93 | def extend(self, x: Iterable[_T]) -> None: 94 | super().extend(x) 95 | self.changed() 96 | 97 | def __iadd__(self, x: Iterable[_T]) -> Self: # type: ignore 98 | self.extend(TrackedObject.make_nested_trackable(v, self) for v in x) 99 | return self 100 | 101 | def insert(self, i: SupportsIndex, x: _T) -> None: 102 | super().insert(i, TrackedObject.make_nested_trackable(x, self)) 103 | self.changed() 104 | 105 | def remove(self, i: _T) -> None: 106 | super().remove(i) 107 | self.changed() 108 | 109 | def clear(self) -> None: 110 | super().clear() 111 | self.changed() 112 | 113 | def sort(self, **kw: Any) -> None: 114 | super().sort(**kw) 115 | self.changed() 116 | 117 | def reverse(self) -> None: 118 | super().reverse() 119 | self.changed() 120 | 121 | 122 | class TrackedDict(TrackedObject, Dict[_KT, _VT]): 123 | def __setitem__(self, key: _KT, value: _VT) -> None: 124 | """Detect dictionary set events and emit change events.""" 125 | super().__setitem__(key, value) 126 | self.changed() 127 | 128 | if TYPE_CHECKING: 129 | # from https://github.com/python/mypy/issues/14858 130 | 131 | @overload 132 | def setdefault( 133 | self: TrackedDict[_KT, Optional[_T]], key: _KT, value: None = None 134 | ) -> Optional[_T]: 135 | ... 136 | 137 | @overload 138 | def setdefault(self, key: _KT, value: _VT) -> _VT: 139 | ... 140 | 141 | def setdefault(self, key: _KT, value: object = None) -> object: 142 | ... 143 | 144 | else: 145 | 146 | def setdefault(self, key, value=None): # noqa: F811 147 | result = super().setdefault(key, value=TrackedObject.make_nested_trackable(value, self)) 148 | self.changed() 149 | return result 150 | 151 | def __delitem__(self, key: _KT) -> None: 152 | """Detect dictionary del events and emit change events.""" 153 | super().__delitem__(key) 154 | self.changed() 155 | 156 | def update(self, *a: Any, **kw: _VT) -> None: 157 | a = tuple(TrackedObject.make_nested_trackable(e, self) for e in a) 158 | kw = {k: TrackedObject.make_nested_trackable(v, self) for k, v in kw.items()} 159 | super().update(*a, **kw) 160 | self.changed() 161 | 162 | if TYPE_CHECKING: 163 | 164 | @overload 165 | def pop(self, __key: _KT) -> _VT: 166 | ... 167 | 168 | @overload 169 | def pop(self, __key: _KT, __default: _VT | _T) -> _VT | _T: 170 | ... 171 | 172 | def pop( 173 | self, __key: _KT, __default: _VT | _T | None = None 174 | ) -> _VT | _T: 175 | ... 176 | 177 | else: 178 | 179 | def pop(self, *arg): # noqa: F811 180 | result = super().pop(*arg) 181 | self.changed() 182 | return result 183 | 184 | def popitem(self) -> Tuple[_KT, _VT]: 185 | result = super().popitem() 186 | self.changed() 187 | return result 188 | 189 | def clear(self) -> None: 190 | super().clear() 191 | self.changed() 192 | 193 | def __setstate__( 194 | self, state: Union[Dict[str, int], Dict[str, str]] 195 | ) -> None: 196 | self.update(state) 197 | 198 | 199 | if pydantic is not None: 200 | class TrackedPydanticBaseModel(TrackedObject, Mutable, pydantic.BaseModel): 201 | @classmethod 202 | def coerce(cls, key, value): 203 | return value if isinstance(value, cls) else cls.parse_obj(value) 204 | 205 | def __init__(self, **data): 206 | super().__init__(**data) 207 | for field in self.__fields__.values(): 208 | setattr( 209 | self, 210 | field.name, 211 | TrackedObject.make_nested_trackable(getattr(self, field.name), self) 212 | ) 213 | 214 | def __setattr__(self, name, value): 215 | prev_value = getattr(self, name, None) 216 | super().__setattr__(name, value) 217 | if prev_value != getattr(self, name): 218 | self.changed() 219 | elif not TYPE_CHECKING: 220 | class TrackedPydanticBaseModel: 221 | def __new__(cls, *a, **k): 222 | raise RuntimeError("pydantic is not installed!") 223 | -------------------------------------------------------------------------------- /tests/test_custom_sqltype.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | import pytest 4 | from sqlalchemy.dialects.postgresql import JSON, JSONB 5 | from sqlalchemy_nested_mutable._compat import pydantic 6 | import sqlalchemy as sa 7 | from sqlalchemy.orm import ( 8 | DeclarativeBase, 9 | Mapped, 10 | mapped_column, 11 | ) 12 | 13 | from sqlalchemy_nested_mutable import MutablePydanticBaseModel 14 | 15 | 16 | class Base(DeclarativeBase): 17 | pass 18 | 19 | 20 | class Addresses(MutablePydanticBaseModel): 21 | class AddressItem(pydantic.BaseModel): 22 | street: str 23 | city: str 24 | area: Optional[str] 25 | 26 | work: List[AddressItem] = [] 27 | home: List[AddressItem] = [] 28 | 29 | 30 | class User(Base): 31 | __tablename__ = "user_account" 32 | 33 | id: Mapped[int] = mapped_column(primary_key=True) 34 | name: Mapped[str] = mapped_column(sa.String(30)) 35 | addresses_default: Mapped[Optional[Addresses]] = mapped_column(Addresses.as_mutable()) 36 | addresses_json: Mapped[Optional[Addresses]] = mapped_column(Addresses.as_mutable(JSON)) 37 | addresses_jsonb: Mapped[Optional[Addresses]] = mapped_column(Addresses.as_mutable(JSONB)) 38 | 39 | 40 | @pytest.fixture(scope="module", autouse=True) 41 | def _with_tables(session): 42 | Base.metadata.create_all(session.bind) 43 | yield 44 | session.execute(sa.text(""" 45 | DROP TABLE user_account CASCADE; 46 | """)) 47 | session.commit() 48 | 49 | 50 | def test_mutable_pydantic_type(session): 51 | session.add(User(name="foo")) 52 | session.commit() 53 | assert session.scalar(sa.select(sa.func.pg_typeof(User.addresses_default))) == "jsonb" 54 | assert session.scalar(sa.select(sa.func.pg_typeof(User.addresses_json))) == "json" 55 | assert session.scalar(sa.select(sa.func.pg_typeof(User.addresses_jsonb))) == "jsonb" 56 | -------------------------------------------------------------------------------- /tests/test_mutable_dict.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sqlalchemy as sa 3 | from sqlalchemy.dialects.postgresql import JSONB 4 | from sqlalchemy.orm import ( 5 | DeclarativeBase, 6 | Mapped, 7 | mapped_column, 8 | ) 9 | 10 | from sqlalchemy_nested_mutable import MutableDict, TrackedDict, TrackedList 11 | 12 | 13 | class Base(DeclarativeBase): 14 | pass 15 | 16 | 17 | class User(Base): 18 | __tablename__ = "user_account" 19 | 20 | id: Mapped[int] = mapped_column(primary_key=True) 21 | name: Mapped[str] = mapped_column(sa.String(30)) 22 | addresses = mapped_column(MutableDict.as_mutable(JSONB), default=dict) 23 | 24 | 25 | @pytest.fixture(scope="module", autouse=True) 26 | def _with_tables(session): 27 | Base.metadata.create_all(session.bind) 28 | yield 29 | session.execute(sa.text(""" 30 | DROP TABLE user_account CASCADE; 31 | """)) 32 | session.commit() 33 | 34 | 35 | def test_mutable_dict(session): 36 | session.add(u := User(name="foo", addresses={ 37 | "home": {"street": "123 Main Street", "city": "New York"}, 38 | "work": "456 Wall Street", 39 | })) 40 | session.commit() 41 | 42 | assert isinstance(u.addresses, MutableDict) 43 | assert u.addresses["home"] == {"street": "123 Main Street", "city": "New York"} 44 | assert isinstance(u.addresses['home'], TrackedDict) 45 | assert isinstance(u.addresses['work'], str) 46 | 47 | # Shallow change 48 | u.addresses["realtime"] = "999 RT Street" 49 | session.commit() 50 | assert u.addresses["realtime"] == "999 RT Street" 51 | 52 | # Deep change 53 | u.addresses["home"]["street"] = "124 Main Street" 54 | session.commit() 55 | assert u.addresses["home"] == {"street": "124 Main Street", "city": "New York"} 56 | 57 | # Change by update() 58 | u.addresses["home"].update({"street": "125 Main Street"}) 59 | session.commit() 60 | assert u.addresses["home"] == {"street": "125 Main Street", "city": "New York"} 61 | 62 | u.addresses["home"].update({"area": "America"}, street="126 Main Street") 63 | session.commit() 64 | assert u.addresses["home"] == {"street": "126 Main Street", "city": "New York", "area": "America"} 65 | 66 | 67 | def test_mutable_dict_mixed_with_list(session): 68 | session.add(u := User(name="bar", addresses={ 69 | "home": {"street": "123 Main Street", "city": "New York"}, 70 | "work": "456 Wall Street", 71 | "others": [ 72 | {"label": "secret0", "address": "789 Moon Street"}, 73 | ] 74 | })) 75 | session.commit() 76 | 77 | assert isinstance(u.addresses["others"], TrackedList) 78 | assert u.addresses["others"] == [{"label": "secret0", "address": "789 Moon Street"}] 79 | 80 | # Deep change on list value 81 | u.addresses["others"].append({"label": "secret1", "address": "790 Moon Street"}) 82 | session.commit() 83 | assert u.addresses["others"] == [ 84 | {"label": "secret0", "address": "789 Moon Street"}, 85 | {"label": "secret1", "address": "790 Moon Street"}, 86 | ] 87 | 88 | # Deep change across list and dict values 89 | u.addresses["others"][1].update(address="791 Moon Street") 90 | session.commit() 91 | assert u.addresses["others"] == [ 92 | {"label": "secret0", "address": "789 Moon Street"}, 93 | {"label": "secret1", "address": "791 Moon Street"}, 94 | ] 95 | -------------------------------------------------------------------------------- /tests/test_mutable_list.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pytest 4 | import sqlalchemy as sa 5 | from sqlalchemy.dialects.postgresql import ARRAY, JSONB 6 | from sqlalchemy.orm import ( 7 | DeclarativeBase, 8 | Mapped, 9 | mapped_column, 10 | ) 11 | 12 | 13 | from sqlalchemy_nested_mutable import MutableList, TrackedList, TrackedDict 14 | 15 | 16 | class Base(DeclarativeBase): 17 | pass 18 | 19 | 20 | class User(Base): 21 | __tablename__ = "user_account" 22 | 23 | id: Mapped[int] = mapped_column(primary_key=True) 24 | name: Mapped[str] = mapped_column(sa.String(30)) 25 | aliases = mapped_column(MutableList[str].as_mutable(ARRAY(sa.String(128))), default=list) 26 | schedule = mapped_column( 27 | MutableList[List[str]].as_mutable(ARRAY(sa.String(128), dimensions=2)), default=list 28 | ) # a user's weekly schedule, e.g. [ ['meeting', 'launch'], ['training', 'presentation'] ] 29 | 30 | 31 | class UserV2(Base): 32 | """Use JSONB to store array data""" 33 | __tablename__ = "user_account_v2" 34 | 35 | id: Mapped[int] = mapped_column(primary_key=True) 36 | name: Mapped[str] = mapped_column(sa.String(30)) 37 | aliases = mapped_column(MutableList[str].as_mutable(JSONB), default=list) 38 | schedule = mapped_column(MutableList[List[str]].as_mutable(JSONB), default=list) 39 | 40 | 41 | @pytest.fixture(scope="module", autouse=True) 42 | def _with_tables(session): 43 | Base.metadata.create_all(session.bind) 44 | yield 45 | session.execute(sa.text(""" 46 | DROP TABLE user_account CASCADE; 47 | DROP TABLE user_account_v2 CASCADE; 48 | """)) 49 | session.commit() 50 | 51 | 52 | def test_mutable_list(session): 53 | session.add(u := User(name="foo", aliases=["bar", "baz"])) 54 | session.commit() 55 | 56 | assert isinstance(u.aliases, MutableList) 57 | 58 | u.aliases.append("qux") 59 | assert u.aliases == ["bar", "baz", "qux"] 60 | 61 | session.commit() 62 | assert u.aliases == ["bar", "baz", "qux"] 63 | 64 | 65 | def test_nested_mutable_list(session): 66 | session.add(u := User( 67 | name="foo", schedule=[["meeting", "launch"], ["training", "presentation"]] 68 | )) 69 | session.commit() 70 | 71 | assert isinstance(u.aliases, MutableList) 72 | assert u.schedule == [["meeting", "launch"], ["training", "presentation"]] 73 | 74 | # Mutation at top level 75 | u.schedule.append(["breakfast", "consulting"]) 76 | session.commit() 77 | assert u.schedule == [["meeting", "launch"], ["training", "presentation"], ["breakfast", "consulting"]] 78 | 79 | # Mutation at nested level 80 | u.schedule[0][0] = "breakfast" 81 | session.commit() 82 | assert u.schedule == [["breakfast", "launch"], ["training", "presentation"], ["breakfast", "consulting"]] 83 | 84 | u.schedule.pop() 85 | session.commit() 86 | assert u.schedule == [["breakfast", "launch"], ["training", "presentation"]] 87 | 88 | 89 | def test_mutable_list_stored_as_jsonb(session): 90 | session.add(u := UserV2(name="foo", aliases=["bar", "baz"])) 91 | session.commit() 92 | 93 | assert isinstance(u.aliases, MutableList) 94 | 95 | u.aliases.append("qux") 96 | assert u.aliases == ["bar", "baz", "qux"] 97 | 98 | session.commit() 99 | assert u.aliases == ["bar", "baz", "qux"] 100 | 101 | 102 | def test_nested_mutable_list_stored_as_jsonb(session): 103 | session.add(u := UserV2( 104 | name="foo", schedule=[["meeting", "launch"], ["training", "presentation"]] 105 | )) 106 | session.commit() 107 | 108 | assert isinstance(u.aliases, MutableList) 109 | assert u.schedule == [["meeting", "launch"], ["training", "presentation"]] 110 | 111 | # Mutation at top level 112 | u.schedule.append(["breakfast", "consulting"]) 113 | session.commit() 114 | assert u.schedule == [["meeting", "launch"], ["training", "presentation"], ["breakfast", "consulting"]] 115 | 116 | # Mutation at nested level 117 | u.schedule[0].insert(0, "breakfast") 118 | session.commit() 119 | assert u.schedule == [["breakfast", "meeting", "launch"], ["training", "presentation"], ["breakfast", "consulting"]] 120 | 121 | u.schedule.pop() 122 | session.commit() 123 | assert u.schedule == [["breakfast", "meeting", "launch"], ["training", "presentation"]] 124 | 125 | 126 | def test_mutable_list_mixed_with_dict(session): 127 | session.add(u := UserV2( 128 | name="foo", schedule=[ 129 | {"day": "mon", "events": ["meeting", "launch"]}, 130 | {"day": "tue", "events": ["training", "presentation"]}, 131 | ] 132 | )) 133 | session.commit() 134 | assert isinstance(u.schedule, MutableList) 135 | assert isinstance(u.schedule[0], TrackedDict) 136 | assert isinstance(u.schedule[0]["events"], TrackedList) 137 | 138 | u.schedule[0]["events"].insert(0, "breakfast") 139 | session.commit() 140 | assert u.schedule[0] == {"day": "mon", "events": ["breakfast", "meeting", "launch"]} 141 | -------------------------------------------------------------------------------- /tests/test_mutable_pydantic_type.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | import pytest 4 | from sqlalchemy_nested_mutable._compat import pydantic 5 | import sqlalchemy as sa 6 | from sqlalchemy.orm import ( 7 | DeclarativeBase, 8 | Mapped, 9 | mapped_column, 10 | ) 11 | 12 | from sqlalchemy_nested_mutable import ( 13 | MutablePydanticBaseModel, 14 | TrackedPydanticBaseModel, 15 | TrackedList, 16 | ) 17 | 18 | 19 | class Base(DeclarativeBase): 20 | pass 21 | 22 | 23 | class Addresses(MutablePydanticBaseModel): 24 | class AddressItem(pydantic.BaseModel): 25 | street: str 26 | city: str 27 | area: Optional[str] 28 | 29 | preferred: Optional[AddressItem] 30 | work: List[AddressItem] = [] 31 | home: List[AddressItem] = [] 32 | updated_time: Optional[str] 33 | 34 | 35 | class User(Base): 36 | __tablename__ = "user_account" 37 | 38 | id: Mapped[int] = mapped_column(primary_key=True) 39 | name: Mapped[str] = mapped_column(sa.String(30)) 40 | addresses: Mapped[Addresses] = mapped_column(Addresses.as_mutable(), nullable=True) 41 | 42 | 43 | @pytest.fixture(scope="module", autouse=True) 44 | def _with_tables(session): 45 | Base.metadata.create_all(session.bind) 46 | yield 47 | session.execute(sa.text(""" 48 | DROP TABLE user_account CASCADE; 49 | """)) 50 | session.commit() 51 | 52 | 53 | def test_mutable_pydantic_type(session): 54 | session.add(u := User(name="foo", addresses={"preferred": {"street": "bar", "city": "baz"}})) 55 | session.commit() 56 | 57 | assert isinstance(u.addresses, MutablePydanticBaseModel) 58 | assert isinstance(u.addresses.preferred, Addresses.AddressItem) 59 | assert isinstance(u.addresses.preferred, TrackedPydanticBaseModel) 60 | assert isinstance(u.addresses.home, TrackedList) 61 | assert type(u.addresses.preferred).__name__ == "TrackedAddressItem" 62 | 63 | # Shallow change 64 | u.addresses.updated_time = "2021-01-01T00:00:00" 65 | session.commit() 66 | assert u.addresses.updated_time == "2021-01-01T00:00:00" 67 | 68 | # Deep change 69 | u.addresses.preferred.street = "bar2" 70 | session.commit() 71 | assert u.addresses.preferred.dict(exclude_none=True) == {"street": "bar2", "city": "baz"} 72 | 73 | # Append item to list property 74 | u.addresses.home.append(Addresses.AddressItem.parse_obj({"street": "bar3", "city": "baz"})) 75 | assert isinstance(u.addresses.home[0], TrackedPydanticBaseModel) 76 | session.commit() 77 | assert u.addresses.home[0].dict(exclude_none=True) == {"street": "bar3", "city": "baz"} 78 | 79 | # Change item in list property 80 | u.addresses.home[0].street = "bar4" 81 | session.commit() 82 | assert u.addresses.home[0].dict(exclude_none=True) == {"street": "bar4", "city": "baz"} 83 | -------------------------------------------------------------------------------- /tests/test_sa_default_behaviour.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pytest 4 | import sqlalchemy as sa 5 | from sqlalchemy.orm import ( 6 | DeclarativeBase, 7 | Mapped, 8 | mapped_column, 9 | ) 10 | from sqlalchemy.dialects.postgresql import JSONB, ARRAY 11 | 12 | 13 | class Base(DeclarativeBase): 14 | pass 15 | 16 | 17 | class User(Base): 18 | __tablename__ = "user_account" 19 | 20 | id: Mapped[int] = mapped_column(primary_key=True) 21 | name: Mapped[str] = mapped_column(sa.String(30)) 22 | aliases: Mapped[List[str]] = mapped_column(ARRAY(sa.String(128))) 23 | addresses: Mapped[dict] = mapped_column(JSONB, default=dict) 24 | 25 | 26 | @pytest.fixture(scope="module", autouse=True) 27 | def _with_tables(session): 28 | Base.metadata.create_all(session.bind) 29 | yield 30 | session.execute(sa.text("DROP TABLE user_account CASCADE;")) 31 | session.commit() 32 | 33 | 34 | def test_sa_array_not_mutable(session): 35 | session.add(u := User(name="foo", aliases=["bar", "baz"])) 36 | session.commit() 37 | 38 | u.aliases.append("qux") 39 | assert u.aliases == ["bar", "baz", "qux"] 40 | 41 | session.commit() 42 | 43 | assert u.aliases == ["bar", "baz"] 44 | 45 | 46 | def test_sa_jsonb_not_mutable(session): 47 | session.add(u := User( 48 | name="bar", 49 | aliases=["baz", "qux"], 50 | addresses={ 51 | "home": "bar", 52 | "work": "baz", 53 | "email": "xyz@example.com" 54 | } 55 | )) 56 | session.commit() 57 | 58 | u.addresses["email"] = "abc@example.com" 59 | assert u.addresses["email"] == "abc@example.com" 60 | 61 | session.commit() 62 | 63 | assert u.addresses["email"] == "xyz@example.com" 64 | --------------------------------------------------------------------------------