├── .dockerignore ├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── MANIFEST ├── Makefile ├── README.md ├── docker-compose.yml ├── poetry.lock ├── pyproject.toml ├── sqlalchemy_serializer ├── __init__.py ├── lib │ ├── __init__.py │ ├── fields.py │ ├── schema.py │ └── serializable │ │ ├── __init__.py │ │ ├── base.py │ │ ├── bytes.py │ │ ├── date.py │ │ ├── datetime.py │ │ ├── decimal.py │ │ ├── enum.py │ │ ├── time.py │ │ └── uuid.py └── serializer.py └── tests ├── __init__.py ├── conftest.py ├── datatypes └── test_enum.py ├── models.py ├── test_custom_serializer.py ├── test_flat_model.py ├── test_get_property_field_names_function.py ├── test_get_serializable_keys_function.py ├── test_get_sql_field_names_function.py ├── test_nested_model.py ├── test_recursive_model.py ├── test_rules.py ├── test_schema.py ├── test_serialize_collection_function.py ├── test_serializer_fork_function.py ├── test_serializer_serialize_dict__function.py ├── test_serializer_serialize_model__function.py ├── test_serializer_set_serialization_depth_function.py ├── test_tree.py └── test_tzinfo.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .git -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: SQLalchemySerializer 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | env: 10 | POSTGRES_HOST: 'localhost' 11 | POSTGRES_DB: 'db_name' 12 | POSTGRES_USER: 'root' 13 | POSTGRES_PASSWORD: 'password' 14 | 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | python-version: ["3.10", "3.11", "3.12"] 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v3 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | python -m pip install poetry flake8 35 | poetry install --no-root --with dev 36 | 37 | - name: Lint with flake8 38 | run: | 39 | # stop the build if there are Python syntax errors or undefined names 40 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 41 | 42 | - name: Set up test database 43 | uses: harmon758/postgresql-action@v1 44 | with: 45 | postgresql version: '11' 46 | postgresql db: $POSTGRES_DB 47 | postgresql user: $POSTGRES_USER 48 | postgresql password: $POSTGRES_PASSWORD 49 | 50 | - name: Run tests 51 | run: | 52 | poetry run pytest --pylama 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | env 3 | 4 | .idea 5 | __MACOSX 6 | .DS_Store 7 | 8 | *.pyc 9 | tests/.cache 10 | __pycache__ 11 | .cache 12 | 13 | *.egg-info 14 | *.retry 15 | .coverage 16 | 17 | Pipfile 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.14-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY pyproject.toml poetry.lock* /app/ 6 | 7 | RUN apk update && \ 8 | apk add build-base postgresql-dev && \ 9 | pip install --no-cache-dir --upgrade pip && \ 10 | pip install poetry && \ 11 | poetry install --no-root --with dev && \ 12 | apk del --purge build-base 13 | 14 | ADD . /app 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Yuri Boiko 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 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # Exclude the local virtual environment directory if it's named .venv 2 | prune .venv 3 | 4 | # Exclude dev/test tools 5 | prune .git 6 | prune pyproject.toml -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | FILE = $(file) 3 | 4 | test: 5 | TEST_FILE=$(FILE) docker-compose up --build --abort-on-container-exit 6 | 7 | format: 8 | black . 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SQLAlchemy-serializer 2 | Mixin for SQLAlchemy models serialization without pain. 3 | 4 | If you want to serialize SQLAlchemy model instances with only one line of code, 5 | and tools like `marshmallow` seems to be redundant and too complex for such a simple task, 6 | this mixin definitely suits you. 7 | 8 | **Contents** 9 | - [Installation](#Installation) 10 | - [Usage](#Usage) 11 | - [Advanced usage](#Advanced-usage) 12 | - [Custom formats](#Custom-formats) 13 | - [Custom types](#Custom-types) 14 | - [Timezones](#Timezones) 15 | - [Troubleshooting](#Troubleshooting) 16 | - [Tests](#Tests) 17 | 18 | ## Installation 19 | 20 | ```bash 21 | pip install SQLAlchemy-serializer 22 | ``` 23 | 24 | ## Usage 25 | 26 | If you want SQLAlchemy model to become serializable, 27 | add **SerializerMixin** in class definition: 28 | ```python 29 | from sqlalchemy_serializer import SerializerMixin 30 | 31 | 32 | class SomeModel(db.Model, SerializerMixin): 33 | ... 34 | ``` 35 | 36 | This mixin adds **.to_dict()** method to model instances. 37 | So now you can do something like this: 38 | ```python 39 | item = SomeModel.query.filter(...).one() 40 | result = item.to_dict() 41 | ``` 42 | You get values of all SQLAlchemy fields in the `result` var, even nested relationships 43 | In order to change the default output you shuld pass tuple of fieldnames as an argument 44 | 45 | - If you want to exclude or add some extra fields (not from database) 46 | You should pass `rules` argument 47 | - If you want to define the only fields to be presented in serializer's output 48 | use `only` argument 49 | 50 | If you want to exclude a few fields for this exact item: 51 | ```python 52 | result = item.to_dict(rules=('-somefield', '-some_relation.nested_one.another_nested_one')) 53 | ``` 54 | 55 | If you want to add a field which is not defined as an SQLAlchemy field: 56 | ```python 57 | class SomeModel(db.Model, SerializerMixin): 58 | non_sql_field = 123 59 | 60 | def method(self): 61 | return anything 62 | 63 | result = item.to_dict(rules=('non_sql_field', 'method')) 64 | ``` 65 | **Note** that method or a function should have no arguments except ***self***, 66 | in order to let serializer call it without hesitations. 67 | 68 | If you want to get exact fields: 69 | ```python 70 | 71 | result = item.to_dict(only=('non_sql_field', 'method', 'somefield')) 72 | ``` 73 | **Note** that if ***somefield*** is an SQLAlchemy instance, you get all it's 74 | serializable fields. So if you want to get only some of them, you should define it like below: 75 | ```python 76 | 77 | result = item.to_dict(only=('non_sql_field', 'method', 'somefield.id', 'somefield.etc')) 78 | ``` 79 | You can use negative rules in `only` param too. 80 | So `item.to_dict(only=('somefield', -'somefield.id'))` 81 | will return `somefiled` without `id`. See [Negative rules in ONLY section](#Negative-rules-in-ONLY-section) 82 | 83 | If you want to define schema for all instances of particular SQLAlchemy model, 84 | add serialize properties to model definition: 85 | ```python 86 | class SomeModel(db.Model, SerializerMixin): 87 | serialize_only = ('somefield.id',) 88 | serialize_rules = () 89 | ... 90 | somefield = db.relationship('AnotherModel') 91 | 92 | result = item.to_dict() 93 | ``` 94 | So the `result` in this case will be `{'somefield': [{'id': some_id}]}` 95 | ***serialize_only*** and ***serialize_rules*** work the same way as ***to_dict's*** arguments 96 | 97 | 98 | # Advanced usage 99 | For more examples see [tests](https://github.com/n0nSmoker/SQLAlchemy-serializer/tree/master/tests) 100 | 101 | ```python 102 | class FlatModel(db.Model, SerializerMixin): 103 | """ 104 | to_dict() of all instances of this model now returns only following two fields 105 | """ 106 | serialize_only = ('non_sqlalchemy_field', 'id') 107 | serialize_rules = () 108 | 109 | id = db.Column(db.Integer, primary_key=True) 110 | string = db.Column(db.String(256), default='Some string!') 111 | time = db.Column(db.DateTime, default=datetime.utcnow()) 112 | date = db.Column(db.Date, default=datetime.utcnow()) 113 | boolean = db.Column(db.Boolean, default=True) 114 | boolean2 = db.Column(db.Boolean, default=False) 115 | null = db.Column(db.String) 116 | non_sqlalchemy_dict = dict(qwerty=123) 117 | 118 | 119 | class ComplexModel(db.Model, SerializerMixin): 120 | """ 121 | Schema is not defined so 122 | we will get all SQLAlchemy attributes of the instance by default 123 | without `non_sqlalchemy_list` 124 | """ 125 | 126 | id = db.Column(db.Integer, primary_key=True) 127 | string = db.Column(db.String(256), default='Some string!') 128 | boolean = db.Column(db.Boolean, default=True) 129 | null = db.Column(db.String) 130 | flat_id = db.Column(db.ForeignKey('test_flat_model.id')) 131 | rel = db.relationship('FlatModel') 132 | non_sqlalchemy_list = [dict(a=12, b=10), dict(a=123, b=12)] 133 | 134 | item = ComplexModel.query.first() 135 | 136 | 137 | # Now by default the result looks like this: 138 | item.to_dict() 139 | 140 | dict( 141 | id=1, 142 | string='Some string!', 143 | boolean=True, 144 | null=None, 145 | flat_id=1, 146 | rel=[dict( 147 | id=1, 148 | non_sqlalchemy_dict=dict(qwerty=123) 149 | )] 150 | 151 | 152 | # Extend schema 153 | item.to_dict(rules=('-id', '-rel.id', 'rel.string', 'non_sqlalchemy_list')) 154 | 155 | dict( 156 | string='Some string!', 157 | boolean=True, 158 | null=None, 159 | flat_id=1, 160 | non_sqlalchemy_list=[dict(a=12, b=10), dict(a=123, b=12)], 161 | rel=dict( 162 | string='Some string!', 163 | non_sqlalchemy_dict=dict(qwerty=123) 164 | ) 165 | ) 166 | 167 | 168 | # Exclusive schema 169 | item.to_dict(only=('id', 'flat_id', 'rel.id', 'non_sqlalchemy_list.a')) 170 | 171 | dict( 172 | id=1, 173 | flat_id=1, 174 | non_sqlalchemy_list=[dict(a=12), dict(a=123)], 175 | rel=dict( 176 | id=1 177 | ) 178 | ) 179 | ``` 180 | # Recursive models and trees 181 | If your models have references to each other or you work with large trees 182 | you need to specify where the serialization should stop. 183 | ```python 184 | item.to_dict('-children.children') 185 | ``` 186 | In this case only the first level of `children` will be included 187 | See [Max recursion](#Max-recursion) 188 | 189 | # Custom formats 190 | If you want to change datetime/date/time/decimal format in one model you can specify it like below: 191 | ```python 192 | from sqlalchemy_serializer import SerializerMixin 193 | 194 | class SomeModel(db.Model, SerializerMixin): 195 | __tablename__ = 'custom_table_name' 196 | 197 | date_format = '%s' # Unixtimestamp (seconds) 198 | datetime_format = '%Y %b %d %H:%M:%S.%f' 199 | time_format = '%H:%M.%f' 200 | decimal_format = '{:0>10.3}' 201 | 202 | id = sa.Column(sa.Integer, primary_key=True) 203 | date = sa.Column(sa.Date) 204 | datetime = sa.Column(sa.DateTime) 205 | time = sa.Column(sa.Time) 206 | money = Decimal('12.123') # same result with sa.Float(asdecimal=True, ...) 207 | ``` 208 | 209 | If you want to change format in every model, you should write 210 | your own mixin class inherited from `SerializerMixin`: 211 | ```python 212 | from sqlalchemy_serializer import SerializerMixin 213 | 214 | class CustomSerializerMixin(SerializerMixin): 215 | date_format = '%s' # Unixtimestamp (seconds) 216 | datetime_format = '%Y %b %d %H:%M:%S.%f' 217 | time_format = '%H:%M.%f' 218 | decimal_format = '{:0>10.3}' 219 | ``` 220 | And later use it as usual: 221 | ```python 222 | from decimal import Decimal 223 | import sqlalchemy as sa 224 | from some.lib.package import CustomSerializerMixin 225 | 226 | 227 | class CustomSerializerModel(db.Model, CustomSerializerMixin): 228 | __tablename__ = 'custom_table_name' 229 | 230 | id = sa.Column(sa.Integer, primary_key=True) 231 | date = sa.Column(sa.Date) 232 | datetime = sa.Column(sa.DateTime) 233 | time = sa.Column(sa.Time) 234 | money = Decimal('12.123') # same result with sa.Float(asdecimal=True, ...) 235 | 236 | ``` 237 | All `date/time/datetime/decimal` fields will be serialized using your custom formats. 238 | 239 | - Decimal uses python `format` syntax 240 | - To get **unixtimestamp** use `%s`, 241 | - Other `datetime` formats you can find [in docs](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior) 242 | 243 | 244 | # Custom types 245 | By default the library can serialize the following types: 246 | ``` 247 | - int 248 | - str 249 | - float 250 | - bytes 251 | - bool 252 | - type(None) 253 | - uuid.UUID 254 | - time 255 | - datetime 256 | - date 257 | - Decimal 258 | - Enum 259 | - dict (if values and keys are one of types mentioned above, or inherit one of them) 260 | - any Iterable (if types of values are mentioned above, or inherit one of them) 261 | ``` 262 | If you want to add serialization of any other type or redefine the default behaviour. 263 | You should add something like this: 264 | 265 | ```python 266 | 267 | serialize_types = ( 268 | (SomeType, lambda x: some_expression), 269 | (AnyOtherType, some_function) 270 | ) 271 | ``` 272 | To your own mixin class inherited from `SerializerMixin`: 273 | 274 | ```python 275 | from sqlalchemy_serializer import SerializerMixin 276 | from geoalchemy2.elements import WKBElement 277 | from geoalchemy2.shape import to_shape 278 | 279 | def serialize_int(value): 280 | return value + 100 281 | 282 | class CustomSerializerMixin(SerializerMixin): 283 | serialize_types = ( 284 | (WKBElement, lambda x: to_shape(x).to_wkt()), 285 | (int, serialize_int) 286 | ) 287 | ``` 288 | ... or directly to the model: 289 | ```python 290 | from geoalchemy2 import Geometry 291 | from sqlalchemy_serializer import SerializerMixin 292 | 293 | class Point(Base, SerializerMixin): 294 | serialize_types = ( 295 | (WKBElement, lambda x: to_shape(x).to_wkt()), 296 | (AnyOtherType, serialize_smth) 297 | ) 298 | __tablename__ = 'point' 299 | id = Column(Integer, primary_key=True) 300 | position = Column(Geometry('POINT')) 301 | ``` 302 | 303 | Unfortunately you can not access formats or tzinfo in that functions. 304 | I'll implement this logic later if any of users needs it. 305 | 306 | 307 | # Timezones 308 | To keep `datetimes` consistent its better to store it in the database normalized to **UTC**. 309 | But when you return response, sometimes (mostly in web, mobile applications can do it themselves) 310 | you need to convert all `datetimes` to user's timezone. 311 | So you need to tell serializer what timezone to use. 312 | There are two ways to do it: 313 | - The simplest one is to pass timezone directly as an argument for `to_dict` function 314 | ```python 315 | import pytz 316 | 317 | item.to_dict(timezone=pytz.timezone('Europe/Moscow')) 318 | ``` 319 | - But if you do not want to write this code in every function, you should define 320 | timezone logic in your custom mixin (how to use customized mixin see [Castomization](#Castomization)) 321 | ```python 322 | import pytz 323 | from sqlalchemy_serializer import SerializerMixin 324 | from some.package import get_current_user 325 | 326 | class CustomSerializerMixin(SerializerMixin): 327 | def get_tzinfo(self): 328 | # you can write your own logic here, 329 | # the example below will work if you store timezone 330 | # in user's profile 331 | return pytz.timezone(get_current_user()['timezone']) 332 | ``` 333 | # Helpers 334 | ## serialize_collection 335 | If you want to do the following in one line 336 | ```python 337 | categories = Category.query.all() 338 | response = [category.to_dict(**some_params) for category in categories] 339 | ``` 340 | use helper 341 | ```python 342 | from sqlalchemy_serializer import serialize_collection 343 | 344 | response = serialize_collection(Category.query.all(), **some_params) 345 | 346 | ``` 347 | # Troubleshooting 348 | 349 | ## Max recursion 350 | If you've faced with **maximum recursion depth exceeded** exception, 351 | most likely the serializer have found instance of the same class somewhere among model's relationships. 352 | Especially if you use backrefs. In this case you need to tell it where to stop like below: 353 | ```python 354 | class User(Base, SerializerMixin): 355 | __tablename__ = 'users' 356 | 357 | # Exclude nested model of the same class to avoid max recursion error 358 | serialize_rules = ('-related_models.user',) 359 | ... 360 | related_models = relationship("RelatedModel", backref='user') 361 | 362 | 363 | class RelatedModel(Base, SerializerMixin): 364 | __tablename__ = 'some_table' 365 | 366 | ... 367 | user_id = Column(Integer, ForeignKey('users.id')) 368 | ... 369 | ``` 370 | If for some reason you need the field `user` to be presented in `related_models` field. 371 | You can change `serialize_rules` to `('-related_models.user.related_models',)` 372 | To break the chain of serialisation a bit further. 373 | [Recursive models and trees](#Recursive-models-and-trees) 374 | 375 | ## Controversial rules 376 | If you add controversial rules like `serialize_rules = ('-prop', 'prop.id')` 377 | The serializer will include `prop` in spite of `-prop` rule. 378 | 379 | ## Negative rules in ONLY section 380 | If you pass rules in `serialize_only` the serializer becomes **NOT** greedy and returns **ONLY** fields listed there. 381 | So `serialize_only = ('-model.id',)` will return nothing 382 | But `serialize_only = ('model', '-model.id',)` will return `model` field without `id` 383 | 384 | ## One element tuples 385 | Do not forget to add **comma** at the end of one element tuples, it is trivial, 386 | but a lot of developers forget about it: 387 | ```python 388 | serialize_only = ('some_field',) # <--- Thats right! 389 | serialize_only = ('some_field') # <--- WRONG it is actually not a tuple 390 | 391 | ``` 392 | 393 | # Tests 394 | To run tests and see tests coverage report just type the following command:(doker and doker-compose should be installed on you local machine) 395 | ```bash 396 | make test 397 | ``` 398 | To run a particular test use 399 | ```bash 400 | make test file=tests/some_file.py 401 | make test file=tests/some_file.py::test_func 402 | ``` 403 | 404 | I will appreciate any help in improving this library, so feel free to submit issues or pull requests. 405 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | x-environment: &environment 3 | environment: 4 | - POSTGRES_HOST=db 5 | - POSTGRES_DB=db_name 6 | - POSTGRES_USER=root 7 | - POSTGRES_PASSWORD=password 8 | 9 | services: 10 | tests: 11 | build: . 12 | command: 13 | [ 14 | "poetry", 15 | "run", 16 | "pytest", 17 | "-vv", 18 | "--pylama", 19 | "--cov=sqlalchemy_serializer", 20 | "--cov-report", 21 | "term-missing", 22 | "$TEST_FILE" 23 | ] 24 | 25 | depends_on: 26 | - db 27 | <<: *environment 28 | 29 | db: 30 | image: postgres:latest 31 | <<: *environment 32 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "black" 5 | version = "24.4.2" 6 | description = "The uncompromising code formatter." 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, 11 | {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, 12 | {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, 13 | {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, 14 | {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, 15 | {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, 16 | {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, 17 | {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, 18 | {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, 19 | {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, 20 | {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, 21 | {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, 22 | {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, 23 | {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, 24 | {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, 25 | {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, 26 | {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, 27 | {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, 28 | {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, 29 | {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, 30 | {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, 31 | {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, 32 | ] 33 | 34 | [package.dependencies] 35 | click = ">=8.0.0" 36 | mypy-extensions = ">=0.4.3" 37 | packaging = ">=22.0" 38 | pathspec = ">=0.9.0" 39 | platformdirs = ">=2" 40 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 41 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 42 | 43 | [package.extras] 44 | colorama = ["colorama (>=0.4.3)"] 45 | d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] 46 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 47 | uvloop = ["uvloop (>=0.15.2)"] 48 | 49 | [[package]] 50 | name = "certifi" 51 | version = "2024.7.4" 52 | description = "Python package for providing Mozilla's CA Bundle." 53 | optional = false 54 | python-versions = ">=3.6" 55 | files = [ 56 | {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, 57 | {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, 58 | ] 59 | 60 | [[package]] 61 | name = "charset-normalizer" 62 | version = "3.3.2" 63 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 64 | optional = false 65 | python-versions = ">=3.7.0" 66 | files = [ 67 | {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, 68 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, 69 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, 70 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, 71 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, 72 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, 73 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, 74 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, 75 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, 76 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, 77 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, 78 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, 79 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, 80 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, 81 | {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, 82 | {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, 83 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, 84 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, 85 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, 86 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, 87 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, 88 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, 89 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, 90 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, 91 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, 92 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, 93 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, 94 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, 95 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, 96 | {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, 97 | {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, 98 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, 99 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, 100 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, 101 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, 102 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, 103 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, 104 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, 105 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, 106 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, 107 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, 108 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, 109 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, 110 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, 111 | {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, 112 | {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, 113 | {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, 114 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, 115 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, 116 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, 117 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, 118 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, 119 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, 120 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, 121 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, 122 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, 123 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, 124 | {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, 125 | {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, 126 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, 127 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, 128 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, 129 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, 130 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, 131 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, 132 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, 133 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, 134 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, 135 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, 136 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, 137 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, 138 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, 139 | {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, 140 | {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, 141 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, 142 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, 143 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, 144 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, 145 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, 146 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, 147 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, 148 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, 149 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, 150 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, 151 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, 152 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, 153 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, 154 | {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, 155 | {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, 156 | {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, 157 | ] 158 | 159 | [[package]] 160 | name = "click" 161 | version = "8.1.7" 162 | description = "Composable command line interface toolkit" 163 | optional = false 164 | python-versions = ">=3.7" 165 | files = [ 166 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 167 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 168 | ] 169 | 170 | [package.dependencies] 171 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 172 | 173 | [[package]] 174 | name = "colorama" 175 | version = "0.4.6" 176 | description = "Cross-platform colored terminal text." 177 | optional = false 178 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 179 | files = [ 180 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 181 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 182 | ] 183 | 184 | [[package]] 185 | name = "coverage" 186 | version = "7.5.4" 187 | description = "Code coverage measurement for Python" 188 | optional = false 189 | python-versions = ">=3.8" 190 | files = [ 191 | {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"}, 192 | {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"}, 193 | {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"}, 194 | {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"}, 195 | {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"}, 196 | {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"}, 197 | {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"}, 198 | {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"}, 199 | {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"}, 200 | {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"}, 201 | {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"}, 202 | {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"}, 203 | {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"}, 204 | {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"}, 205 | {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"}, 206 | {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"}, 207 | {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"}, 208 | {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"}, 209 | {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"}, 210 | {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"}, 211 | {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"}, 212 | {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"}, 213 | {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"}, 214 | {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"}, 215 | {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"}, 216 | {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"}, 217 | {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"}, 218 | {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"}, 219 | {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"}, 220 | {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"}, 221 | {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"}, 222 | {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"}, 223 | {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"}, 224 | {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"}, 225 | {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"}, 226 | {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"}, 227 | {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"}, 228 | {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"}, 229 | {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"}, 230 | {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"}, 231 | {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"}, 232 | {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"}, 233 | {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"}, 234 | {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"}, 235 | {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"}, 236 | {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"}, 237 | {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"}, 238 | {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"}, 239 | {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"}, 240 | {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"}, 241 | {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"}, 242 | {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"}, 243 | ] 244 | 245 | [package.dependencies] 246 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 247 | 248 | [package.extras] 249 | toml = ["tomli"] 250 | 251 | [[package]] 252 | name = "exceptiongroup" 253 | version = "1.2.1" 254 | description = "Backport of PEP 654 (exception groups)" 255 | optional = false 256 | python-versions = ">=3.7" 257 | files = [ 258 | {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, 259 | {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, 260 | ] 261 | 262 | [package.extras] 263 | test = ["pytest (>=6)"] 264 | 265 | [[package]] 266 | name = "greenlet" 267 | version = "3.0.3" 268 | description = "Lightweight in-process concurrent programming" 269 | optional = false 270 | python-versions = ">=3.7" 271 | files = [ 272 | {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"}, 273 | {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"}, 274 | {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"}, 275 | {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"}, 276 | {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"}, 277 | {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"}, 278 | {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"}, 279 | {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"}, 280 | {file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"}, 281 | {file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"}, 282 | {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"}, 283 | {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"}, 284 | {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"}, 285 | {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"}, 286 | {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"}, 287 | {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"}, 288 | {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"}, 289 | {file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"}, 290 | {file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"}, 291 | {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"}, 292 | {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"}, 293 | {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"}, 294 | {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"}, 295 | {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"}, 296 | {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"}, 297 | {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"}, 298 | {file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"}, 299 | {file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"}, 300 | {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"}, 301 | {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"}, 302 | {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"}, 303 | {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"}, 304 | {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"}, 305 | {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"}, 306 | {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"}, 307 | {file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"}, 308 | {file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"}, 309 | {file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"}, 310 | {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"}, 311 | {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"}, 312 | {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"}, 313 | {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"}, 314 | {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"}, 315 | {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"}, 316 | {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"}, 317 | {file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"}, 318 | {file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"}, 319 | {file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"}, 320 | {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"}, 321 | {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"}, 322 | {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"}, 323 | {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"}, 324 | {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"}, 325 | {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"}, 326 | {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"}, 327 | {file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"}, 328 | {file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"}, 329 | {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"}, 330 | ] 331 | 332 | [package.extras] 333 | docs = ["Sphinx", "furo"] 334 | test = ["objgraph", "psutil"] 335 | 336 | [[package]] 337 | name = "idna" 338 | version = "3.7" 339 | description = "Internationalized Domain Names in Applications (IDNA)" 340 | optional = false 341 | python-versions = ">=3.5" 342 | files = [ 343 | {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, 344 | {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, 345 | ] 346 | 347 | [[package]] 348 | name = "iniconfig" 349 | version = "2.0.0" 350 | description = "brain-dead simple config-ini parsing" 351 | optional = false 352 | python-versions = ">=3.7" 353 | files = [ 354 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 355 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 356 | ] 357 | 358 | [[package]] 359 | name = "mccabe" 360 | version = "0.7.0" 361 | description = "McCabe checker, plugin for flake8" 362 | optional = false 363 | python-versions = ">=3.6" 364 | files = [ 365 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 366 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 367 | ] 368 | 369 | [[package]] 370 | name = "mypy-extensions" 371 | version = "1.0.0" 372 | description = "Type system extensions for programs checked with the mypy type checker." 373 | optional = false 374 | python-versions = ">=3.5" 375 | files = [ 376 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 377 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 378 | ] 379 | 380 | [[package]] 381 | name = "packaging" 382 | version = "24.1" 383 | description = "Core utilities for Python packages" 384 | optional = false 385 | python-versions = ">=3.8" 386 | files = [ 387 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 388 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 389 | ] 390 | 391 | [[package]] 392 | name = "pathspec" 393 | version = "0.12.1" 394 | description = "Utility library for gitignore style pattern matching of file paths." 395 | optional = false 396 | python-versions = ">=3.8" 397 | files = [ 398 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 399 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 400 | ] 401 | 402 | [[package]] 403 | name = "platformdirs" 404 | version = "4.2.2" 405 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 406 | optional = false 407 | python-versions = ">=3.8" 408 | files = [ 409 | {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, 410 | {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, 411 | ] 412 | 413 | [package.extras] 414 | docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] 415 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] 416 | type = ["mypy (>=1.8)"] 417 | 418 | [[package]] 419 | name = "pluggy" 420 | version = "1.5.0" 421 | description = "plugin and hook calling mechanisms for python" 422 | optional = false 423 | python-versions = ">=3.8" 424 | files = [ 425 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 426 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 427 | ] 428 | 429 | [package.extras] 430 | dev = ["pre-commit", "tox"] 431 | testing = ["pytest", "pytest-benchmark"] 432 | 433 | [[package]] 434 | name = "psycopg2-binary" 435 | version = "2.9.9" 436 | description = "psycopg2 - Python-PostgreSQL Database Adapter" 437 | optional = false 438 | python-versions = ">=3.7" 439 | files = [ 440 | {file = "psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c"}, 441 | {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202"}, 442 | {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7"}, 443 | {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b"}, 444 | {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9"}, 445 | {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84"}, 446 | {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e"}, 447 | {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98"}, 448 | {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245"}, 449 | {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e"}, 450 | {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f"}, 451 | {file = "psycopg2_binary-2.9.9-cp310-cp310-win32.whl", hash = "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682"}, 452 | {file = "psycopg2_binary-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0"}, 453 | {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26"}, 454 | {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f"}, 455 | {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2"}, 456 | {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0"}, 457 | {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53"}, 458 | {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be"}, 459 | {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27"}, 460 | {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359"}, 461 | {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2"}, 462 | {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc"}, 463 | {file = "psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d"}, 464 | {file = "psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417"}, 465 | {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf"}, 466 | {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d"}, 467 | {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212"}, 468 | {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493"}, 469 | {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996"}, 470 | {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119"}, 471 | {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba"}, 472 | {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07"}, 473 | {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb"}, 474 | {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe"}, 475 | {file = "psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93"}, 476 | {file = "psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab"}, 477 | {file = "psycopg2_binary-2.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a"}, 478 | {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9"}, 479 | {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77"}, 480 | {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984"}, 481 | {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503"}, 482 | {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52"}, 483 | {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae"}, 484 | {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420"}, 485 | {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041"}, 486 | {file = "psycopg2_binary-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692"}, 487 | {file = "psycopg2_binary-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f"}, 488 | {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980"}, 489 | {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6"}, 490 | {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94"}, 491 | {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152"}, 492 | {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55"}, 493 | {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972"}, 494 | {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd"}, 495 | {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59"}, 496 | {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3"}, 497 | {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716"}, 498 | {file = "psycopg2_binary-2.9.9-cp38-cp38-win32.whl", hash = "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5"}, 499 | {file = "psycopg2_binary-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7"}, 500 | {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472"}, 501 | {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f"}, 502 | {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e"}, 503 | {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67"}, 504 | {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876"}, 505 | {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7"}, 506 | {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291"}, 507 | {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f"}, 508 | {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1"}, 509 | {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860"}, 510 | {file = "psycopg2_binary-2.9.9-cp39-cp39-win32.whl", hash = "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90"}, 511 | {file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"}, 512 | ] 513 | 514 | [[package]] 515 | name = "pycodestyle" 516 | version = "2.12.0" 517 | description = "Python style guide checker" 518 | optional = false 519 | python-versions = ">=3.8" 520 | files = [ 521 | {file = "pycodestyle-2.12.0-py2.py3-none-any.whl", hash = "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4"}, 522 | {file = "pycodestyle-2.12.0.tar.gz", hash = "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c"}, 523 | ] 524 | 525 | [[package]] 526 | name = "pydocstyle" 527 | version = "6.3.0" 528 | description = "Python docstring style checker" 529 | optional = false 530 | python-versions = ">=3.6" 531 | files = [ 532 | {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, 533 | {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, 534 | ] 535 | 536 | [package.dependencies] 537 | snowballstemmer = ">=2.2.0" 538 | 539 | [package.extras] 540 | toml = ["tomli (>=1.2.3)"] 541 | 542 | [[package]] 543 | name = "pyflakes" 544 | version = "3.2.0" 545 | description = "passive checker of Python programs" 546 | optional = false 547 | python-versions = ">=3.8" 548 | files = [ 549 | {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, 550 | {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, 551 | ] 552 | 553 | [[package]] 554 | name = "pylama" 555 | version = "8.4.1" 556 | description = "Code audit tool for python" 557 | optional = false 558 | python-versions = ">=3.7" 559 | files = [ 560 | {file = "pylama-8.4.1-py3-none-any.whl", hash = "sha256:5bbdbf5b620aba7206d688ed9fc917ecd3d73e15ec1a89647037a09fa3a86e60"}, 561 | {file = "pylama-8.4.1.tar.gz", hash = "sha256:2d4f7aecfb5b7466216d48610c7d6bad1c3990c29cdd392ad08259b161e486f6"}, 562 | ] 563 | 564 | [package.dependencies] 565 | mccabe = ">=0.7.0" 566 | pycodestyle = ">=2.9.1" 567 | pydocstyle = ">=6.1.1" 568 | pyflakes = ">=2.5.0" 569 | 570 | [package.extras] 571 | all = ["eradicate", "mypy", "pylint", "radon", "vulture"] 572 | eradicate = ["eradicate"] 573 | mypy = ["mypy"] 574 | pylint = ["pylint"] 575 | radon = ["radon"] 576 | tests = ["eradicate (>=2.0.0)", "mypy", "pylama-quotes", "pylint (>=2.11.1)", "pytest (>=7.1.2)", "pytest-mypy", "radon (>=5.1.0)", "toml", "types-setuptools", "types-toml", "vulture"] 577 | toml = ["toml (>=0.10.2)"] 578 | vulture = ["vulture"] 579 | 580 | [[package]] 581 | name = "pytest" 582 | version = "8.1.1" 583 | description = "pytest: simple powerful testing with Python" 584 | optional = false 585 | python-versions = ">=3.8" 586 | files = [ 587 | {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, 588 | {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, 589 | ] 590 | 591 | [package.dependencies] 592 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 593 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 594 | iniconfig = "*" 595 | packaging = "*" 596 | pluggy = ">=1.4,<2.0" 597 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 598 | 599 | [package.extras] 600 | testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 601 | 602 | [[package]] 603 | name = "pytest-cov" 604 | version = "5.0.0" 605 | description = "Pytest plugin for measuring coverage." 606 | optional = false 607 | python-versions = ">=3.8" 608 | files = [ 609 | {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, 610 | {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, 611 | ] 612 | 613 | [package.dependencies] 614 | coverage = {version = ">=5.2.1", extras = ["toml"]} 615 | pytest = ">=4.6" 616 | 617 | [package.extras] 618 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] 619 | 620 | [[package]] 621 | name = "pytest-mock" 622 | version = "3.14.0" 623 | description = "Thin-wrapper around the mock package for easier use with pytest" 624 | optional = false 625 | python-versions = ">=3.8" 626 | files = [ 627 | {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, 628 | {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, 629 | ] 630 | 631 | [package.dependencies] 632 | pytest = ">=6.2.5" 633 | 634 | [package.extras] 635 | dev = ["pre-commit", "pytest-asyncio", "tox"] 636 | 637 | [[package]] 638 | name = "pytz" 639 | version = "2024.1" 640 | description = "World timezone definitions, modern and historical" 641 | optional = false 642 | python-versions = "*" 643 | files = [ 644 | {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, 645 | {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, 646 | ] 647 | 648 | [[package]] 649 | name = "requests" 650 | version = "2.32.3" 651 | description = "Python HTTP for Humans." 652 | optional = false 653 | python-versions = ">=3.8" 654 | files = [ 655 | {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, 656 | {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, 657 | ] 658 | 659 | [package.dependencies] 660 | certifi = ">=2017.4.17" 661 | charset-normalizer = ">=2,<4" 662 | idna = ">=2.5,<4" 663 | urllib3 = ">=1.21.1,<3" 664 | 665 | [package.extras] 666 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 667 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 668 | 669 | [[package]] 670 | name = "setuptools" 671 | version = "70.2.0" 672 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 673 | optional = false 674 | python-versions = ">=3.8" 675 | files = [ 676 | {file = "setuptools-70.2.0-py3-none-any.whl", hash = "sha256:b8b8060bb426838fbe942479c90296ce976249451118ef566a5a0b7d8b78fb05"}, 677 | {file = "setuptools-70.2.0.tar.gz", hash = "sha256:bd63e505105011b25c3c11f753f7e3b8465ea739efddaccef8f0efac2137bac1"}, 678 | ] 679 | 680 | [package.extras] 681 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 682 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 683 | 684 | [[package]] 685 | name = "snowballstemmer" 686 | version = "2.2.0" 687 | description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." 688 | optional = false 689 | python-versions = "*" 690 | files = [ 691 | {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, 692 | {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, 693 | ] 694 | 695 | [[package]] 696 | name = "sqlalchemy" 697 | version = "2.0.29" 698 | description = "Database Abstraction Library" 699 | optional = false 700 | python-versions = ">=3.7" 701 | files = [ 702 | {file = "SQLAlchemy-2.0.29-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c142852ae192e9fe5aad5c350ea6befe9db14370b34047e1f0f7cf99e63c63b"}, 703 | {file = "SQLAlchemy-2.0.29-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:99a1e69d4e26f71e750e9ad6fdc8614fbddb67cfe2173a3628a2566034e223c7"}, 704 | {file = "SQLAlchemy-2.0.29-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ef3fbccb4058355053c51b82fd3501a6e13dd808c8d8cd2561e610c5456013c"}, 705 | {file = "SQLAlchemy-2.0.29-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d6753305936eddc8ed190e006b7bb33a8f50b9854823485eed3a886857ab8d1"}, 706 | {file = "SQLAlchemy-2.0.29-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0f3ca96af060a5250a8ad5a63699180bc780c2edf8abf96c58af175921df847a"}, 707 | {file = "SQLAlchemy-2.0.29-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c4520047006b1d3f0d89e0532978c0688219857eb2fee7c48052560ae76aca1e"}, 708 | {file = "SQLAlchemy-2.0.29-cp310-cp310-win32.whl", hash = "sha256:b2a0e3cf0caac2085ff172c3faacd1e00c376e6884b5bc4dd5b6b84623e29e4f"}, 709 | {file = "SQLAlchemy-2.0.29-cp310-cp310-win_amd64.whl", hash = "sha256:01d10638a37460616708062a40c7b55f73e4d35eaa146781c683e0fa7f6c43fb"}, 710 | {file = "SQLAlchemy-2.0.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:308ef9cb41d099099fffc9d35781638986870b29f744382904bf9c7dadd08513"}, 711 | {file = "SQLAlchemy-2.0.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:296195df68326a48385e7a96e877bc19aa210e485fa381c5246bc0234c36c78e"}, 712 | {file = "SQLAlchemy-2.0.29-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a13b917b4ffe5a0a31b83d051d60477819ddf18276852ea68037a144a506efb9"}, 713 | {file = "SQLAlchemy-2.0.29-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f6d971255d9ddbd3189e2e79d743ff4845c07f0633adfd1de3f63d930dbe673"}, 714 | {file = "SQLAlchemy-2.0.29-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:61405ea2d563407d316c63a7b5271ae5d274a2a9fbcd01b0aa5503635699fa1e"}, 715 | {file = "SQLAlchemy-2.0.29-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de7202ffe4d4a8c1e3cde1c03e01c1a3772c92858837e8f3879b497158e4cb44"}, 716 | {file = "SQLAlchemy-2.0.29-cp311-cp311-win32.whl", hash = "sha256:b5d7ed79df55a731749ce65ec20d666d82b185fa4898430b17cb90c892741520"}, 717 | {file = "SQLAlchemy-2.0.29-cp311-cp311-win_amd64.whl", hash = "sha256:205f5a2b39d7c380cbc3b5dcc8f2762fb5bcb716838e2d26ccbc54330775b003"}, 718 | {file = "SQLAlchemy-2.0.29-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d96710d834a6fb31e21381c6d7b76ec729bd08c75a25a5184b1089141356171f"}, 719 | {file = "SQLAlchemy-2.0.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52de4736404e53c5c6a91ef2698c01e52333988ebdc218f14c833237a0804f1b"}, 720 | {file = "SQLAlchemy-2.0.29-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c7b02525ede2a164c5fa5014915ba3591730f2cc831f5be9ff3b7fd3e30958e"}, 721 | {file = "SQLAlchemy-2.0.29-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dfefdb3e54cd15f5d56fd5ae32f1da2d95d78319c1f6dfb9bcd0eb15d603d5d"}, 722 | {file = "SQLAlchemy-2.0.29-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a88913000da9205b13f6f195f0813b6ffd8a0c0c2bd58d499e00a30eb508870c"}, 723 | {file = "SQLAlchemy-2.0.29-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fecd5089c4be1bcc37c35e9aa678938d2888845a134dd016de457b942cf5a758"}, 724 | {file = "SQLAlchemy-2.0.29-cp312-cp312-win32.whl", hash = "sha256:8197d6f7a3d2b468861ebb4c9f998b9df9e358d6e1cf9c2a01061cb9b6cf4e41"}, 725 | {file = "SQLAlchemy-2.0.29-cp312-cp312-win_amd64.whl", hash = "sha256:9b19836ccca0d321e237560e475fd99c3d8655d03da80c845c4da20dda31b6e1"}, 726 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:87a1d53a5382cdbbf4b7619f107cc862c1b0a4feb29000922db72e5a66a5ffc0"}, 727 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a0732dffe32333211801b28339d2a0babc1971bc90a983e3035e7b0d6f06b93"}, 728 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90453597a753322d6aa770c5935887ab1fc49cc4c4fdd436901308383d698b4b"}, 729 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ea311d4ee9a8fa67f139c088ae9f905fcf0277d6cd75c310a21a88bf85e130f5"}, 730 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5f20cb0a63a3e0ec4e169aa8890e32b949c8145983afa13a708bc4b0a1f30e03"}, 731 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-win32.whl", hash = "sha256:e5bbe55e8552019c6463709b39634a5fc55e080d0827e2a3a11e18eb73f5cdbd"}, 732 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-win_amd64.whl", hash = "sha256:c2f9c762a2735600654c654bf48dad388b888f8ce387b095806480e6e4ff6907"}, 733 | {file = "SQLAlchemy-2.0.29-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e614d7a25a43a9f54fcce4675c12761b248547f3d41b195e8010ca7297c369c"}, 734 | {file = "SQLAlchemy-2.0.29-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:471fcb39c6adf37f820350c28aac4a7df9d3940c6548b624a642852e727ea586"}, 735 | {file = "SQLAlchemy-2.0.29-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:988569c8732f54ad3234cf9c561364221a9e943b78dc7a4aaf35ccc2265f1930"}, 736 | {file = "SQLAlchemy-2.0.29-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dddaae9b81c88083e6437de95c41e86823d150f4ee94bf24e158a4526cbead01"}, 737 | {file = "SQLAlchemy-2.0.29-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:334184d1ab8f4c87f9652b048af3f7abea1c809dfe526fb0435348a6fef3d380"}, 738 | {file = "SQLAlchemy-2.0.29-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:38b624e5cf02a69b113c8047cf7f66b5dfe4a2ca07ff8b8716da4f1b3ae81567"}, 739 | {file = "SQLAlchemy-2.0.29-cp38-cp38-win32.whl", hash = "sha256:bab41acf151cd68bc2b466deae5deeb9e8ae9c50ad113444151ad965d5bf685b"}, 740 | {file = "SQLAlchemy-2.0.29-cp38-cp38-win_amd64.whl", hash = "sha256:52c8011088305476691b8750c60e03b87910a123cfd9ad48576d6414b6ec2a1d"}, 741 | {file = "SQLAlchemy-2.0.29-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3071ad498896907a5ef756206b9dc750f8e57352113c19272bdfdc429c7bd7de"}, 742 | {file = "SQLAlchemy-2.0.29-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dba622396a3170974f81bad49aacebd243455ec3cc70615aeaef9e9613b5bca5"}, 743 | {file = "SQLAlchemy-2.0.29-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b184e3de58009cc0bf32e20f137f1ec75a32470f5fede06c58f6c355ed42a72"}, 744 | {file = "SQLAlchemy-2.0.29-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c37f1050feb91f3d6c32f864d8e114ff5545a4a7afe56778d76a9aec62638ba"}, 745 | {file = "SQLAlchemy-2.0.29-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bda7ce59b06d0f09afe22c56714c65c957b1068dee3d5e74d743edec7daba552"}, 746 | {file = "SQLAlchemy-2.0.29-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:25664e18bef6dc45015b08f99c63952a53a0a61f61f2e48a9e70cec27e55f699"}, 747 | {file = "SQLAlchemy-2.0.29-cp39-cp39-win32.whl", hash = "sha256:77d29cb6c34b14af8a484e831ab530c0f7188f8efed1c6a833a2c674bf3c26ec"}, 748 | {file = "SQLAlchemy-2.0.29-cp39-cp39-win_amd64.whl", hash = "sha256:04c487305ab035a9548f573763915189fc0fe0824d9ba28433196f8436f1449c"}, 749 | {file = "SQLAlchemy-2.0.29-py3-none-any.whl", hash = "sha256:dc4ee2d4ee43251905f88637d5281a8d52e916a021384ec10758826f5cbae305"}, 750 | {file = "SQLAlchemy-2.0.29.tar.gz", hash = "sha256:bd9566b8e58cabd700bc367b60e90d9349cd16f0984973f98a9a09f9c64e86f0"}, 751 | ] 752 | 753 | [package.dependencies] 754 | 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\""} 755 | typing-extensions = ">=4.6.0" 756 | 757 | [package.extras] 758 | aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] 759 | aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] 760 | aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] 761 | asyncio = ["greenlet (!=0.4.17)"] 762 | asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] 763 | mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] 764 | mssql = ["pyodbc"] 765 | mssql-pymssql = ["pymssql"] 766 | mssql-pyodbc = ["pyodbc"] 767 | mypy = ["mypy (>=0.910)"] 768 | mysql = ["mysqlclient (>=1.4.0)"] 769 | mysql-connector = ["mysql-connector-python"] 770 | oracle = ["cx_oracle (>=8)"] 771 | oracle-oracledb = ["oracledb (>=1.0.1)"] 772 | postgresql = ["psycopg2 (>=2.7)"] 773 | postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] 774 | postgresql-pg8000 = ["pg8000 (>=1.29.1)"] 775 | postgresql-psycopg = ["psycopg (>=3.0.7)"] 776 | postgresql-psycopg2binary = ["psycopg2-binary"] 777 | postgresql-psycopg2cffi = ["psycopg2cffi"] 778 | postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] 779 | pymysql = ["pymysql"] 780 | sqlcipher = ["sqlcipher3_binary"] 781 | 782 | [[package]] 783 | name = "tomli" 784 | version = "2.0.1" 785 | description = "A lil' TOML parser" 786 | optional = false 787 | python-versions = ">=3.7" 788 | files = [ 789 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 790 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 791 | ] 792 | 793 | [[package]] 794 | name = "typing-extensions" 795 | version = "4.12.2" 796 | description = "Backported and Experimental Type Hints for Python 3.8+" 797 | optional = false 798 | python-versions = ">=3.8" 799 | files = [ 800 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 801 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 802 | ] 803 | 804 | [[package]] 805 | name = "urllib3" 806 | version = "2.2.2" 807 | description = "HTTP library with thread-safe connection pooling, file post, and more." 808 | optional = false 809 | python-versions = ">=3.8" 810 | files = [ 811 | {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, 812 | {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, 813 | ] 814 | 815 | [package.extras] 816 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 817 | h2 = ["h2 (>=4,<5)"] 818 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 819 | zstd = ["zstandard (>=0.18.0)"] 820 | 821 | [metadata] 822 | lock-version = "2.0" 823 | python-versions = "^3.10" 824 | content-hash = "eafb9fae930671e1108344f8acf63710a3e5735e3e459a7c0693eb2f40639f30" 825 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "sqlalchemy-serializer" 3 | version = "1.4.22" 4 | description = "Mixin for SQLAlchemy models serialization without pain" 5 | authors = ["yuri.boiko "] 6 | license = "MIT" 7 | readme = "README.md" 8 | include = [ 9 | "README.md", 10 | "LICENSE", 11 | "sqlalchemy_serializer/**" 12 | ] 13 | classifiers = [ 14 | "Programming Language :: Python :: 3", 15 | "License :: OSI Approved :: Apache Software License", 16 | "Operating System :: OS Independent", 17 | ] 18 | keywords = ["sqlalchemy", "serialize", "to_dict", "JSON"] 19 | repository = "https://github.com/n0nSmoker/SQLAlchemy-serializer" 20 | 21 | [tool.poetry.dependencies] 22 | python = "^3.10" 23 | SQLAlchemy = "2.0.29" 24 | psycopg2-binary = "2.9.9" 25 | pytz = "^2024.1" 26 | setuptools = "^70.1.1" 27 | 28 | [tool.poetry.group.dev.dependencies] 29 | pytest = "8.1.1" 30 | pytest-cov = "5.0.0" 31 | pylama = "8.4.1" 32 | requests = "2.32.3" 33 | black = "^24.4.2" 34 | pytest-mock = "^3.14.0" 35 | 36 | [build-system] 37 | requires = ["poetry-core"] 38 | build-backend = "poetry.core.masonry.api" 39 | 40 | [tool.pytest.ini_options] 41 | addopts = "-xvrs --color=yes" 42 | log_cli = true 43 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["SerializerMixin", "Serializer", "serialize_collection"] 2 | 3 | from sqlalchemy_serializer.serializer import ( 4 | SerializerMixin, 5 | Serializer, 6 | serialize_collection, 7 | ) 8 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0nSmoker/SQLAlchemy-serializer/826dce26947b4dcab980eb45aa7628b131678c3b/sqlalchemy_serializer/lib/__init__.py -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/fields.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import typing as t 3 | import inspect 4 | from sqlalchemy import inspect as sql_inspect 5 | 6 | 7 | def get_sql_field_names(model_instance) -> t.Set[str]: 8 | """ 9 | :return: set of sql fields names 10 | :raise: sqlalchemy.exc.NoInspectionAvailable 11 | """ 12 | inspector = sql_inspect(model_instance) 13 | return {a.key for a in inspector.mapper.attrs} 14 | 15 | 16 | def get_property_field_names(model_instance) -> t.Set[str]: 17 | """ 18 | :return: set of field names defined as @property 19 | """ 20 | cls = model_instance.__class__ 21 | return { 22 | name for name, member in inspect.getmembers(cls) if isinstance(member, property) 23 | } 24 | 25 | 26 | @functools.lru_cache 27 | def get_serializable_keys(model_instance) -> t.Set[str]: 28 | """ 29 | :return: set of keys available for serialization 30 | :raise: sqlalchemy.exc.NoInspectionAvailable if model_instance is not an sqlalchemy mapper 31 | """ 32 | if model_instance.serializable_keys: 33 | result = set(model_instance.serializable_keys) 34 | 35 | else: 36 | result = get_sql_field_names(model_instance) 37 | 38 | if model_instance.auto_serialize_properties: 39 | result.update(get_property_field_names(model_instance)) 40 | 41 | return result 42 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/schema.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | import typing as t 4 | 5 | 6 | logger = logging.getLogger("serializer") 7 | 8 | 9 | class Tree(defaultdict): 10 | def __init__( 11 | self, to_include=None, to_exclude=None, is_greedy=True, *args, **kwargs 12 | ): 13 | super(Tree, self).__init__(*args, **kwargs) 14 | self.default_factory = Tree 15 | self.to_include = to_include 16 | self.to_exclude = to_exclude 17 | self.is_greedy = is_greedy 18 | 19 | def apply(self, node: "Tree"): 20 | if self.is_greedy and not node.is_greedy: 21 | # Apply strictness to all subtrees 22 | # not included into the node 23 | for k, subtree in self.items(): 24 | if k not in node.keys() and subtree: 25 | subtree.to_strict() 26 | 27 | self.is_greedy = node.is_greedy and self.is_greedy 28 | 29 | if node.to_include is not None: 30 | self.to_include = node.to_include 31 | 32 | if node.to_exclude is not None: 33 | self.to_exclude = node.to_exclude 34 | 35 | def to_strict(self): 36 | self.is_greedy = False 37 | for tree in self.values(): 38 | if not tree: 39 | continue # Exclude leafs 40 | tree.to_strict() 41 | 42 | def __repr__(self): 43 | include = f"to_include={self.to_include}" 44 | exclude = f"to_exclude={self.to_exclude}" 45 | greedy = f"is_greedy={self.is_greedy}" 46 | keys = "\n".join(f"{k}: {v}".replace("\n", "\n ") for k, v in self.items()) 47 | keys = f"\n{keys}" if keys else "" 48 | return f"Tree({include}, {exclude}, {greedy})[{keys}]" 49 | 50 | 51 | class Rule: 52 | DELIM = "." # Delimiter to separate nested rules 53 | NEGATION = "-" # Prefix for negative rules 54 | 55 | def __init__(self, rule: str): 56 | self.is_negative = rule.startswith(self.NEGATION) 57 | rule = rule.replace(self.NEGATION, "") 58 | self.keys = rule.split(self.DELIM) 59 | 60 | def __repr__(self): 61 | prefix = self.NEGATION if self.is_negative else "" 62 | return f"{prefix}{self.DELIM.join(self.keys)}" 63 | 64 | 65 | class Schema: 66 | def __init__(self, tree: t.Optional[Tree] = None): 67 | self._tree = tree or Tree() 68 | 69 | @property 70 | def keys(self) -> set: 71 | return {k for k, t in self._tree.items() if t.to_include} 72 | 73 | @property 74 | def is_greedy(self) -> bool: 75 | return self._tree.is_greedy 76 | 77 | def update(self, extend=(), only=()): 78 | if extend: 79 | self.apply(rules=extend, is_greedy=True) 80 | if only: 81 | self.apply(rules=only, is_greedy=False) 82 | 83 | def apply(self, rules, is_greedy): 84 | rules_tree = Tree() 85 | for raw in rules: 86 | logger.debug("Checking rule:%s", raw) 87 | rule = Rule(raw) 88 | 89 | current = self._tree 90 | chain = Tree() 91 | chain.is_greedy = is_greedy 92 | 93 | keys_num = len(rule.keys) 94 | new = chain 95 | for i, k in enumerate(rule.keys): 96 | is_last_key = keys_num == i + 1 97 | parent = current 98 | node = current.get(k, Tree()) # Does not create a new node 99 | new = new[k] # Creates a new node 100 | 101 | if not (is_last_key or rule.is_negative) and node.is_greedy: 102 | new.is_greedy = is_greedy 103 | 104 | if not node and node.to_exclude: 105 | logger.debug("Ignore rule:%s leaf excludes key:%s", raw, k) 106 | break 107 | 108 | if rule.is_negative: 109 | new.to_exclude = True 110 | else: 111 | new.to_include = True 112 | 113 | if is_last_key: 114 | if not parent.is_greedy: 115 | logger.debug( 116 | "Ignore rule:%s parent does not accept new rules", raw 117 | ) 118 | elif rule.is_negative and node.to_include: 119 | logger.debug("Ignore rule:%s leaf includes key:%s", raw, k) 120 | else: 121 | merge_trees(rules_tree, chain) 122 | else: 123 | current = node # Go deeper 124 | 125 | if rules_tree: 126 | logger.debug("Updating tree with rules:%s is_greedy:%s", rules, is_greedy) 127 | merge_trees(self._tree, rules_tree) 128 | 129 | def is_included(self, key: str) -> bool: 130 | node = self._tree.get(key) 131 | if self._tree.is_greedy: 132 | if not node: 133 | return node is None or not node.to_exclude 134 | return True 135 | else: 136 | return bool(node is not None and node.to_include) 137 | 138 | def fork(self, key: str) -> "Schema": 139 | return Schema(tree=self._tree[key]) 140 | 141 | 142 | def merge_trees(old: Tree, *trees): 143 | for tree in trees: 144 | old.apply(tree) 145 | for k in tree.keys(): 146 | merge_trees(old[k], tree[k]) 147 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/serializable/__init__.py: -------------------------------------------------------------------------------- 1 | from .bytes import Bytes 2 | from .date import Date 3 | from .datetime import DateTime 4 | from .time import Time 5 | from .uuid import UUID 6 | from .decimal import Decimal 7 | from .enum import Enum 8 | 9 | 10 | __all__ = ["Bytes", "Date", "DateTime", "Decimal", "Time", "UUID", "Enum"] 11 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/serializable/base.py: -------------------------------------------------------------------------------- 1 | class Base: 2 | def __call__(self, value) -> str: 3 | raise NotImplementedError( 4 | f"Method should implement serialization logic for{value}" 5 | ) 6 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/serializable/bytes.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | 3 | 4 | class Bytes(Base): 5 | def __call__(self, value: bytes) -> str: 6 | return value.decode() 7 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/serializable/date.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from .datetime import format_dt 3 | from .base import Base 4 | 5 | 6 | class Date(Base): 7 | def __init__(self, str_format: str = "%Y-%m-%d") -> None: 8 | self.str_format = str_format 9 | 10 | def __call__(self, value: datetime.date) -> str: 11 | return format_dt(tpl=self.str_format, dt=value) 12 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/serializable/datetime.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from .base import Base 3 | 4 | 5 | class DateTime(Base): 6 | def __init__(self, str_format: str = "%H:%M:%S", tzinfo=None) -> None: 7 | self.tzinfo = tzinfo 8 | self.str_format = str_format 9 | 10 | def __call__(self, value: datetime) -> str: 11 | if self.tzinfo: 12 | value = to_local_time(dt=value, tzinfo=self.tzinfo) 13 | 14 | return format_dt(tpl=self.str_format, dt=value) 15 | 16 | 17 | def to_local_time(dt: datetime, tzinfo) -> datetime: 18 | normalized = dt.astimezone(tzinfo) 19 | return normalized.replace(tzinfo=None) 20 | 21 | 22 | def format_dt(dt, tpl=None) -> str: 23 | if not tpl: 24 | return dt.isoformat() 25 | return dt.strftime(tpl) 26 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/serializable/decimal.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | from .base import Base 3 | 4 | 5 | class Decimal(Base): 6 | def __init__(self, str_format: str = "%H:%M:%S") -> None: 7 | self.str_format = str_format 8 | 9 | def __call__(self, value: decimal.Decimal) -> str: 10 | return self.str_format.format(value) 11 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/serializable/enum.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from .base import Base 3 | 4 | 5 | class Enum(Base): 6 | def __call__(self, value: enum.Enum) -> str: 7 | return value.value 8 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/serializable/time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from .datetime import format_dt 3 | from .base import Base 4 | 5 | 6 | class Time(Base): 7 | def __init__(self, str_format: str = "%H:%M:%S") -> None: 8 | self.str_format = str_format 9 | 10 | def __call__(self, value: datetime.time): 11 | return format_dt(tpl=self.str_format, dt=value) 12 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/lib/serializable/uuid.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from .base import Base 3 | 4 | 5 | class UUID(Base): 6 | def __call__(self, value: uuid.UUID): 7 | return str(value) 8 | -------------------------------------------------------------------------------- /sqlalchemy_serializer/serializer.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime, date, time 3 | from decimal import Decimal 4 | from enum import Enum 5 | import logging 6 | import inspect 7 | from collections import namedtuple 8 | from collections.abc import Iterable 9 | from types import MethodType 10 | import typing as t 11 | 12 | from sqlalchemy_serializer.lib.fields import get_serializable_keys 13 | 14 | from .lib.schema import Schema 15 | from .lib import serializable 16 | 17 | 18 | logger = logging.getLogger("serializer") 19 | logger.setLevel(level="WARN") 20 | 21 | 22 | class SerializerMixin: 23 | """ 24 | Mixin for retrieving public fields of sqlAlchemy-model in json-compatible format 25 | with no pain 26 | It can be inherited to redefine get_tzinfo callback, datetime formats or to add 27 | some extra serialization logic 28 | """ 29 | 30 | # Default exclusive schema. 31 | # If left blank, serializer becomes greedy and takes all SQLAlchemy-model's attributes 32 | serialize_only: tuple = () 33 | 34 | # Additions to default schema. Can include negative rules 35 | serialize_rules: tuple = () 36 | 37 | # Extra serialising functions 38 | serialize_types: tuple = () 39 | 40 | # Custom list of fields to serialize in this model 41 | serializable_keys: tuple = () 42 | 43 | date_format = "%Y-%m-%d" 44 | datetime_format = "%Y-%m-%d %H:%M:%S" 45 | time_format = "%H:%M" 46 | decimal_format = "{}" 47 | 48 | # Serialize fields of the model defined as @property automatically 49 | auto_serialize_properties: bool = False 50 | 51 | def get_tzinfo(self): 52 | """ 53 | Callback to make serializer aware of user's timezone. Should be redefined if needed 54 | Example: 55 | return pytz.timezone('Africa/Abidjan') 56 | 57 | :return: datetime.tzinfo 58 | """ 59 | return None 60 | 61 | def to_dict( 62 | self, 63 | only=(), 64 | rules=(), 65 | date_format=None, 66 | datetime_format=None, 67 | time_format=None, 68 | tzinfo=None, 69 | decimal_format=None, 70 | serialize_types=None, 71 | ): 72 | """ 73 | Returns SQLAlchemy model's data in JSON compatible format 74 | 75 | For details about datetime formats follow: 76 | https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior 77 | 78 | :param only: exclusive schema to replace the default one 79 | always have higher priority than rules 80 | :param rules: schema to extend default one or schema defined in "only" 81 | :param date_format: str 82 | :param datetime_format: str 83 | :param time_format: str 84 | :param decimal_format: str 85 | :param serialize_types: 86 | :param tzinfo: datetime.tzinfo converts datetimes to local user timezone 87 | :return: data: dict 88 | """ 89 | s = Serializer( 90 | date_format=date_format or self.date_format, 91 | datetime_format=datetime_format or self.datetime_format, 92 | time_format=time_format or self.time_format, 93 | decimal_format=decimal_format or self.decimal_format, 94 | tzinfo=tzinfo or self.get_tzinfo(), 95 | serialize_types=serialize_types or self.serialize_types, 96 | ) 97 | return s(self, only=only, extend=rules) 98 | 99 | 100 | Options = namedtuple( 101 | "Options", 102 | "date_format datetime_format time_format decimal_format tzinfo serialize_types", 103 | ) 104 | 105 | 106 | class Serializer: 107 | # Types that do nod need any serialization logic 108 | atomic_types = ( 109 | int, 110 | str, 111 | float, 112 | bool, 113 | type(None), 114 | ) 115 | 116 | def __init__(self, **kwargs): 117 | self.set_serialization_depth(0) 118 | self.set_options(Options(**kwargs)) 119 | self.init_callbacks() 120 | 121 | self.schema = Schema() 122 | 123 | def __call__(self, value, only=(), extend=()): 124 | """ 125 | Serialization starts here 126 | :param value: Value to serialize 127 | :param only: Exclusive schema of serialization 128 | :param extend: Rules that extend default schema 129 | :return: object: JSON-compatible object 130 | """ 131 | self.schema.update(only=only, extend=extend) 132 | 133 | logger.debug("Call serializer for type:%s", get_type(value)) 134 | return self.serialize(value) 135 | 136 | def set_serialization_depth(self, value: int): 137 | self.serialization_depth = value 138 | 139 | def set_options(self, opts: Options): 140 | self.opts = opts 141 | 142 | def init_callbacks(self): 143 | """Initialize callbacks""" 144 | self.serialize_types = ( 145 | *(self.opts.serialize_types or ()), 146 | (self.atomic_types, lambda x: x), # Should be checked before any other type 147 | (bytes, serializable.Bytes()), 148 | (uuid.UUID, serializable.UUID()), 149 | ( 150 | time, # Should be checked before datetime 151 | serializable.Time(str_format=self.opts.time_format), 152 | ), 153 | ( 154 | datetime, 155 | serializable.DateTime( 156 | str_format=self.opts.datetime_format, tzinfo=self.opts.tzinfo 157 | ), 158 | ), 159 | (date, serializable.Date(str_format=self.opts.date_format)), 160 | (Decimal, serializable.Decimal(str_format=self.opts.decimal_format)), 161 | (dict, self.serialize_dict), # Should be checked before Iterable 162 | (Iterable, self.serialize_iter), 163 | (Enum, serializable.Enum()), 164 | (SerializerMixin, self.serialize_model), 165 | ) 166 | 167 | @staticmethod 168 | def is_valid_callable(func) -> bool: 169 | """ 170 | Determines objects that should be called before serialization 171 | """ 172 | if callable(func): 173 | i = inspect.getfullargspec(func) 174 | if ( 175 | i.args == ["self"] 176 | and isinstance(func, MethodType) 177 | and not any([i.varargs, i.varkw]) 178 | ): 179 | return True 180 | return not any([i.args, i.varargs, i.varkw]) 181 | return False 182 | 183 | def is_forkable(self, value): 184 | """ 185 | Determines if object should be processed in a separate serializer 186 | """ 187 | return not isinstance(value, str) and isinstance( 188 | value, (Iterable, dict, SerializerMixin) 189 | ) 190 | 191 | def fork(self, key: str) -> "Serializer": 192 | """ 193 | Return new serializer for a key 194 | :return: serializer 195 | """ 196 | serializer = Serializer(**self.opts._asdict()) 197 | serializer.set_serialization_depth(self.serialization_depth + 1) 198 | serializer.schema = self.schema.fork(key=key) 199 | 200 | logger.debug("Fork serializer for key:%s", key) 201 | return serializer 202 | 203 | def serialize(self, value, **kwargs): 204 | """ 205 | Orchestrates the serialization process. 206 | 207 | Args: 208 | value: The value to be serialized. 209 | **kwargs: Only to ensure that no key is passed 210 | since None and Ellipsis are valid keys. 211 | 212 | Returns: 213 | The serialized value. 214 | """ 215 | if self.is_valid_callable(value): 216 | value = value() 217 | logger.debug("Process callable resulting type:%s", get_type(value)) 218 | 219 | if kwargs: 220 | if "key" in kwargs: 221 | # since None and ... are valid keys 222 | return self.serialize_with_fork(value=value, key=kwargs["key"]) 223 | raise ValueError("Malformed structure of kwargs. Only `key` accepted") 224 | 225 | return self.apply_callback(value=value) 226 | 227 | def apply_callback(self, value): 228 | """ 229 | Apply a proper callback to serialize the value 230 | :return: serialized value 231 | :raises: IsNotSerializable 232 | """ 233 | for types, callback in self.serialize_types: 234 | if isinstance(value, types): 235 | return callback(value) 236 | raise IsNotSerializable(f"Unserializable type:{get_type(value)} value:{value}") 237 | 238 | def serialize_with_fork(self, value, key): 239 | serializer = self 240 | if self.is_forkable(value): 241 | serializer = self.fork(key=key) 242 | 243 | return serializer.apply_callback(value) 244 | 245 | def serialize_iter(self, value: Iterable) -> list: 246 | res = [] 247 | for v in value: 248 | try: 249 | r = self.serialize(v) 250 | except ( 251 | IsNotSerializable 252 | ): # FIXME: Why we swallow exception only in iterable? 253 | logger.warning("Can not serialize type:%s", get_type(v)) 254 | continue 255 | 256 | res.append(r) 257 | return res 258 | 259 | def serialize_dict(self, value: dict) -> dict: 260 | res = {} 261 | for k, v in value.items(): 262 | if self.schema.is_included(k): # TODO: Skip check if is NOT greedy 263 | logger.debug("Serialize key:%s type:%s of dict", k, get_type(v)) 264 | 265 | res[k] = self.serialize(value=v, key=k) 266 | else: 267 | logger.debug("Skip key:%s of dict", k) 268 | return res 269 | 270 | def serialize_model(self, value) -> dict: 271 | self.schema.update(only=value.serialize_only, extend=value.serialize_rules) 272 | 273 | res = {} 274 | keys = self.schema.keys 275 | if self.schema.is_greedy: 276 | keys.update(get_serializable_keys(value)) 277 | 278 | for k in keys: 279 | if self.schema.is_included(key=k): # TODO: Skip check if is NOT greedy 280 | v = getattr(value, k) 281 | logger.debug( 282 | "Serialize key:%s type:%s of model:%s", 283 | k, 284 | get_type(v), 285 | get_type(value), 286 | ) 287 | res[k] = self.serialize(value=v, key=k) 288 | 289 | else: 290 | logger.debug("Skip key:%s of model:%s", k, get_type(value)) 291 | return res 292 | 293 | 294 | class IsNotSerializable(Exception): 295 | pass 296 | 297 | 298 | def get_type(value) -> str: 299 | return type(value).__name__ 300 | 301 | 302 | def serialize_collection(iterable: t.Iterable, *args, **kwargs) -> list: 303 | return [item.to_dict(*args, **kwargs) for item in iterable] 304 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0nSmoker/SQLAlchemy-serializer/826dce26947b4dcab980eb45aa7628b131678c3b/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import logging 4 | 5 | from sqlalchemy import create_engine 6 | from sqlalchemy.orm import sessionmaker 7 | 8 | from sqlalchemy_serializer.serializer import Serializer 9 | from .models import Base 10 | 11 | 12 | logger = logging.getLogger("serializer") 13 | logger.setLevel(logging.DEBUG) 14 | 15 | 16 | DEFAULT_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" 17 | DEFAULT_DATE_FORMAT = "%Y-%m-%d" 18 | DEFAULT_TIME_FORMAT = "%H:%M" 19 | DEFAULT_DECIMAL_FORMAT = "{}" 20 | 21 | 22 | DB_HOST = os.environ.get("POSTGRES_HOST") 23 | DB_PORT = os.environ.get("POSTGRES_PORT", 5432) 24 | DB_NAME = os.environ.get("POSTGRES_DB") 25 | DB_USER = os.environ.get("POSTGRES_USER") 26 | DB_PASS = os.environ.get("POSTGRES_PASSWORD") 27 | 28 | 29 | @pytest.fixture(scope="session") 30 | def session(request): 31 | """Creates a new database session for a test.""" 32 | engine = create_engine( 33 | f"postgresql+psycopg2://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" 34 | ) 35 | 36 | maker = sessionmaker(bind=engine) 37 | session = maker() 38 | 39 | Base.metadata.drop_all( 40 | bind=engine 41 | ) # Flush DATABASE before use (in case of reused container) 42 | Base.metadata.create_all(bind=engine) 43 | 44 | def teardown(): 45 | session.close() 46 | 47 | request.addfinalizer(teardown) 48 | 49 | return session 50 | 51 | 52 | @pytest.fixture(scope="session") 53 | def get_instance(session): 54 | def func(model, **kwargs): 55 | instance = model(**kwargs) 56 | session.add(instance) 57 | session.commit() 58 | return instance 59 | 60 | return func 61 | 62 | 63 | @pytest.fixture() 64 | def get_serializer(): 65 | def func( 66 | date_format=DEFAULT_DATE_FORMAT, 67 | datetime_format=DEFAULT_DATETIME_FORMAT, 68 | time_format=DEFAULT_TIME_FORMAT, 69 | decimal_format=DEFAULT_DECIMAL_FORMAT, 70 | tzinfo=None, 71 | serialize_types=(), 72 | ): 73 | return Serializer( 74 | date_format=date_format, 75 | datetime_format=datetime_format, 76 | time_format=time_format, 77 | decimal_format=decimal_format, 78 | tzinfo=tzinfo, 79 | serialize_types=serialize_types, 80 | ) 81 | 82 | return func 83 | -------------------------------------------------------------------------------- /tests/datatypes/test_enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | import pytest 3 | 4 | 5 | @pytest.fixture 6 | def default_options(): 7 | return dict( 8 | date_format="%Y-%m-%d", 9 | datetime_format="%Y-%m-%d %H:%M:%S", 10 | time_format="%H:%M:%S", 11 | decimal_format="%.2f", 12 | tzinfo=None, 13 | serialize_types=None, 14 | ) 15 | 16 | 17 | class Numbers(Enum): 18 | FIRST = 1 19 | SECOND = 2 20 | THIRD = 3 21 | 22 | 23 | class Strings(Enum): 24 | RED = "red" 25 | GREEN = "green" 26 | BLUE = "blue" 27 | 28 | 29 | def test_enums(default_options, get_serializer): 30 | 31 | data = { 32 | "int_enum": Numbers.FIRST, 33 | "string_enum": Strings.RED, 34 | "list_enum": [Numbers.FIRST, Numbers.SECOND], 35 | } 36 | 37 | serializer = get_serializer(**default_options) 38 | result = serializer(data, only=["int_enum", "string_enum", "list_enum"]) 39 | 40 | assert result == { 41 | "int_enum": Numbers.FIRST.value, 42 | "string_enum": Strings.RED.value, 43 | "list_enum": [Numbers.FIRST.value, Numbers.SECOND.value], 44 | } 45 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | from uuid import uuid4 4 | 5 | import pytz 6 | 7 | import sqlalchemy as sa 8 | from sqlalchemy.dialects.postgresql import UUID 9 | from sqlalchemy.orm import declarative_base, relationship 10 | from sqlalchemy_serializer import SerializerMixin 11 | 12 | 13 | DATETIME = datetime( 14 | year=2018, month=1, day=1, hour=1, minute=1, second=1, microsecond=123 15 | ) 16 | DATE = DATETIME.date() 17 | TIME = DATETIME.time() 18 | 19 | MONEY = Decimal("12.123") 20 | 21 | Base = declarative_base() 22 | 23 | 24 | class FlatModel(Base, SerializerMixin): 25 | __tablename__ = "flat_model" 26 | serialize_only = () 27 | serialize_rules = () 28 | 29 | id = sa.Column(sa.Integer, primary_key=True) 30 | string = sa.Column(sa.String(256), default="Some string with") 31 | date = sa.Column(sa.Date, default=DATETIME) 32 | datetime = sa.Column(sa.DateTime, default=DATETIME) 33 | time = sa.Column(sa.Time, default=TIME) 34 | bool = sa.Column(sa.Boolean, default=True) 35 | null = sa.Column(sa.String) 36 | uuid = sa.Column(UUID(as_uuid=True), default=uuid4) 37 | list = [1, "test_string", 0.9, {"key": 123, "key2": 23423}, {"key": 234}] 38 | set = {1, 2, "test_string"} 39 | dict = dict(key=123, key2={"key": 12}) 40 | money = MONEY 41 | 42 | @property 43 | def prop(self): 44 | return "Some property" 45 | 46 | @property 47 | def prop_with_bytes(self): 48 | return b"Some bytes" 49 | 50 | def method(self): 51 | return f"User defined method + {self.string}" 52 | 53 | def _protected_method(self): 54 | return f"User defined protected method + {self.string}" 55 | 56 | 57 | class NestedModel(Base, SerializerMixin): 58 | __tablename__ = "nested_model" 59 | serialize_only = () 60 | serialize_rules = () 61 | 62 | id = sa.Column(sa.Integer, primary_key=True) 63 | string = sa.Column(sa.String(256), default="(NESTED)Some string with") 64 | date = sa.Column(sa.Date, default=DATETIME) 65 | datetime = sa.Column(sa.DateTime, default=DATETIME) 66 | time = sa.Column(sa.Time, default=TIME) 67 | bool = sa.Column(sa.Boolean, default=False) 68 | null = sa.Column(sa.String) 69 | list = [1, "(NESTED)test_string", 0.9, {"key": 123}] 70 | set = {1, 2, "(NESTED)test_string"} 71 | dict = dict(key=123) 72 | 73 | model_id = sa.Column(sa.Integer, sa.ForeignKey("flat_model.id")) 74 | model = relationship("FlatModel") 75 | 76 | @property 77 | def prop(self): 78 | return "(NESTED)Some property" 79 | 80 | def method(self): 81 | return f"(NESTED)User defined method + {self.string}" 82 | 83 | def _protected_method(self): 84 | return f"(NESTED)User defined protected method + {self.string}" 85 | 86 | 87 | class RecursiveModel(Base, SerializerMixin): 88 | __tablename__ = "recursive_model" 89 | serialize_only = () 90 | 91 | id = sa.Column(sa.Integer, primary_key=True) 92 | name = sa.Column(sa.String(256), default="some name") 93 | parent_id = sa.Column(sa.Integer, sa.ForeignKey("recursive_model.id")) 94 | children = relationship("RecursiveModel") 95 | 96 | 97 | # Custom serializer 98 | CUSTOM_TZINFO = pytz.timezone("Asia/Krasnoyarsk") 99 | CUSTOM_DATE_FORMAT = "%s" # Unixtimestamp (seconds) 100 | CUSTOM_DATE_TIME_FORMAT = "%Y %b %d %H:%M:%S.%f" 101 | CUSTOM_TIME_FORMAT = "%H:%M.%f" 102 | CUSTOM_DECIMAL_FORMAT = "{:0>10.3}" 103 | CUSTOM_STR_VALUE = "Test custom type serializer" 104 | 105 | 106 | class CustomSerializerMixin(SerializerMixin): 107 | date_format = CUSTOM_DATE_FORMAT 108 | datetime_format = CUSTOM_DATE_TIME_FORMAT 109 | time_format = CUSTOM_TIME_FORMAT 110 | decimal_format = CUSTOM_DECIMAL_FORMAT 111 | 112 | serialize_types = ((str, lambda _: CUSTOM_STR_VALUE),) 113 | 114 | def get_tzinfo(self): 115 | return CUSTOM_TZINFO 116 | 117 | 118 | class CustomSerializerModel(Base, CustomSerializerMixin): 119 | __tablename__ = "custom_flat_model" 120 | serialize_only = () 121 | serialize_rules = ("money",) # include non SQL decimal field to test format 122 | 123 | id = sa.Column(sa.Integer, primary_key=True) 124 | string = sa.Column(sa.String(256), default="Some string with") 125 | date = sa.Column(sa.Date, default=DATETIME) 126 | datetime = sa.Column(sa.DateTime, default=DATETIME) 127 | time = sa.Column(sa.Time, default=TIME) 128 | bool = sa.Column(sa.Boolean, default=True) 129 | money = MONEY 130 | -------------------------------------------------------------------------------- /tests/test_custom_serializer.py: -------------------------------------------------------------------------------- 1 | from .models import ( 2 | CustomSerializerModel, 3 | DATETIME, 4 | TIME, 5 | DATE, 6 | MONEY, 7 | CUSTOM_TZINFO, 8 | CUSTOM_DATE_FORMAT, 9 | CUSTOM_TIME_FORMAT, 10 | CUSTOM_DATE_TIME_FORMAT, 11 | CUSTOM_DECIMAL_FORMAT, 12 | CUSTOM_STR_VALUE, 13 | ) 14 | 15 | 16 | def test_tzinfo_set_in_serializer(get_instance): 17 | """ 18 | Checks how serializer applies tzinfo for datetime objects 19 | """ 20 | i = get_instance(CustomSerializerModel) 21 | data = i.to_dict() 22 | 23 | # Check time/date formats 24 | assert "date" in data 25 | assert data["date"] == DATE.strftime(CUSTOM_DATE_FORMAT) 26 | assert "datetime" in data 27 | assert "time" in data 28 | assert data["time"] == TIME.strftime(CUSTOM_TIME_FORMAT) 29 | 30 | assert "money" in data 31 | assert data["money"] == CUSTOM_DECIMAL_FORMAT.format(MONEY) 32 | 33 | # Timezone info affects only datetime objects 34 | assert data["datetime"] == DATETIME.astimezone(CUSTOM_TZINFO).strftime( 35 | CUSTOM_DATE_TIME_FORMAT 36 | ) 37 | 38 | # Check other fields 39 | assert "id" in data 40 | assert "string" in data 41 | assert "bool" in data 42 | 43 | 44 | def test_add_custom_serialization_types(get_instance): 45 | """ 46 | Checks custom type serializers 47 | """ 48 | i = get_instance(CustomSerializerModel) 49 | data = i.to_dict() 50 | 51 | assert "string" in data 52 | assert data["string"] == CUSTOM_STR_VALUE 53 | assert "id" in data 54 | assert data["id"] == i.id 55 | 56 | # Redefine serializer 57 | CustomSerializerModel.serialize_types = ( 58 | (str, lambda _: "New value"), 59 | (int, lambda x: x + 1), 60 | ) 61 | i = get_instance(CustomSerializerModel) 62 | data = i.to_dict() 63 | 64 | assert "string" in data 65 | assert data["string"] == "New value" 66 | assert "id" in data 67 | assert data["id"] == i.id + 1 68 | -------------------------------------------------------------------------------- /tests/test_flat_model.py: -------------------------------------------------------------------------------- 1 | from .models import FlatModel, DATETIME, TIME, DATE, MONEY 2 | 3 | 4 | def test_no_defaults_no_rules(get_instance): 5 | """ 6 | Checks to_dict method of flat model with no predefined options 7 | """ 8 | i = get_instance(FlatModel) 9 | data = i.to_dict() 10 | 11 | # Check SQLAlchemy fields 12 | assert "id" in data 13 | assert "string" in data and data["string"] == i.string 14 | assert "date" in data 15 | assert "time" in data 16 | assert "datetime" in data 17 | assert "bool" in data and data["bool"] == i.bool 18 | assert "null" in data and data["null"] is None 19 | assert "uuid" in data and str(i.uuid) == data["uuid"] 20 | 21 | # Check non-sql fields (not included in this case, need to be defined explicitly) 22 | assert "list" not in data 23 | assert "set" not in data 24 | assert "dict" not in data 25 | assert "prop" not in data 26 | assert "method" not in data 27 | assert "_protected_method" not in data 28 | assert "money" not in data 29 | 30 | 31 | def test_no_defaults_no_rules_with_auto_serialize_properties(get_instance): 32 | """ 33 | Checks to_dict method of flat model with no predefined options 34 | but with automatic serialization of @properties 35 | """ 36 | 37 | class AutoPropFlatModel(FlatModel): 38 | auto_serialize_properties = True 39 | 40 | i = get_instance(AutoPropFlatModel) 41 | data = i.to_dict() 42 | 43 | # Check SQLAlchemy fields 44 | assert "id" in data 45 | assert "string" in data and data["string"] == i.string 46 | assert "date" in data 47 | assert "time" in data 48 | assert "datetime" in data 49 | assert "bool" in data and data["bool"] == i.bool 50 | assert "null" in data and data["null"] is None 51 | assert "uuid" in data and str(i.uuid) == data["uuid"] 52 | 53 | # Properties 54 | assert "prop" in data 55 | assert "prop_with_bytes" in data 56 | 57 | # Check non-sql fields 58 | assert "list" not in data 59 | assert "set" not in data 60 | assert "dict" not in data 61 | assert "method" not in data 62 | assert "_protected_method" not in data 63 | assert "money" not in data 64 | 65 | 66 | def test_default_formats(get_instance): 67 | """ 68 | Check date/datetime/time/decimal 69 | default formats in resulting JSON of flat model with no predefined options 70 | """ 71 | i = get_instance(FlatModel) 72 | 73 | # Default formats 74 | d_format = i.date_format 75 | dt_format = i.datetime_format 76 | t_format = i.time_format 77 | decimal_format = i.decimal_format 78 | 79 | # Include non-SQL field to check decimal_format and bytes 80 | data = i.to_dict(rules=("money", "prop_with_bytes")) 81 | 82 | assert "date" in data 83 | assert data["date"] == DATE.strftime(d_format) 84 | assert "datetime" in data 85 | assert data["datetime"] == DATETIME.strftime(dt_format) 86 | assert "time" in data 87 | assert data["time"] == TIME.strftime(t_format) 88 | 89 | assert "money" in data 90 | assert data["money"] == decimal_format.format(MONEY) 91 | 92 | assert "prop_with_bytes" in data 93 | assert data["prop_with_bytes"] == i.prop_with_bytes.decode() 94 | 95 | 96 | def test_formats_got_in_runtime(get_instance): 97 | """ 98 | Check date/datetime/time/decimal 99 | default formats in resulting JSON passed as the parameters of to_dict func 100 | """ 101 | d_format = "%Y/%m/%d" 102 | dt_format = "%Y/%m/%d %H:%M" 103 | t_format = ">%H<" 104 | decimal_format = "{:.3}" 105 | 106 | i = get_instance(FlatModel) 107 | 108 | # Check that default formats are different 109 | assert d_format != i.date_format 110 | assert dt_format != i.datetime_format 111 | assert t_format != i.time_format 112 | assert decimal_format != i.decimal_format 113 | 114 | data = i.to_dict( 115 | date_format=d_format, 116 | datetime_format=dt_format, 117 | time_format=t_format, 118 | decimal_format=decimal_format, 119 | rules=("money",), # Include non-SQL field to check decimal_format 120 | ) 121 | 122 | # Check serialized formats 123 | assert "date" in data 124 | assert data["date"] == DATE.strftime(d_format) 125 | assert "datetime" in data 126 | assert data["datetime"] == DATETIME.strftime(dt_format) 127 | assert "time" in data 128 | assert data["time"] == TIME.strftime(t_format) 129 | assert "money" in data 130 | assert data["money"] == decimal_format.format(MONEY) 131 | 132 | # Check if we got ISO date/time if there is no format at all 133 | i = get_instance(FlatModel) 134 | 135 | i.date_format = None 136 | i.datetime_format = None 137 | i.time_format = None 138 | 139 | data = i.to_dict() 140 | 141 | assert "date" in data 142 | assert data["date"] == DATE.isoformat() 143 | assert "datetime" in data 144 | assert data["datetime"] == DATETIME.isoformat() 145 | assert "time" in data 146 | assert data["time"] == TIME.isoformat() 147 | 148 | 149 | def test_default_only_param(get_instance): 150 | i = get_instance(FlatModel) 151 | i.serialize_only = ("id", "string", "datetime", "_protected_method", "prop") 152 | data = i.to_dict() 153 | 154 | assert "id" in data 155 | assert data["id"] == i.id 156 | assert "string" in data 157 | assert data["string"] == i.string 158 | assert "datetime" in data # No need to check formatted value 159 | assert "_protected_method" in data 160 | assert data["_protected_method"] == i._protected_method() 161 | assert "prop" in data 162 | assert data["prop"] == i.prop 163 | # Check if there is no other keys 164 | assert len(data.keys()) == 5 165 | 166 | 167 | def test_default_rules_param(get_instance): 168 | i = get_instance(FlatModel) 169 | i.serialize_rules = ("-id", "_protected_method", "prop", "list", "dict", "set") 170 | data = i.to_dict() 171 | 172 | # Check SQLAlchemy fields 173 | assert "id" not in data # is excluded in rules 174 | assert "string" in data 175 | assert data["string"] == i.string 176 | assert "date" in data 177 | assert "time" in data 178 | assert "datetime" in data 179 | assert "bool" in data 180 | assert data["bool"] == i.bool 181 | assert "null" in data 182 | assert data["null"] is None 183 | 184 | # Check non SQL fields included in rules 185 | assert "_protected_method" in data 186 | assert data["_protected_method"] == i._protected_method() 187 | assert "prop" in data 188 | assert data["prop"] == i.prop 189 | assert "list" in data 190 | assert data["list"] == i.list 191 | assert "dict" in data 192 | assert data["dict"] == i.dict 193 | # Serializer converts all iterables to lists 194 | assert "set" in data 195 | assert isinstance(data["set"], list) 196 | assert data["set"] == list(i.set) 197 | 198 | 199 | def test_default_rules_and_only_params(get_instance): 200 | i = get_instance(FlatModel) 201 | i.serialize_only = ("id", "string", "method", "list", "dict", "set") 202 | i.serialize_rules = ("prop",) 203 | data = i.to_dict() 204 | 205 | assert "id" in data 206 | assert data["id"] == i.id 207 | assert "string" in data 208 | assert data["string"] == i.string 209 | assert "method" in data 210 | assert data["method"] == i.method() 211 | assert "prop" in data 212 | assert data["prop"] == i.prop 213 | assert "list" in data 214 | assert data["list"] == i.list 215 | assert "dict" in data 216 | assert data["dict"] == i.dict 217 | # Serializer converts all iterables to lists 218 | assert "set" in data 219 | assert isinstance(data["set"], list) 220 | assert data["set"] == list(i.set) 221 | # Check if there is no other keys 222 | assert len(data.keys()) == 7 223 | 224 | 225 | def test_only_param_got_in_runtime(get_instance): 226 | i = get_instance(FlatModel) 227 | data = i.to_dict(only=("id", "string", "datetime", "_protected_method", "prop")) 228 | 229 | assert "id" in data 230 | assert data["id"] == i.id 231 | assert "string" in data 232 | assert data["string"] == i.string 233 | assert "datetime" in data # No need to check formatted value 234 | assert "_protected_method" in data 235 | assert data["_protected_method"] == i._protected_method() 236 | assert "prop" in data 237 | assert data["prop"] == i.prop 238 | # Check if there is no other keys 239 | assert len(data.keys()) == 5 240 | 241 | 242 | def test_rules_param_got_in_runtime(get_instance): 243 | i = get_instance(FlatModel) 244 | data = i.to_dict(rules=("-id", "_protected_method", "prop")) 245 | 246 | # Check SQLAlchemy fields 247 | assert "id" not in data # is excluded in rules 248 | assert "string" in data 249 | assert data["string"] == i.string 250 | assert "date" in data 251 | assert "time" in data 252 | assert "datetime" in data 253 | assert "bool" in data 254 | assert data["bool"] == i.bool 255 | assert "null" in data 256 | assert data["null"] is None 257 | 258 | # Check non SQL fields included in rules 259 | assert "_protected_method" in data 260 | assert data["_protected_method"] == i._protected_method() 261 | assert "prop" in data 262 | assert data["prop"] == i.prop 263 | 264 | 265 | def test_rules_and_only_params_got_in_runtime(get_instance): 266 | i = get_instance(FlatModel) 267 | data = i.to_dict( 268 | only=("id", "string", "method", "list", "dict", "set"), rules=("prop",) 269 | ) 270 | 271 | # Check that we got only 'id', 'string', 'method', 'list', 'dict', 'set' and 'prop' fields 272 | assert "id" in data 273 | assert data["id"] == i.id 274 | assert "string" in data 275 | assert data["string"] == i.string 276 | assert "method" in data 277 | assert data["method"] == i.method() 278 | assert "list" in data 279 | assert data["list"] == i.list 280 | assert "dict" in data 281 | assert data["dict"] == i.dict 282 | # Serializer converts all iterables to lists 283 | assert "set" in data 284 | assert isinstance(data["set"], list) 285 | assert data["set"] == list(i.set) 286 | assert "prop" in data 287 | assert data["prop"] == i.prop 288 | # Check if there is no other keys 289 | assert len(data.keys()) == 7 290 | 291 | 292 | def test_overlapping_of_default_and_got_in_runtime_params1(get_instance): 293 | i = get_instance(FlatModel) 294 | i.serialize_only = ("id", "method") 295 | i.serialize_rules = ("_protected_method",) 296 | data = i.to_dict(only=("method", "prop")) 297 | 298 | # Check that we got only 'method' and 'prop' 299 | assert "method" in data 300 | assert data["method"] == i.method() 301 | assert "prop" in data 302 | assert data["prop"] == i.prop 303 | # Check if there is no other keys 304 | assert len(data.keys()) == 2 305 | 306 | 307 | def test_overlapping_of_default_and_got_in_runtime_params2(get_instance): 308 | i = get_instance(FlatModel) 309 | i.serialize_only = ("id", "string") 310 | i.serialize_rules = ("_protected_method", "prop") 311 | data = i.to_dict( 312 | rules=( 313 | "-id", 314 | "method", 315 | ) 316 | ) 317 | 318 | # Check that we got only 'method', 'string', '_protected_method', 'prop' 319 | assert "id" not in data 320 | assert "method" in data 321 | assert data["method"] == i.method() 322 | assert "string" in data 323 | assert data["string"] == i.string 324 | assert "_protected_method" in data 325 | assert data["_protected_method"] == i._protected_method() 326 | assert "prop" in data 327 | assert data["prop"] == i.prop 328 | 329 | 330 | def test_rules_for_nested_dicts_and_lists(get_instance): 331 | i = get_instance(FlatModel) 332 | i.serialize_only = ("list", "prop") 333 | data = i.to_dict( 334 | rules=( 335 | "-list.key", 336 | "dict.key2", 337 | ) 338 | ) 339 | 340 | # Check that we got only 'prop', 'list' without 'key' and dict with key2 341 | assert len(data.keys()) == 3 342 | 343 | assert "list" in data 344 | for elm in data["list"]: 345 | if isinstance(elm, dict): 346 | assert "key" not in elm 347 | 348 | assert "dict" in data 349 | assert "key2" in data["dict"] 350 | assert len(data["dict"].keys()) == 1 351 | assert data["dict"]["key2"] == i.dict["key2"] 352 | 353 | assert "prop" in data 354 | assert data["prop"] == i.prop 355 | -------------------------------------------------------------------------------- /tests/test_get_property_field_names_function.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy_serializer.lib.fields import get_property_field_names 2 | 3 | from .models import FlatModel 4 | 5 | 6 | def test_get_property_field_names__returns_result(get_instance): 7 | instance = get_instance(FlatModel) 8 | assert get_property_field_names(instance) == { 9 | "prop", 10 | "prop_with_bytes", 11 | } 12 | -------------------------------------------------------------------------------- /tests/test_get_serializable_keys_function.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy_serializer.lib.fields import get_serializable_keys 2 | from tests.models import FlatModel 3 | 4 | 5 | def test_get_serializable_keys__custom_only(get_instance): 6 | instance = get_instance(FlatModel) 7 | instance.serializable_keys = ("id", "string") 8 | assert get_serializable_keys(instance) == {"id", "string"} 9 | 10 | 11 | def test_get_serializable_keys__sql_only(get_instance): 12 | instance = get_instance(FlatModel) 13 | assert not instance.serializable_keys 14 | assert not instance.auto_serialize_properties 15 | assert get_serializable_keys(instance) == { 16 | "id", 17 | "string", 18 | "date", 19 | "datetime", 20 | "time", 21 | "bool", 22 | "null", 23 | "uuid", 24 | } 25 | 26 | 27 | def test_get_serializable_keys__auto_serialize_propereties(get_instance): 28 | instance = get_instance(FlatModel) 29 | instance.auto_serialize_properties = True 30 | assert not instance.serializable_keys 31 | assert get_serializable_keys(instance) == { 32 | "id", 33 | "string", 34 | "date", 35 | "datetime", 36 | "time", 37 | "bool", 38 | "null", 39 | "uuid", 40 | "prop", 41 | "prop_with_bytes", 42 | } 43 | -------------------------------------------------------------------------------- /tests/test_get_sql_field_names_function.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy_serializer.lib.fields import get_sql_field_names 3 | from sqlalchemy.exc import NoInspectionAvailable 4 | 5 | from .models import FlatModel 6 | 7 | 8 | def test_get_sql_field_names__returns_result(get_instance): 9 | instance = get_instance(FlatModel) 10 | assert get_sql_field_names(instance) == { 11 | "id", 12 | "string", 13 | "date", 14 | "datetime", 15 | "time", 16 | "bool", 17 | "null", 18 | "uuid", 19 | } 20 | 21 | 22 | class NonSqlInstance: 23 | a = 1 24 | 25 | def __init__(self) -> None: 26 | self.b = 2 27 | 28 | 29 | @pytest.mark.parametrize( 30 | "non_sql_instance", (object, [1, 2], dict(a=2), NonSqlInstance()) 31 | ) 32 | def test_get_sql_field_names__raises_error(non_sql_instance): 33 | with pytest.raises(NoInspectionAvailable): 34 | get_sql_field_names(non_sql_instance) 35 | -------------------------------------------------------------------------------- /tests/test_nested_model.py: -------------------------------------------------------------------------------- 1 | from .models import FlatModel, NestedModel, DATETIME, TIME, DATE 2 | 3 | 4 | def test_no_defaults_no_rules(get_instance): 5 | """ 6 | Checks to_dict method of model with no predefined options 7 | """ 8 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 9 | data = i.to_dict() 10 | 11 | # Check nested SQLAlchemy fields 12 | assert "model" in data 13 | nested = data["model"] 14 | 15 | assert "id" in nested 16 | assert "string" in nested 17 | assert nested["string"] == i.model.string 18 | assert "date" in nested 19 | assert "time" in nested 20 | assert "datetime" in nested 21 | assert "bool" in nested 22 | assert nested["bool"] == i.model.bool 23 | assert "null" in nested 24 | assert nested["null"] is None 25 | 26 | # Check non-sql fields (not included in this case, need to be defined explicitly) 27 | assert "list" not in nested 28 | assert "set" not in nested 29 | assert "dict" not in nested 30 | assert "prop" not in nested 31 | assert "method" not in nested 32 | assert "_protected_method" not in nested 33 | 34 | 35 | def test_datetime_default_formats(get_instance): 36 | """ 37 | Check date/datetime/time 38 | default formats in resulting JSON of model with no predefined options 39 | """ 40 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 41 | # Default formats 42 | d_format = i.date_format 43 | dt_format = i.datetime_format 44 | t_format = i.time_format 45 | data = i.to_dict() 46 | 47 | assert "model" in data 48 | nested = data["model"] 49 | 50 | assert "date" in nested 51 | assert nested["date"] == DATE.strftime(d_format) 52 | assert "datetime" in data 53 | assert nested["datetime"] == DATETIME.strftime(dt_format) 54 | assert "time" in data 55 | assert nested["time"] == TIME.strftime(t_format) 56 | 57 | 58 | def test_datetime_formats_got_in_runtime(get_instance): 59 | """ 60 | Check date/datetime/time 61 | default formats in resulting JSON of flat model got as the parameters of to_dict func 62 | """ 63 | d_format = "%Y/%m/%d" 64 | dt_format = "%Y/%m/%d %H:%M" 65 | t_format = ">%H<" 66 | 67 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 68 | 69 | # Check that default formats are different 70 | assert d_format != i.date_format 71 | assert dt_format != i.datetime_format 72 | assert t_format != i.time_format 73 | 74 | data = i.to_dict( 75 | date_format=d_format, 76 | datetime_format=dt_format, 77 | time_format=t_format, 78 | ) 79 | assert "model" in data 80 | nested = data["model"] 81 | 82 | # Check serialized formats 83 | assert "date" in nested 84 | assert nested["date"] == DATE.strftime(d_format) 85 | assert "datetime" in data 86 | assert nested["datetime"] == DATETIME.strftime(dt_format) 87 | assert "time" in data 88 | assert nested["time"] == TIME.strftime(t_format) 89 | 90 | 91 | def test_default_only_param(get_instance): 92 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 93 | i.serialize_only = ( 94 | "model.id", 95 | "model.string", 96 | "model.datetime", 97 | "model._protected_method", 98 | "model.prop", 99 | ) 100 | data = i.to_dict() 101 | 102 | # Check if there is no other keys 103 | assert len(data.keys()) == 1 104 | 105 | assert "model" in data 106 | nested = data["model"] 107 | 108 | assert "id" in nested 109 | assert nested["id"] == i.model.id 110 | assert "string" in nested 111 | assert nested["string"] == i.model.string 112 | assert "datetime" in nested # No need to check formatted value 113 | assert "_protected_method" in nested 114 | assert nested["_protected_method"] == i.model._protected_method() 115 | assert "prop" in nested 116 | assert nested["prop"] == i.model.prop 117 | # Check if there is no other keys 118 | assert len(nested.keys()) == 5 119 | 120 | 121 | def test_default_rules_param(get_instance): 122 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 123 | i.serialize_rules = ( 124 | "-model.id", 125 | "model._protected_method", 126 | "model.prop", 127 | "model.list", 128 | "model.dict", 129 | "model.set", 130 | ) 131 | data = i.to_dict() 132 | assert "model" in data 133 | nested = data["model"] 134 | 135 | # Check SQLAlchemy fields 136 | assert "id" not in nested # is excluded in rules 137 | assert "string" in nested 138 | assert nested["string"] == i.model.string 139 | assert "date" in nested 140 | assert "time" in nested 141 | assert "datetime" in nested 142 | assert "bool" in nested 143 | assert nested["bool"] == i.model.bool 144 | assert "null" in nested 145 | assert nested["null"] is None 146 | 147 | # Check non SQL fields included in rules 148 | assert "_protected_method" in nested 149 | assert nested["_protected_method"] == i.model._protected_method() 150 | assert "prop" in nested 151 | assert nested["prop"] == i.model.prop 152 | assert "list" in nested 153 | assert nested["list"] == i.model.list 154 | assert "dict" in nested 155 | assert nested["dict"] == i.model.dict 156 | # Serializer converts all iterables to lists 157 | assert "set" in nested 158 | assert isinstance(nested["set"], list) 159 | assert nested["set"] == list(i.model.set) 160 | 161 | 162 | def test_default_rules_and_only_params(get_instance): 163 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 164 | i.serialize_only = ( 165 | "model.id", 166 | "model.string", 167 | "model.method", 168 | "model.list", 169 | "model.dict", 170 | "model.set", 171 | ) 172 | i.serialize_rules = ("model.prop",) 173 | data = i.to_dict() 174 | assert "model" in data 175 | nested = data["model"] 176 | 177 | assert "id" in nested 178 | assert nested["id"] == i.model.id 179 | assert "string" in nested 180 | assert nested["string"] == i.model.string 181 | assert "method" in nested 182 | assert nested["method"] == i.model.method() 183 | assert "prop" in nested 184 | assert nested["prop"] == i.model.prop 185 | assert "list" in nested 186 | assert nested["list"] == i.model.list 187 | assert "dict" in nested 188 | assert nested["dict"] == i.model.dict 189 | # Serializer converts all iterables to lists 190 | assert "set" in nested 191 | assert isinstance(nested["set"], list) 192 | assert nested["set"] == list(i.model.set) 193 | # Check if there is no other keys 194 | assert len(nested.keys()) == 7 195 | 196 | 197 | def test_only_param_got_in_runtime(get_instance): 198 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 199 | data = i.to_dict( 200 | only=( 201 | "id", 202 | "model.id", 203 | "model.string", 204 | "model.datetime", 205 | "model._protected_method", 206 | "model.prop", 207 | ) 208 | ) 209 | 210 | assert "id" in data 211 | assert len(data.keys()) == 2 212 | assert "model" in data 213 | nested = data["model"] 214 | 215 | assert "id" in nested 216 | assert nested["id"] == i.model.id 217 | assert "string" in nested 218 | assert nested["string"] == i.model.string 219 | assert "datetime" in nested # No need to check formatted value 220 | assert "_protected_method" in nested 221 | assert nested["_protected_method"] == i.model._protected_method() 222 | assert "prop" in nested 223 | assert nested["prop"] == i.model.prop 224 | # Check if there is no other keys 225 | assert len(nested.keys()) == 5 226 | 227 | 228 | def test_rules_param_got_in_runtime(get_instance): 229 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 230 | data = i.to_dict( 231 | rules=("-id", "prop", "-model.id", "model._protected_method", "model.prop") 232 | ) 233 | 234 | assert "id" not in data 235 | assert "prop" in data # Non SQL field here because it was mentioned in rules 236 | assert "string" in data # SQL field here because all model's fields here by default 237 | nested = data["model"] 238 | 239 | # Check SQLAlchemy fields 240 | assert "id" not in nested # is excluded in rules 241 | assert "string" in nested 242 | assert nested["string"] == i.model.string 243 | assert "date" in nested 244 | assert "time" in nested 245 | assert "datetime" in nested 246 | assert "bool" in nested 247 | assert nested["bool"] == i.model.bool 248 | assert "null" in nested 249 | assert nested["null"] is None 250 | 251 | # Check non SQL fields included in rules 252 | assert "_protected_method" in nested 253 | assert nested["_protected_method"] == i.model._protected_method() 254 | assert "prop" in nested 255 | assert nested["prop"] == i.model.prop 256 | 257 | 258 | def test_rules_and_only_params_got_in_runtime(get_instance): 259 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 260 | data = i.to_dict( 261 | only=("model.id", "model.string", "model.method", "model.set"), 262 | rules=( 263 | "id", 264 | "model.prop", 265 | ), 266 | ) 267 | 268 | assert "id" in data 269 | assert len(data.keys()) == 2 270 | 271 | assert "model" in data 272 | nested = data["model"] 273 | 274 | assert "id" in nested 275 | assert nested["id"] == i.model.id 276 | assert "string" in nested 277 | assert nested["string"] == i.model.string 278 | assert "method" in nested 279 | assert nested["method"] == i.model.method() 280 | # Serializer converts all iterables to lists 281 | assert "set" in nested 282 | assert isinstance(nested["set"], list) 283 | assert nested["set"] == list(i.model.set) 284 | assert "prop" in nested 285 | assert nested["prop"] == i.model.prop 286 | # Check if there is no other keys 287 | assert len(nested.keys()) == 5 288 | 289 | 290 | def test_overlapping_of_default_and_got_in_runtime_params1(get_instance): 291 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 292 | i.serialize_only = ("model.id", "model.method") 293 | i.serialize_rules = ("_protected_method",) 294 | data = i.to_dict( 295 | only=( 296 | "id", 297 | "model.method", 298 | "model.prop", 299 | ) # Params passed in to_dict have HIGHEST priority 300 | ) 301 | 302 | assert "id" in data 303 | assert len(data.keys()) == 2 304 | 305 | assert "model" in data 306 | nested = data["model"] 307 | 308 | # Check that we got only 'method' and 'prop' 309 | assert "method" in nested 310 | assert nested["method"] == i.model.method() 311 | assert "prop" in nested 312 | assert nested["prop"] == i.model.prop 313 | # Check if there is no other keys 314 | assert len(nested.keys()) == 2 315 | 316 | 317 | def test_overlapping_of_default_and_got_in_runtime_params2(get_instance): 318 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 319 | i.serialize_only = ("id", "string") 320 | i.serialize_rules = ("model.prop",) 321 | data = i.to_dict( 322 | rules=( 323 | "-id", 324 | "model.method", 325 | ) 326 | ) 327 | 328 | assert "id" not in data 329 | assert "string" in data 330 | assert "model" in data 331 | assert len(data.keys()) == 2 332 | 333 | nested = data["model"] 334 | 335 | assert "method" in nested 336 | assert nested["method"] == i.model.method() 337 | assert "prop" in nested 338 | assert nested["prop"] == i.model.prop 339 | assert len(nested.keys()) == 2 340 | 341 | 342 | def test_rules_for_nested_dicts_and_lists(get_instance): 343 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 344 | i.serialize_only = ("dict.key", "model.list", "model.prop") 345 | data = i.to_dict( 346 | rules=( 347 | "-model.list.key", 348 | "model.dict.key2", 349 | ) 350 | ) 351 | assert "dict" in data 352 | assert "model" in data 353 | assert len(data.keys()) == 2 354 | 355 | assert "key" in data["dict"] 356 | assert data["dict"]["key"] == i.dict["key"] 357 | assert len(data["dict"].keys()) == 1 358 | 359 | nested = data["model"] 360 | 361 | # Check that we got only 'prop', 'list' without 'key' and dict with key2 362 | assert len(nested.keys()) == 3 363 | 364 | assert "list" in nested 365 | for elm in nested["list"]: 366 | if isinstance(elm, dict): 367 | assert "key" not in elm 368 | 369 | assert "dict" in nested 370 | assert "key2" in nested["dict"] 371 | assert len(nested["dict"].keys()) == 1 372 | assert nested["dict"]["key2"] == i.model.dict["key2"] 373 | 374 | assert "prop" in nested 375 | assert nested["prop"] == i.model.prop 376 | 377 | 378 | def test_controversial_rules(get_instance): 379 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 380 | i.serialize_rules = ("-model", "model.id") 381 | data = i.to_dict() 382 | 383 | # All rules will be included 384 | assert "model" in data 385 | 386 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 387 | i.serialize_rules = ("-model.id",) 388 | i.serialize_only = ("model", "model.string") 389 | data = i.to_dict() 390 | 391 | assert "model" in data 392 | nested = data["model"] 393 | 394 | # Rules from ONLY section always have higher priority 395 | assert "id" not in nested 396 | assert "string" in nested 397 | 398 | # Negative rules in ONLY section 399 | # Nice way 400 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 401 | i.serialize_only = ("model", "-model.string") 402 | data = i.to_dict() 403 | 404 | assert "model" in data 405 | nested = data["model"] 406 | assert "id" in nested 407 | assert "string" not in nested 408 | 409 | # Wrong way 410 | i = get_instance(NestedModel, model_id=get_instance(FlatModel).id) 411 | i.serialize_only = ("-model.string",) 412 | data = i.to_dict() 413 | 414 | assert not data 415 | 416 | 417 | def test_combination(get_instance): 418 | flat = get_instance(FlatModel) 419 | nested = get_instance(NestedModel, model_id=flat.id) 420 | res = nested.to_dict( 421 | only=("model", "set"), rules=("model.set", "-model.id", "-model.date") 422 | ) 423 | assert set(res.keys()) == {"model", "set"} 424 | assert set(res["model"].keys()) == { 425 | "string", 426 | "datetime", 427 | "time", 428 | "bool", 429 | "null", 430 | "uuid", 431 | "set", 432 | } 433 | 434 | 435 | def test_combination2(get_instance): 436 | flat = get_instance(FlatModel) 437 | flat.serialize_only = ("id", "date", "string", "time") 438 | nested = get_instance(NestedModel, model_id=flat.id) 439 | res = nested.to_dict( 440 | only=("model", "set"), rules=("model.set", "-model.id", "-model.date") 441 | ) 442 | assert set(res.keys()) == {"model", "set"} 443 | assert set(res["model"].keys()) == {"string", "time", "set"} 444 | -------------------------------------------------------------------------------- /tests/test_recursive_model.py: -------------------------------------------------------------------------------- 1 | from .models import RecursiveModel 2 | 3 | 4 | def test_no_rules(get_instance): 5 | """ 6 | Checks to_dict method of model without rules 7 | """ 8 | root = get_instance(RecursiveModel) 9 | child_full = get_instance(RecursiveModel, parent_id=root.id) 10 | _ = get_instance(RecursiveModel, parent_id=child_full.id) 11 | 12 | # No rules 13 | data = root.to_dict() 14 | 15 | assert "children" in data 16 | assert "children" in data["children"][0] 17 | assert "children" in data["children"][0]["children"][0] 18 | 19 | assert "name" in data 20 | assert "name" in data["children"][0] 21 | assert "name" in data["children"][0]["children"][0] 22 | 23 | 24 | def test_rules_in_class(get_instance): 25 | """ 26 | Checks to_dict method of model with rules 27 | defined on class level 28 | """ 29 | root = get_instance(RecursiveModel) 30 | child_full = get_instance(RecursiveModel, parent_id=root.id) 31 | _ = get_instance(RecursiveModel, parent_id=child_full.id) 32 | 33 | RecursiveModel.serialize_rules = ("-children.children.children",) 34 | data = root.to_dict() 35 | 36 | assert "children" in data 37 | assert "children" in data["children"][0] 38 | assert "children" not in data["children"][0]["children"][0] 39 | 40 | assert "name" in data 41 | assert "name" in data["children"][0] 42 | assert "name" in data["children"][0]["children"][0] 43 | 44 | 45 | def test_rules_in_method_call(get_instance): 46 | """ 47 | Checks to_dict method of model with rules 48 | passed in to_dict method 49 | """ 50 | root = get_instance(RecursiveModel) 51 | child_full = get_instance(RecursiveModel, parent_id=root.id) 52 | _ = get_instance(RecursiveModel, parent_id=child_full.id) 53 | 54 | RecursiveModel.serialize_rules = () 55 | data = root.to_dict(rules=("-children.children.children",)) 56 | 57 | assert "children" in data 58 | assert "children" in data["children"][0] 59 | assert "children" not in data["children"][0]["children"][0] 60 | 61 | assert "name" in data 62 | assert "name" in data["children"][0] 63 | assert "name" in data["children"][0]["children"][0] 64 | 65 | 66 | def test_combination_of_rules(get_instance): 67 | """ 68 | Checks to_dict method of model with combinations of 69 | rules passed in to_dict method 70 | """ 71 | root = get_instance(RecursiveModel) 72 | child_full = get_instance(RecursiveModel, parent_id=root.id) 73 | _ = get_instance(RecursiveModel, parent_id=child_full.id) 74 | 75 | RecursiveModel.serialize_rules = ("-children.children", "-children.id") 76 | data = root.to_dict() 77 | 78 | assert "children" in data 79 | assert "children" not in data["children"][0] 80 | 81 | assert "name" in data 82 | assert "name" in data["children"][0] 83 | 84 | assert "id" in data 85 | assert "id" not in data["children"][0] 86 | -------------------------------------------------------------------------------- /tests/test_rules.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sqlalchemy_serializer.lib.schema import Rule 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "text, keys, is_negative", 8 | [ 9 | ("simple", ["simple"], False), 10 | ("double.rule", ["double", "rule"], False), 11 | ("-negative", ["negative"], True), 12 | ("-negative.rule", ["negative", "rule"], True), 13 | ], 14 | ) 15 | def test_rule(text, keys, is_negative): 16 | rule = Rule(text) 17 | assert rule.keys == keys 18 | assert rule.is_negative == is_negative 19 | assert str(rule) == text 20 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sqlalchemy_serializer.lib.schema import Schema, Rule, Tree 4 | 5 | 6 | @pytest.mark.parametrize("tree", [None, Tree(to_include=False)]) 7 | def test_init_method(tree): 8 | schema = Schema(tree=tree) 9 | assert isinstance(schema._tree, Tree) 10 | if not tree: 11 | assert schema._tree.to_include is None 12 | else: 13 | assert not schema._tree.to_include 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "rules, keys", 18 | [ 19 | ({"extend": ("key.another",)}, {"key"}), 20 | ({"extend": ("key.another", "-key")}, {"key"}), 21 | ({"extend": ("key.another", "-key.another")}, {"key"}), 22 | ({"only": ("key.another", "-key.another")}, {"key"}), 23 | ({"only": ("-key.another",)}, set()), 24 | ({"only": ("key.another",), "extend": ("another",)}, {"key", "another"}), 25 | ], 26 | ) 27 | def test_keys_property(rules, keys): 28 | schema = Schema() 29 | schema.update(**rules) 30 | assert schema.keys == keys 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "rules, expected", 35 | [ 36 | ({"extend": ("key.another",)}, True), 37 | ({"extend": ("key.another", "-key")}, True), 38 | ({"extend": ("key.another", "-key.another")}, True), 39 | ({"only": ("key.another", "-key.another")}, False), 40 | ({"only": ("-key.another",)}, False), 41 | ({"only": ("key.another",), "extend": ("another",)}, False), 42 | ], 43 | ) 44 | def test_is_greedy_property(rules, expected): 45 | schema = Schema() 46 | schema.update(**rules) 47 | assert schema.is_greedy is expected 48 | 49 | 50 | @pytest.mark.parametrize( 51 | "old, new, expected", 52 | [ 53 | ( 54 | {"extend": ("new.o.o", "old.o.o")}, 55 | {"only": ("old",)}, 56 | { 57 | "": False, # root 58 | "new": False, 59 | "new.o": False, 60 | "new.o.o": True, 61 | "old": True, 62 | "old.o": True, 63 | "old.o.o": True, 64 | }, 65 | ), 66 | ], 67 | ) 68 | def test_is_greedy_updated(old, new, expected): 69 | schema = Schema() 70 | schema.update(**old) 71 | schema.update(**new) 72 | assert schema.is_greedy == expected.pop("") 73 | for rule, is_greedy in expected.items(): 74 | leaf = check_rule(rule, schema._tree) 75 | assert leaf.is_greedy == is_greedy 76 | 77 | 78 | @pytest.mark.parametrize( 79 | "old, new, expect", 80 | [ 81 | ( 82 | # Controversial rules 83 | {"extend": ("-o", "o.o")}, 84 | {}, 85 | ("-o", "o.o"), 86 | ), 87 | ({"extend": ("-o",)}, {"extend": ("-o.o",)}, ("-o",)), 88 | ({"extend": ("-o",)}, {"extend": ("o",)}, ("-o",)), 89 | ({"extend": ("o",)}, {"extend": ("-o",)}, ("o",)), 90 | ({"extend": ("o.o",)}, {"extend": ("-o.one",)}, ("o.o", "-o.one")), 91 | ( 92 | {"extend": ("-o.o.o",)}, 93 | {"extend": ("o.o.another",)}, 94 | ("-o.o.o", "o.o.another"), 95 | ), 96 | ( 97 | {"only": ("o.o",)}, 98 | {"extend": ("o.another", "-o.o.o")}, 99 | ( 100 | "o.o", 101 | "-o.o.o", 102 | "!o.another", 103 | ), # Prefix "!" means there should not be that rule 104 | ), 105 | ( 106 | {"extend": ("o.o",)}, 107 | {"only": ("o.another", "-o.o.o")}, 108 | ("o.o", "-o.o.o", "o.another"), 109 | ), 110 | ( 111 | {"extend": ("o.o",)}, 112 | {"only": ("o.another", "-o.o.o")}, 113 | ("o.o", "-o.o.o", "o.another"), 114 | ), 115 | ( 116 | {"extend": ("o.o.o", "o.another.o", "o.o.another")}, 117 | {"only": ("o.another", "-o.o.another")}, 118 | ("o.o.o", "o.another.o", "o.o.another"), 119 | ), 120 | ], 121 | ) 122 | def test_update_method(old, new, expect): 123 | """ 124 | Checks if the schema merges rules correctly 125 | """ 126 | schema = Schema() 127 | schema.update(**old) 128 | schema.update(**new) 129 | 130 | assert schema._tree.is_greedy == bool(not (old.get("only") or new.get("only"))) 131 | for rule in expect: 132 | if rule.startswith("!"): 133 | rule = rule[1:] 134 | with pytest.raises(NoNodeException): 135 | check_rule(text=rule, tree=schema._tree) 136 | else: 137 | check_rule(text=rule, tree=schema._tree) 138 | 139 | 140 | @pytest.mark.parametrize( 141 | "args, expected", 142 | [ 143 | ( 144 | # Conflicted rules, negative rule have higher priority 145 | {"extend": ("key", "-key")}, 146 | False, 147 | ), 148 | ({"extend": ("-key",)}, False), 149 | ({"extend": ("another",)}, True), 150 | ({"extend": ("-another",)}, True), 151 | ({"extend": ("-key.another",)}, True), 152 | ({"extend": ("key.another",)}, True), 153 | ( 154 | { 155 | "extend": ( 156 | "key", 157 | "-key.another", 158 | ) 159 | }, 160 | True, 161 | ), 162 | ( 163 | # Conflicted rules, positive rule have higher priority 164 | {"only": ("key", "-key")}, 165 | True, 166 | ), 167 | ({"only": ("-key",)}, False), 168 | ({"only": ("another",)}, False), 169 | ({"only": ("key", "-another")}, True), 170 | ({"only": ("key",)}, True), 171 | ({"only": ("key.another",)}, True), 172 | ({"only": ("-key.another",)}, False), 173 | ( 174 | { 175 | "only": ( 176 | "key", 177 | "-key.another", 178 | ) 179 | }, 180 | True, 181 | ), 182 | ], 183 | ) 184 | def test_is_included_method(args, expected): 185 | KEY = "key" 186 | schema = Schema() 187 | schema.update(**args) 188 | assert schema.is_included(KEY) == expected 189 | 190 | 191 | @pytest.mark.parametrize( 192 | "args, new_args, is_greedy", 193 | [ 194 | ( 195 | {"extend": ("key.key", "-key.key.key")}, 196 | {}, 197 | True, 198 | ), 199 | ( 200 | {"extend": ("-key.another",)}, 201 | {"only": ("key",)}, 202 | True, 203 | ), 204 | ( 205 | {"only": ("-key.another",)}, 206 | {"extend": ("key",)}, 207 | True, 208 | ), 209 | ( 210 | {"extend": ("-key.another",)}, 211 | {"extend": ("key",)}, 212 | True, 213 | ), 214 | ( 215 | {"only": ("key",)}, 216 | {"extend": ("-key.another",)}, 217 | True, 218 | ), 219 | ( 220 | {"only": ("key.another",)}, 221 | {"extend": ("-key.another2",)}, 222 | False, 223 | ), 224 | ( 225 | {"extend": ("key.another",)}, 226 | {"only": ("key.another2",)}, 227 | False, 228 | ), 229 | ], 230 | ) 231 | def test_fork_method(args, new_args, is_greedy): 232 | KEY = "key" 233 | schema = Schema() 234 | schema.update(**args) 235 | schema.update(**new_args) 236 | forked = schema.fork(KEY) 237 | assert forked.is_greedy is is_greedy 238 | 239 | 240 | def check_rule(text: str, tree: Tree) -> Tree: 241 | """ 242 | Checks that the rule is correctly stored in the tree 243 | and returns the leaf 244 | """ 245 | rule = Rule(text) 246 | for k in rule.keys: 247 | tree = tree.get(k) 248 | if tree is None: 249 | raise NoNodeException(f"Can not find key:{k} in tree:{tree}") 250 | if rule.is_negative: 251 | assert tree.to_exclude 252 | else: 253 | assert tree.to_include 254 | return tree 255 | 256 | 257 | class NoNodeException(Exception): 258 | pass 259 | -------------------------------------------------------------------------------- /tests/test_serialize_collection_function.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy_serializer.serializer import serialize_collection 2 | from .models import FlatModel 3 | 4 | 5 | def test_serialize_collection__success(get_instance): 6 | iterable = [get_instance(FlatModel) for _ in range(3)] 7 | result = serialize_collection(iterable, only=("id",)) 8 | assert isinstance(result, list) 9 | assert len(result) == 3 10 | assert all(list(item.keys()) == ["id"] for item in result) 11 | -------------------------------------------------------------------------------- /tests/test_serializer_fork_function.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy_serializer.serializer import Serializer 2 | 3 | 4 | def test_fork_with_key(mocker, get_serializer): 5 | key = "test_value" 6 | schema = mocker.MagicMock() 7 | serializer = get_serializer() 8 | 9 | mocker.patch.object(serializer, "schema", schema) 10 | result = serializer.fork(key=key) 11 | 12 | assert isinstance(result, Serializer) 13 | assert result.opts == serializer.opts 14 | schema.fork.assert_called_once_with(key=key) 15 | 16 | 17 | def test_fork_logger(mocker, get_serializer): 18 | key = "test_key" 19 | mocked_logger = mocker.patch("sqlalchemy_serializer.serializer.logger") 20 | mocker.patch("sqlalchemy_serializer.serializer.Serializer.__call__") 21 | serializer = get_serializer() 22 | 23 | mocker.patch.object(serializer, "schema", mocker.MagicMock()) 24 | serializer.fork(key=key) 25 | 26 | mocked_logger.debug.assert_called_once_with("Fork serializer for key:%s", key) 27 | -------------------------------------------------------------------------------- /tests/test_serializer_serialize_dict__function.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | import pytest 3 | 4 | 5 | @pytest.fixture 6 | def test_dict(): 7 | return { 8 | "int": 123, 9 | "float": 12.3, 10 | "decimal": Decimal("12.3"), 11 | "str": "!@#$%^", 12 | "callable": lambda: {"str": "value", "int": 1234}, 13 | "list_of_dicts": [ 14 | { 15 | "str": "string", 16 | "int": 1235, 17 | } 18 | ], 19 | } 20 | 21 | 22 | def test_serializer_serialize_dict__success(get_serializer, test_dict): 23 | serializer = get_serializer() 24 | result = serializer.serialize_dict(test_dict) 25 | assert result == { 26 | "int": 123, 27 | "float": 12.3, 28 | "decimal": "12.3", 29 | "str": "!@#$%^", 30 | "callable": {"str": "value", "int": 1234}, 31 | "list_of_dicts": [ 32 | { 33 | "str": "string", 34 | "int": 1235, 35 | } 36 | ], 37 | } 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "only, expected", 42 | [ 43 | (("int",), {"int": 123}), 44 | (("callable.int",), {"callable": {"int": 1234}}), 45 | (("list_of_dicts.int",), {"list_of_dicts": [{"int": 1235}]}), 46 | ], 47 | ) 48 | def test_serializer_serialize_dict__fork_success( 49 | get_serializer, test_dict, only, expected 50 | ): 51 | serializer = get_serializer() 52 | serializer.schema.update(only=only) 53 | result = serializer.serialize_dict(test_dict) 54 | assert result == expected 55 | -------------------------------------------------------------------------------- /tests/test_serializer_serialize_model__function.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.models import FlatModel, NestedModel 4 | 5 | 6 | @pytest.fixture 7 | def test_model(get_instance): 8 | model = get_instance(NestedModel) 9 | model.model = get_instance(FlatModel) 10 | return model 11 | 12 | 13 | # TODO: Add more checks of model fields 14 | @pytest.mark.parametrize( 15 | "only, expected", 16 | [ 17 | # FIXME: last `key` ignored and all the rist is returned is it expected? 18 | # (("model.list.key",), {"model": {"list": [{"key": 123}]}}), 19 | (("dict.key",), {"dict": {"key": 123}}), 20 | (("bool",), {"bool": False}), 21 | (("null",), {"null": None}), # FIXME: Invalid JSON 22 | ], 23 | ) 24 | def test_serializer_serialize_model__fork_success( 25 | get_serializer, test_model, only, expected 26 | ): 27 | serializer = get_serializer() 28 | serializer.schema.update(only=only) 29 | result = serializer.serialize_model(test_model) 30 | assert result == expected 31 | -------------------------------------------------------------------------------- /tests/test_serializer_set_serialization_depth_function.py: -------------------------------------------------------------------------------- 1 | def test_set_serialization_depth_success(get_serializer): 2 | new_serialization_depth = 123 3 | serializer = get_serializer() 4 | 5 | assert serializer.serialization_depth == 0 6 | serializer.set_serialization_depth(new_serialization_depth) 7 | assert serializer.serialization_depth == new_serialization_depth 8 | -------------------------------------------------------------------------------- /tests/test_tree.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sqlalchemy_serializer.lib.schema import Tree, merge_trees 4 | 5 | 6 | def test_tree_defaults(): 7 | tree = Tree() 8 | assert tree.to_include is None 9 | assert tree.to_exclude is None 10 | assert tree.is_greedy is True 11 | assert tree.default_factory == Tree 12 | assert not tree 13 | 14 | assert tree.get("random_key1") is None 15 | assert isinstance(tree["random_key2"], Tree) 16 | assert tree 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "to_include, to_exclude, is_greedy, props", 21 | [ 22 | (True, True, False, {}), 23 | (False, True, True, {"some": "arg", "another": ("one",)}), 24 | ], 25 | ) 26 | def test_tree_init(to_include, to_exclude, is_greedy, props): 27 | tree = Tree( 28 | to_include=to_include, to_exclude=to_exclude, is_greedy=is_greedy, **props 29 | ) 30 | assert tree.to_exclude == to_exclude 31 | assert tree.to_include == to_include 32 | assert tree.is_greedy == is_greedy 33 | for k, v in props.items(): 34 | assert tree[k] == v 35 | 36 | 37 | @pytest.mark.parametrize( 38 | "tree", 39 | [ 40 | Tree(key=Tree(first=Tree(another=Tree()), second=Tree())), 41 | Tree(key=Tree()), 42 | Tree(), 43 | ], 44 | ) 45 | def test_to_strict_method(tree): 46 | def check_greed(t): 47 | assert not t.is_greedy 48 | for subtree in t.values(): 49 | if subtree: 50 | check_greed(subtree) 51 | else: 52 | assert subtree.is_greedy 53 | 54 | tree.to_strict() 55 | check_greed(tree) 56 | 57 | 58 | @pytest.mark.parametrize( 59 | "is_dummy, is_greedy, to_include, to_exclude", 60 | [ 61 | (True, True, True, True), 62 | (True, True, True, False), 63 | (True, True, False, False), 64 | (True, False, False, False), 65 | (False, False, False, False), 66 | (False, True, False, False), 67 | (False, True, True, False), 68 | (False, True, True, True), 69 | ], 70 | ) 71 | def test_tree_apply(is_dummy, is_greedy, to_include, to_exclude): 72 | tree = Tree() if is_dummy else Tree(some=Tree()) 73 | node = Tree(is_greedy=is_greedy, to_exclude=to_exclude, to_include=to_include) 74 | tree.apply(node) 75 | 76 | assert tree.is_greedy == is_greedy 77 | assert tree.to_include == to_include 78 | assert tree.to_exclude == to_exclude 79 | 80 | 81 | def test_merge_trees(): 82 | tree1 = Tree(is_greedy=False, to_include=True) 83 | node1 = tree1["key1"] 84 | node1.is_greedy = False 85 | node1.to_include = True 86 | 87 | tree2 = Tree(is_greedy=False, to_exclude=True) 88 | node2 = tree2["key1"] 89 | node2.to_exclude = True 90 | node2.is_greedy = False 91 | 92 | node3 = node2["key2"] 93 | node3.to_exclude = True 94 | node3.is_greedy = False 95 | 96 | tree3 = Tree(is_greedy=True, to_include=True) 97 | node4 = tree3["key1"] 98 | node4.to_include = True 99 | 100 | node5 = node4["key3"] 101 | node5.to_include = True 102 | 103 | merge_trees(tree1, tree2, tree3) 104 | 105 | # Check root 106 | assert not tree1.is_greedy 107 | assert tree1.to_include 108 | assert tree1.to_exclude 109 | 110 | # Check the first level 111 | node = tree1.get("key1") 112 | assert node 113 | assert not node.is_greedy 114 | assert node.to_include 115 | assert node.to_exclude 116 | 117 | # Check leaves 118 | leaf = node.get("key2") 119 | assert leaf is not None 120 | assert not leaf.is_greedy 121 | assert leaf.to_exclude 122 | assert leaf.to_include is None 123 | 124 | leaf = node.get("key3") 125 | assert leaf is not None 126 | assert leaf.is_greedy 127 | assert leaf.to_include 128 | assert leaf.to_exclude is None 129 | -------------------------------------------------------------------------------- /tests/test_tzinfo.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | 3 | from .models import FlatModel, DATETIME, TIME, DATE 4 | 5 | 6 | def test_tzinfo_set_directly(get_instance): 7 | """ 8 | Checks how serializer applies tzinfo for datetime objects 9 | 10 | """ 11 | i = get_instance(FlatModel) 12 | 13 | # Default formats 14 | d_format = i.date_format 15 | dt_format = i.datetime_format 16 | t_format = i.time_format 17 | tzinfo = pytz.timezone("Europe/Moscow") 18 | 19 | data = i.to_dict(tzinfo=tzinfo) 20 | 21 | # No change for time and date objects 22 | assert "date" in data 23 | assert data["date"] == DATE.strftime(d_format) 24 | assert "datetime" in data 25 | assert "time" in data 26 | assert data["time"] == TIME.strftime(t_format) 27 | 28 | # Timezone info affects only datetime objects 29 | assert data["datetime"] == DATETIME.astimezone(tzinfo).strftime(dt_format) 30 | --------------------------------------------------------------------------------