diff --git a/.gitignore b/.gitignore index 6b01817..7c13220 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,3 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3f111be --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: check-yaml + - id: check-toml + - id: detect-private-key + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/psf/black + rev: 22.10.0 + hooks: + - id: black + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + name: isort (python) + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-use-type-annotations diff --git a/Dockerfile b/Dockerfile index 20dbd3f..08e0203 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,25 @@ -FROM python:3.10 as build +FROM python:3.10-slim as build WORKDIR /tmp RUN pip install poetry COPY ./pyproject.toml ./poetry.lock* /tmp/ -RUN poetry export -f requirements.txt --output requirements.txt --without-hashes +RUN poetry export -f requirements.txt --output requirements.txt --without-hashes --without dev -FROM python:3.10 +ENV VIRTUAL_ENV=/opt/venv +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +RUN python -m venv $VIRTUAL_ENV && \ + pip install --no-cache-dir --upgrade -r requirements.txt + +FROM python:3.10-slim WORKDIR /app -COPY --from=build /tmp/requirements.txt /code/requirements.txt -RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt +ENV VIRTUAL_ENV=/opt/venv +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +COPY --from=build $VIRTUAL_ENV $VIRTUAL_ENV COPY resa_padel/ /app/ -CMD python . \ No newline at end of file +CMD python . diff --git a/README.md b/README.md index 1c43cfb..81a6443 100644 --- a/README.md +++ b/README.md @@ -1,2 +1 @@ # resa-padel - diff --git a/poetry.lock b/poetry.lock index 3e7a2a1..4055470 100644 --- a/poetry.lock +++ b/poetry.lock @@ -165,35 +165,56 @@ tests = ["attrs[tests-no-zope]", "zope-interface"] tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "black" -version = "24.1.1" +version = "24.2.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-24.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2588021038bd5ada078de606f2a804cadd0a3cc6a79cb3e9bb3a8bf581325a4c"}, - {file = "black-24.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a95915c98d6e32ca43809d46d932e2abc5f1f7d582ffbe65a5b4d1588af7445"}, - {file = "black-24.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa6a0e965779c8f2afb286f9ef798df770ba2b6cee063c650b96adec22c056a"}, - {file = "black-24.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5242ecd9e990aeb995b6d03dc3b2d112d4a78f2083e5a8e86d566340ae80fec4"}, - {file = "black-24.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fc1ec9aa6f4d98d022101e015261c056ddebe3da6a8ccfc2c792cbe0349d48b7"}, - {file = "black-24.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0269dfdea12442022e88043d2910429bed717b2d04523867a85dacce535916b8"}, - {file = "black-24.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3d64db762eae4a5ce04b6e3dd745dcca0fb9560eb931a5be97472e38652a161"}, - {file = "black-24.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5d7b06ea8816cbd4becfe5f70accae953c53c0e53aa98730ceccb0395520ee5d"}, - {file = "black-24.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e2c8dfa14677f90d976f68e0c923947ae68fa3961d61ee30976c388adc0b02c8"}, - {file = "black-24.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a21725862d0e855ae05da1dd25e3825ed712eaaccef6b03017fe0853a01aa45e"}, - {file = "black-24.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07204d078e25327aad9ed2c64790d681238686bce254c910de640c7cc4fc3aa6"}, - {file = "black-24.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a83fe522d9698d8f9a101b860b1ee154c1d25f8a82ceb807d319f085b2627c5b"}, - {file = "black-24.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:08b34e85170d368c37ca7bf81cf67ac863c9d1963b2c1780c39102187ec8dd62"}, - {file = "black-24.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7258c27115c1e3b5de9ac6c4f9957e3ee2c02c0b39222a24dc7aa03ba0e986f5"}, - {file = "black-24.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40657e1b78212d582a0edecafef133cf1dd02e6677f539b669db4746150d38f6"}, - {file = "black-24.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e298d588744efda02379521a19639ebcd314fba7a49be22136204d7ed1782717"}, - {file = "black-24.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34afe9da5056aa123b8bfda1664bfe6fb4e9c6f311d8e4a6eb089da9a9173bf9"}, - {file = "black-24.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:854c06fb86fd854140f37fb24dbf10621f5dab9e3b0c29a690ba595e3d543024"}, - {file = "black-24.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3897ae5a21ca132efa219c029cce5e6bfc9c3d34ed7e892113d199c0b1b444a2"}, - {file = "black-24.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:ecba2a15dfb2d97105be74bbfe5128bc5e9fa8477d8c46766505c1dda5883aac"}, - {file = "black-24.1.1-py3-none-any.whl", hash = "sha256:5cdc2e2195212208fbcae579b931407c1fa9997584f0a415421748aeafff1168"}, - {file = "black-24.1.1.tar.gz", hash = "sha256:48b5760dcbfe5cf97fd4fba23946681f3a81514c6ab8a45b50da67ac8fbc6c7b"}, + {file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"}, + {file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"}, + {file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"}, + {file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"}, + {file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"}, + {file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"}, + {file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"}, + {file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"}, + {file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"}, + {file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"}, + {file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"}, + {file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"}, + {file = "black-24.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4"}, + {file = "black-24.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218"}, + {file = "black-24.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0"}, + {file = "black-24.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d"}, + {file = "black-24.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8"}, + {file = "black-24.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8"}, + {file = "black-24.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540"}, + {file = "black-24.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31"}, + {file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"}, + {file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"}, ] [package.dependencies] @@ -211,6 +232,17 @@ d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + [[package]] name = "click" version = "8.1.7" @@ -236,6 +268,17 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + [[package]] name = "exceptiongroup" version = "1.2.0" @@ -250,6 +293,22 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "filelock" +version = "3.13.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, + {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + [[package]] name = "frozenlist" version = "1.4.1" @@ -347,6 +406,20 @@ files = [ {file = "icdiff-2.0.7.tar.gz", hash = "sha256:f79a318891adbf59a45e3a7694f5e1f18c5407065264637072ac8363b759866f"}, ] +[[package]] +name = "identify" +version = "2.5.35" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, +] + +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" version = "3.6" @@ -579,6 +652,20 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + [[package]] name = "packaging" version = "23.2" @@ -741,20 +828,38 @@ files = [ {file = "pprintpp-0.4.0.tar.gz", hash = "sha256:ea826108e2c7f49dc6d66c752973c3fc9749142a798d6b254e1e301cfdbc6403"}, ] +[[package]] +name = "pre-commit" +version = "3.6.2" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c"}, + {file = "pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + [[package]] name = "pydantic" -version = "2.6.1" +version = "2.6.3" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.6.1-py3-none-any.whl", hash = "sha256:0b6a909df3192245cb736509a92ff69e4fef76116feffec68e93a567347bae6f"}, - {file = "pydantic-2.6.1.tar.gz", hash = "sha256:4fd5c182a2488dc63e6d32737ff19937888001e2a6d86e94b3f233104a5d1fa9"}, + {file = "pydantic-2.6.3-py3-none-any.whl", hash = "sha256:72c6034df47f46ccdf81869fddb81aade68056003900a8724a4f160700016a2a"}, + {file = "pydantic-2.6.3.tar.gz", hash = "sha256:e07805c4c7f5c6826e33a1d4c9d47950d7eaf34868e2690f8594d2e30241f11f"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.16.2" +pydantic-core = "2.16.3" typing-extensions = ">=4.6.1" [package.extras] @@ -762,90 +867,90 @@ email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.16.2" +version = "2.16.3" description = "" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.16.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3fab4e75b8c525a4776e7630b9ee48aea50107fea6ca9f593c98da3f4d11bf7c"}, - {file = "pydantic_core-2.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8bde5b48c65b8e807409e6f20baee5d2cd880e0fad00b1a811ebc43e39a00ab2"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2924b89b16420712e9bb8192396026a8fbd6d8726224f918353ac19c4c043d2a"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16aa02e7a0f539098e215fc193c8926c897175d64c7926d00a36188917717a05"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:936a787f83db1f2115ee829dd615c4f684ee48ac4de5779ab4300994d8af325b"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:459d6be6134ce3b38e0ef76f8a672924460c455d45f1ad8fdade36796df1ddc8"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9ee4febb249c591d07b2d4dd36ebcad0ccd128962aaa1801508320896575ef"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40a0bd0bed96dae5712dab2aba7d334a6c67cbcac2ddfca7dbcc4a8176445990"}, - {file = "pydantic_core-2.16.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:870dbfa94de9b8866b37b867a2cb37a60c401d9deb4a9ea392abf11a1f98037b"}, - {file = "pydantic_core-2.16.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:308974fdf98046db28440eb3377abba274808bf66262e042c412eb2adf852731"}, - {file = "pydantic_core-2.16.2-cp310-none-win32.whl", hash = "sha256:a477932664d9611d7a0816cc3c0eb1f8856f8a42435488280dfbf4395e141485"}, - {file = "pydantic_core-2.16.2-cp310-none-win_amd64.whl", hash = "sha256:8f9142a6ed83d90c94a3efd7af8873bf7cefed2d3d44387bf848888482e2d25f"}, - {file = "pydantic_core-2.16.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:406fac1d09edc613020ce9cf3f2ccf1a1b2f57ab00552b4c18e3d5276c67eb11"}, - {file = "pydantic_core-2.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ce232a6170dd6532096cadbf6185271e4e8c70fc9217ebe105923ac105da9978"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a90fec23b4b05a09ad988e7a4f4e081711a90eb2a55b9c984d8b74597599180f"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8aafeedb6597a163a9c9727d8a8bd363a93277701b7bfd2749fbefee2396469e"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9957433c3a1b67bdd4c63717eaf174ebb749510d5ea612cd4e83f2d9142f3fc8"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0d7a9165167269758145756db43a133608a531b1e5bb6a626b9ee24bc38a8f7"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dffaf740fe2e147fedcb6b561353a16243e654f7fe8e701b1b9db148242e1272"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8ed79883b4328b7f0bd142733d99c8e6b22703e908ec63d930b06be3a0e7113"}, - {file = "pydantic_core-2.16.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cf903310a34e14651c9de056fcc12ce090560864d5a2bb0174b971685684e1d8"}, - {file = "pydantic_core-2.16.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:46b0d5520dbcafea9a8645a8164658777686c5c524d381d983317d29687cce97"}, - {file = "pydantic_core-2.16.2-cp311-none-win32.whl", hash = "sha256:70651ff6e663428cea902dac297066d5c6e5423fda345a4ca62430575364d62b"}, - {file = "pydantic_core-2.16.2-cp311-none-win_amd64.whl", hash = "sha256:98dc6f4f2095fc7ad277782a7c2c88296badcad92316b5a6e530930b1d475ebc"}, - {file = "pydantic_core-2.16.2-cp311-none-win_arm64.whl", hash = "sha256:ef6113cd31411eaf9b39fc5a8848e71c72656fd418882488598758b2c8c6dfa0"}, - {file = "pydantic_core-2.16.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:88646cae28eb1dd5cd1e09605680c2b043b64d7481cdad7f5003ebef401a3039"}, - {file = "pydantic_core-2.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7b883af50eaa6bb3299780651e5be921e88050ccf00e3e583b1e92020333304b"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bf26c2e2ea59d32807081ad51968133af3025c4ba5753e6a794683d2c91bf6e"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99af961d72ac731aae2a1b55ccbdae0733d816f8bfb97b41909e143de735f522"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02906e7306cb8c5901a1feb61f9ab5e5c690dbbeaa04d84c1b9ae2a01ebe9379"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5362d099c244a2d2f9659fb3c9db7c735f0004765bbe06b99be69fbd87c3f15"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ac426704840877a285d03a445e162eb258924f014e2f074e209d9b4ff7bf380"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b94cbda27267423411c928208e89adddf2ea5dd5f74b9528513f0358bba019cb"}, - {file = "pydantic_core-2.16.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6db58c22ac6c81aeac33912fb1af0e930bc9774166cdd56eade913d5f2fff35e"}, - {file = "pydantic_core-2.16.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396fdf88b1b503c9c59c84a08b6833ec0c3b5ad1a83230252a9e17b7dfb4cffc"}, - {file = "pydantic_core-2.16.2-cp312-none-win32.whl", hash = "sha256:7c31669e0c8cc68400ef0c730c3a1e11317ba76b892deeefaf52dcb41d56ed5d"}, - {file = "pydantic_core-2.16.2-cp312-none-win_amd64.whl", hash = "sha256:a3b7352b48fbc8b446b75f3069124e87f599d25afb8baa96a550256c031bb890"}, - {file = "pydantic_core-2.16.2-cp312-none-win_arm64.whl", hash = "sha256:a9e523474998fb33f7c1a4d55f5504c908d57add624599e095c20fa575b8d943"}, - {file = "pydantic_core-2.16.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:ae34418b6b389d601b31153b84dce480351a352e0bb763684a1b993d6be30f17"}, - {file = "pydantic_core-2.16.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:732bd062c9e5d9582a30e8751461c1917dd1ccbdd6cafb032f02c86b20d2e7ec"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b52776a2e3230f4854907a1e0946eec04d41b1fc64069ee774876bbe0eab55"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef551c053692b1e39e3f7950ce2296536728871110e7d75c4e7753fb30ca87f4"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ebb892ed8599b23fa8f1799e13a12c87a97a6c9d0f497525ce9858564c4575a4"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa6c8c582036275997a733427b88031a32ffa5dfc3124dc25a730658c47a572f"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4ba0884a91f1aecce75202473ab138724aa4fb26d7707f2e1fa6c3e68c84fbf"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7924e54f7ce5d253d6160090ddc6df25ed2feea25bfb3339b424a9dd591688bc"}, - {file = "pydantic_core-2.16.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69a7b96b59322a81c2203be537957313b07dd333105b73db0b69212c7d867b4b"}, - {file = "pydantic_core-2.16.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7e6231aa5bdacda78e96ad7b07d0c312f34ba35d717115f4b4bff6cb87224f0f"}, - {file = "pydantic_core-2.16.2-cp38-none-win32.whl", hash = "sha256:41dac3b9fce187a25c6253ec79a3f9e2a7e761eb08690e90415069ea4a68ff7a"}, - {file = "pydantic_core-2.16.2-cp38-none-win_amd64.whl", hash = "sha256:f685dbc1fdadb1dcd5b5e51e0a378d4685a891b2ddaf8e2bba89bd3a7144e44a"}, - {file = "pydantic_core-2.16.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:55749f745ebf154c0d63d46c8c58594d8894b161928aa41adbb0709c1fe78b77"}, - {file = "pydantic_core-2.16.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b30b0dd58a4509c3bd7eefddf6338565c4905406aee0c6e4a5293841411a1286"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18de31781cdc7e7b28678df7c2d7882f9692ad060bc6ee3c94eb15a5d733f8f7"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5864b0242f74b9dd0b78fd39db1768bc3f00d1ffc14e596fd3e3f2ce43436a33"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8f9186ca45aee030dc8234118b9c0784ad91a0bb27fc4e7d9d6608a5e3d386c"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc6f6c9be0ab6da37bc77c2dda5f14b1d532d5dbef00311ee6e13357a418e646"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa057095f621dad24a1e906747179a69780ef45cc8f69e97463692adbcdae878"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ad84731a26bcfb299f9eab56c7932d46f9cad51c52768cace09e92a19e4cf55"}, - {file = "pydantic_core-2.16.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3b052c753c4babf2d1edc034c97851f867c87d6f3ea63a12e2700f159f5c41c3"}, - {file = "pydantic_core-2.16.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e0f686549e32ccdb02ae6f25eee40cc33900910085de6aa3790effd391ae10c2"}, - {file = "pydantic_core-2.16.2-cp39-none-win32.whl", hash = "sha256:7afb844041e707ac9ad9acad2188a90bffce2c770e6dc2318be0c9916aef1469"}, - {file = "pydantic_core-2.16.2-cp39-none-win_amd64.whl", hash = "sha256:9da90d393a8227d717c19f5397688a38635afec89f2e2d7af0df037f3249c39a"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f60f920691a620b03082692c378661947d09415743e437a7478c309eb0e4f82"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:47924039e785a04d4a4fa49455e51b4eb3422d6eaacfde9fc9abf8fdef164e8a"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6294e76b0380bb7a61eb8a39273c40b20beb35e8c87ee101062834ced19c545"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe56851c3f1d6f5384b3051c536cc81b3a93a73faf931f404fef95217cf1e10d"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9d776d30cde7e541b8180103c3f294ef7c1862fd45d81738d156d00551005784"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:72f7919af5de5ecfaf1eba47bf9a5d8aa089a3340277276e5636d16ee97614d7"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:4bfcbde6e06c56b30668a0c872d75a7ef3025dc3c1823a13cf29a0e9b33f67e8"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ff7c97eb7a29aba230389a2661edf2e9e06ce616c7e35aa764879b6894a44b25"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9b5f13857da99325dcabe1cc4e9e6a3d7b2e2c726248ba5dd4be3e8e4a0b6d0e"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7e41e3ada4cca5f22b478c08e973c930e5e6c7ba3588fb8e35f2398cdcc1545"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60eb8ceaa40a41540b9acae6ae7c1f0a67d233c40dc4359c256ad2ad85bdf5e5"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7beec26729d496a12fd23cf8da9944ee338c8b8a17035a560b585c36fe81af20"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22c5f022799f3cd6741e24f0443ead92ef42be93ffda0d29b2597208c94c3753"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:eca58e319f4fd6df004762419612122b2c7e7d95ffafc37e890252f869f3fb2a"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed957db4c33bc99895f3a1672eca7e80e8cda8bd1e29a80536b4ec2153fa9804"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:459c0d338cc55d099798618f714b21b7ece17eb1a87879f2da20a3ff4c7628e2"}, - {file = "pydantic_core-2.16.2.tar.gz", hash = "sha256:0ba503850d8b8dcc18391f10de896ae51d37fe5fe43dbfb6a35c5c5cad271a06"}, + {file = "pydantic_core-2.16.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4"}, + {file = "pydantic_core-2.16.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99"}, + {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979"}, + {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db"}, + {file = "pydantic_core-2.16.3-cp310-none-win32.whl", hash = "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132"}, + {file = "pydantic_core-2.16.3-cp310-none-win_amd64.whl", hash = "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb"}, + {file = "pydantic_core-2.16.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4"}, + {file = "pydantic_core-2.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f"}, + {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e"}, + {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba"}, + {file = "pydantic_core-2.16.3-cp311-none-win32.whl", hash = "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721"}, + {file = "pydantic_core-2.16.3-cp311-none-win_amd64.whl", hash = "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df"}, + {file = "pydantic_core-2.16.3-cp311-none-win_arm64.whl", hash = "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9"}, + {file = "pydantic_core-2.16.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff"}, + {file = "pydantic_core-2.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e"}, + {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca"}, + {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf"}, + {file = "pydantic_core-2.16.3-cp312-none-win32.whl", hash = "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe"}, + {file = "pydantic_core-2.16.3-cp312-none-win_amd64.whl", hash = "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed"}, + {file = "pydantic_core-2.16.3-cp312-none-win_arm64.whl", hash = "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6"}, + {file = "pydantic_core-2.16.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01"}, + {file = "pydantic_core-2.16.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c"}, + {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8"}, + {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5"}, + {file = "pydantic_core-2.16.3-cp38-none-win32.whl", hash = "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a"}, + {file = "pydantic_core-2.16.3-cp38-none-win_amd64.whl", hash = "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed"}, + {file = "pydantic_core-2.16.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820"}, + {file = "pydantic_core-2.16.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8"}, + {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b"}, + {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972"}, + {file = "pydantic_core-2.16.3-cp39-none-win32.whl", hash = "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2"}, + {file = "pydantic_core-2.16.3-cp39-none-win_amd64.whl", hash = "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da"}, + {file = "pydantic_core-2.16.3.tar.gz", hash = "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad"}, ] [package.dependencies] @@ -853,30 +958,30 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pydantic-extra-types" -version = "2.5.0" +version = "2.6.0" description = "Extra Pydantic types." optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_extra_types-2.5.0-py3-none-any.whl", hash = "sha256:7346873019cac32061b471adf2cdac711664ddb7a6ede04219bed2da34888c4d"}, - {file = "pydantic_extra_types-2.5.0.tar.gz", hash = "sha256:46b85240093dc63ad4a8f3cab49e03d76ae0577e4f99e2bbff7d32f99d009bf9"}, + {file = "pydantic_extra_types-2.6.0-py3-none-any.whl", hash = "sha256:d291d521c2e2bf2e6f11971caf8d639518124ae26a76d2e712599e98c4ef2b2b"}, + {file = "pydantic_extra_types-2.6.0.tar.gz", hash = "sha256:e9a93cfb245158462acb76621785219f80ad112303a0a7784d2ada65e6ed6cba"}, ] [package.dependencies] pydantic = ">=2.5.2" [package.extras] -all = ["pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<9)", "pycountry (>=23,<24)", "python-ulid (>=1,<2)"] +all = ["pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<9)", "pycountry (>=23)", "python-ulid (>=1,<2)", "python-ulid (>=1,<3)"] [[package]] name = "pytest" -version = "8.0.0" +version = "8.0.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.0.0-py3-none-any.whl", hash = "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6"}, - {file = "pytest-8.0.0.tar.gz", hash = "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c"}, + {file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"}, + {file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"}, ] [package.dependencies] @@ -961,13 +1066,13 @@ dev = ["black", "flake8", "pre-commit"] [[package]] name = "python-dateutil" -version = "2.8.2" +version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] [package.dependencies] @@ -1049,30 +1154,46 @@ files = [ [[package]] name = "ruff" -version = "0.2.1" +version = "0.2.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dd81b911d28925e7e8b323e8d06951554655021df8dd4ac3045d7212ac4ba080"}, - {file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dc586724a95b7d980aa17f671e173df00f0a2eef23f8babbeee663229a938fec"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c92db7101ef5bfc18e96777ed7bc7c822d545fa5977e90a585accac43d22f18a"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13471684694d41ae0f1e8e3a7497e14cd57ccb7dd72ae08d56a159d6c9c3e30e"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a11567e20ea39d1f51aebd778685582d4c56ccb082c1161ffc10f79bebe6df35"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:00a818e2db63659570403e44383ab03c529c2b9678ba4ba6c105af7854008105"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be60592f9d218b52f03384d1325efa9d3b41e4c4d55ea022cd548547cc42cd2b"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd2288890b88e8aab4499e55148805b58ec711053588cc2f0196a44f6e3d855"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ef052283da7dec1987bba8d8733051c2325654641dfe5877a4022108098683"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7022d66366d6fded4ba3889f73cd791c2d5621b2ccf34befc752cb0df70f5fad"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0a725823cb2a3f08ee743a534cb6935727d9e47409e4ad72c10a3faf042ad5ba"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0034d5b6323e6e8fe91b2a1e55b02d92d0b582d2953a2b37a67a2d7dedbb7acc"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e5cb5526d69bb9143c2e4d2a115d08ffca3d8e0fddc84925a7b54931c96f5c02"}, - {file = "ruff-0.2.1-py3-none-win32.whl", hash = "sha256:6b95ac9ce49b4fb390634d46d6ece32ace3acdd52814671ccaf20b7f60adb232"}, - {file = "ruff-0.2.1-py3-none-win_amd64.whl", hash = "sha256:e3affdcbc2afb6f5bd0eb3130139ceedc5e3f28d206fe49f63073cb9e65988e0"}, - {file = "ruff-0.2.1-py3-none-win_arm64.whl", hash = "sha256:efababa8e12330aa94a53e90a81eb6e2d55f348bc2e71adbf17d9cad23c03ee6"}, - {file = "ruff-0.2.1.tar.gz", hash = "sha256:3b42b5d8677cd0c72b99fcaf068ffc62abb5a19e71b4a3b9cfa50658a0af02f1"}, + {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"}, + {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"}, + {file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"}, + {file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"}, + {file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"}, + {file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"}, ] +[[package]] +name = "setuptools" +version = "69.1.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"}, + {file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "six" version = "1.16.0" @@ -1084,6 +1205,17 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "soupsieve" +version = "2.5" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, + {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, +] + [[package]] name = "termcolor" version = "2.4.0" @@ -1111,26 +1243,46 @@ files = [ [[package]] name = "typing-extensions" -version = "4.9.0" +version = "4.10.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] [[package]] name = "tzdata" -version = "2023.4" +version = "2024.1" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2023.4-py2.py3-none-any.whl", hash = "sha256:aa3ace4329eeacda5b7beb7ea08ece826c28d761cda36e747cfbf97996d39bf3"}, - {file = "tzdata-2023.4.tar.gz", hash = "sha256:dd54c94f294765522c77399649b4fefd95522479a664a0cec87f41bebc6148c9"}, + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, ] +[[package]] +name = "virtualenv" +version = "20.25.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + [[package]] name = "yarl" version = "1.9.4" @@ -1237,4 +1389,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "eb2292ededdcd551249bd05bdc2fd3d38dfeeba495cb3ea8dd7ba5dfd9250980" +content-hash = "b542ec9550a6b162afec437cfeef6648cbe99de6831e80e4423fac7219d2a3e4" diff --git a/pyproject.toml b/pyproject.toml index 94f0cd2..e312206 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ pydantic = "^2.6.1" pydantic-extra-types = "^2.5.0" python-dotenv = "^1.0.1" jinja2 = "^3.1.3" +beautifulsoup4 = "^4.12.3" [tool.poetry.group.dev.dependencies] black = "^24.1.1" @@ -25,14 +26,24 @@ pytest-sugar = "^1.0.0" pytest-icdiff = "^0.9" pytest-asyncio = "^0.23.5" pytest-aioresponses = "^0.2.0" +pre-commit = "^3.6.2" [tool.ruff] line-length = 88 +[tool.isort] +profile = "black" + [tool.pytest.ini_options] pythonpath = [ "resa_padel" ] +log_cli = 1 +log_cli_level = "DEBUG" +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "uncertain", +] [build-system] requires = ["poetry-core"] diff --git a/resa_padel/__init__.py b/resa_padel/__init__.py index 8b13789..e69de29 100644 --- a/resa_padel/__init__.py +++ b/resa_padel/__init__.py @@ -1 +0,0 @@ - diff --git a/resa_padel/booking.py b/resa_padel/booking.py index 144358d..2a0b7ec 100644 --- a/resa_padel/booking.py +++ b/resa_padel/booking.py @@ -1,53 +1,13 @@ import asyncio import logging -import time import config -import pendulum -from aiohttp import ClientSession -from gestion_sports.gestion_sports_connector import GestionSportsConnector +from gestion_sports.gestion_sports_platform import GestionSportsPlatform from models import BookingFilter, Club, User -from pendulum import DateTime LOGGER = logging.getLogger(__name__) -def wait_until_booking_time(club: Club, booking_filter: BookingFilter): - """ - Wait until the booking is open. - The booking filter contains the date and time of the booking. - The club has the information about when the booking is open for that date. - - :param club: the club where to book a court - :param booking_filter: the booking information - """ - LOGGER.info("Waiting booking time") - booking_datetime = build_booking_datetime(booking_filter, club) - now = pendulum.now() - while now < booking_datetime: - time.sleep(1) - now = pendulum.now() - - -def build_booking_datetime(booking_filter: BookingFilter, club: Club) -> DateTime: - """ - Build the date and time when the booking is open for a given match date. - The booking filter contains the date and time of the booking. - The club has the information about when the booking is open for that date. - - :param booking_filter: the booking information - :param club: the club where to book a court - :return: the date and time when the booking is open - """ - date_to_book = booking_filter.date - booking_date = date_to_book.subtract(days=club.booking_open_days_before) - - booking_hour = club.booking_opening_time.hour - booking_minute = club.booking_opening_time.minute - - return booking_date.at(booking_hour, booking_minute) - - async def book(club: Club, user: User, booking_filter: BookingFilter) -> int | None: """ Book a court for a user to a club following a booking filter @@ -57,12 +17,23 @@ async def book(club: Club, user: User, booking_filter: BookingFilter) -> int | N :param booking_filter: the information related to the booking :return: the id of the booked court, or None if no court was booked """ - async with ClientSession() as session: - platform = GestionSportsConnector(session, club.url) - await platform.land() - await platform.login(user, club) - wait_until_booking_time(club, booking_filter) - return await platform.book(booking_filter, club) + async with GestionSportsPlatform(club) as platform: + return await platform.book(user, booking_filter) + + +async def get_user_without_booking(club: Club, users: list[User]) -> User | None: + """ + Return the first user who has no booking + + :param club: the club where to book + :param users: the list of users + :return: any user who has no booking + """ + async with GestionSportsPlatform(club) as platform: + for user in users: + if await platform.user_has_no_ongoing_booking(user): + return user + return None def main() -> int | None: @@ -71,12 +42,12 @@ def main() -> int | None: :return: the id of the booked court, or None if no court was booked """ - user = config.get_user() booking_filter = config.get_booking_filter() club = config.get_club() + user = asyncio.run(get_user_without_booking(club, config.get_available_users())) LOGGER.info( - "Starting booking court of %s for user %s at club %s at %s", + "Starting booking court of sport %s for user %s at club %s at %s", booking_filter.sport_id, user.login, club.id, diff --git a/resa_padel/config.py b/resa_padel/config.py index ad15c18..749ea5e 100644 --- a/resa_padel/config.py +++ b/resa_padel/config.py @@ -64,6 +64,25 @@ def get_user() -> User: return User(login=login, password=password) +def get_available_users() -> list[User]: + """ + Read the environment variables to get all the available users in order + to increase the chance of having a user with a free slot for a booking + + :return: the list of all users that can book a court + """ + available_users_credentials = os.environ.get("AVAILABLE_USERS_CREDENTIALS") + available_users = [ + credential for credential in available_users_credentials.split(",") + ] + users = [] + for user in available_users: + login, password = user.split(":") + users.append(User(login=login, password=password)) + + return users + + def get_post_headers(platform_id: str) -> dict: """ Get the headers for the POST endpoint related to a specific booking platform diff --git a/resa_padel/gestion_sports/gestion_sports_config.py b/resa_padel/gestion_sports/gestion_sports_config.py index 4f342c2..ea5b852 100644 --- a/resa_padel/gestion_sports/gestion_sports_config.py +++ b/resa_padel/gestion_sports/gestion_sports_config.py @@ -3,5 +3,7 @@ from pathlib import Path import config RESOURCES_DIR = Path(config.RESOURCES_DIR, "gestion-sports") + BOOKING_TEMPLATE = Path(RESOURCES_DIR, "booking-payload.txt") LOGIN_TEMPLATE = Path(RESOURCES_DIR, "login-payload.txt") +USERS_BOOKINGS_TEMPLATE = Path(RESOURCES_DIR, "users_bookings.txt") diff --git a/resa_padel/gestion_sports/gestion_sports_connector.py b/resa_padel/gestion_sports/gestion_sports_connector.py index 85d6eb2..de5dca3 100644 --- a/resa_padel/gestion_sports/gestion_sports_connector.py +++ b/resa_padel/gestion_sports/gestion_sports_connector.py @@ -5,16 +5,14 @@ from urllib.parse import urljoin import config from aiohttp import ClientResponse, ClientSession +from gestion_sports import gestion_sports_html_parser as html_parser from gestion_sports.payload_builders import ( GestionSportsBookingPayloadBuilder, GestionSportsLoginPayloadBuilder, + GestionSportsUsersBookingsPayloadBuilder, ) from models import BookingFilter, Club, User -DATE_FORMAT = "%d/%m/%Y" - -TIME_FORMAT = "%H:%M" - LOGGER = logging.getLogger(__name__) POST_HEADERS = config.get_post_headers("gestion-sports") @@ -36,7 +34,7 @@ class GestionSportsConnector: :return: the URL to the landing page """ - return urljoin(self.url, "/connexion.php?") + return urljoin(self.url, "/connexion.php") @property def login_url(self) -> str: @@ -45,7 +43,7 @@ class GestionSportsConnector: :return: the URL to the login page """ - return urljoin(self.url, "/connexion.php?") + return urljoin(self.url, "/connexion.php") @property def booking_url(self) -> str: @@ -54,7 +52,16 @@ class GestionSportsConnector: :return: the URL to the booking page """ - return urljoin(self.url, "/membre/reservation.html?") + return urljoin(self.url, "/membre/reservation.html") + + @property + def user_bookings_url(self) -> str: + """ + Get the URL where all the user's bookings are available + + :return: the URL to the user's bookings + """ + return urljoin(self.url, "/membre/mesresas.html") async def land(self) -> ClientResponse: """ @@ -62,7 +69,7 @@ class GestionSportsConnector: :return: the response from the landing page """ - LOGGER.info("Connecting to GestionSports API") + LOGGER.info("Connecting to GestionSports API at %s", self.login_url) async with self.session.get(self.landing_url) as response: await response.text() return response @@ -73,18 +80,21 @@ class GestionSportsConnector: :return: the response from the login """ + LOGGER.info("Logging in to GestionSports API at %s", self.login_url) payload_builder = GestionSportsLoginPayloadBuilder() payload = payload_builder.user(user).club(club).build() async with self.session.post( self.login_url, data=payload, headers=POST_HEADERS ) as response: - await response.text() + resp_text = await response.text() + LOGGER.debug("Connexion request response:\n%s", resp_text) return response async def book(self, booking_filter: BookingFilter, club: Club) -> int | None: """ - Perform a request for each court at the same time to increase the chances to get a booking. + Perform a request for each court at the same time to increase the chances to get + a booking. The gestion-sports backend does not allow several bookings at the same time so there is no need to make each request one after the other @@ -92,6 +102,9 @@ class GestionSportsConnector: :param club: the club where to book the court :return: the booked court, or None if no court was booked """ + LOGGER.info( + "Booking any available court from GestionSports API at %s", self.booking_url + ) # use asyncio to request a booking on every court # the gestion-sports backend is able to book only one court for a user bookings = await asyncio.gather( @@ -102,36 +115,85 @@ class GestionSportsConnector: return_exceptions=True, ) + LOGGER.debug("Booking results:\n'%s'", bookings) return self.get_booked_court(bookings) - @staticmethod - def get_booked_court(bookings): - LOGGER.info(bookings) - for court, is_booked in bookings: - if is_booked: - return court - return None - async def book_one_court( self, booking_filter: BookingFilter, court_id: int ) -> tuple[int, bool]: + """ + Book a single court according to the information provided in the booking filter + + :param booking_filter: the booking information + :param court_id: the id of the court to book + :return: a tuple containing the court id and the booking status + """ + LOGGER.debug( + "Booking court %s at %s", + court_id, + booking_filter.date.to_w3c_string(), + ) payload_builder = GestionSportsBookingPayloadBuilder() payload = ( payload_builder.booking_filter(booking_filter).court_id(court_id).build() ) - LOGGER.info(payload) + LOGGER.debug("Booking court %s request:\n'%s'", court_id, payload) async with self.session.post( self.booking_url, data=payload, headers=POST_HEADERS ) as response: resp_json = await response.text() - - return court_id, self.is_response_status_ok(resp_json) + LOGGER.debug("Response from booking request:\n'%s'", resp_json) + return court_id, self.is_booking_response_status_ok(resp_json) @staticmethod - def is_response_status_ok(response: str) -> bool: + def get_booked_court(bookings: list[tuple[int, bool]]) -> int | None: + """ + Parse the booking list and return the court that was booked - LOGGER.info(response) + :param bookings: a list of bookings + :return: the id of the booked court if any, None otherwise + """ + for court, is_booked in bookings: + if is_booked: + LOGGER.debug("Court %s is booked", court) + return court + LOGGER.debug("No booked court found") + return None + + @staticmethod + def is_booking_response_status_ok(response: str) -> bool: + """ + Check if the booking response is OK + + :param response: the response as a string + :return: true if the status is ok, false otherwise + """ formatted_result = response.removeprefix('"').removesuffix('"') result_json = json.loads(formatted_result) return result_json["status"] == "ok" + + async def get_ongoing_bookings(self) -> dict: + """ + Get the list of all ongoing bookings of a user. + The steps to perform this are to get the user's bookings page and get a hidden + property in the HTML to get a hash that will be used in the payload of the + POST request (sic) to get the user's bookings. + Gestion sports is really a mess!! + + :return: the list of all ongoing bookings of a user + """ + async with self.session.get(self.user_bookings_url) as get_resp: + html = await get_resp.text() + hash_value = html_parser.get_hash_input(html) + + payload_builder = GestionSportsUsersBookingsPayloadBuilder() + payload_builder.hash(hash_value) + payload = payload_builder.build() + + async with self.session.post( + self.user_bookings_url, data=payload, headers=POST_HEADERS + ) as response: + resp = await response.text() + LOGGER.debug("ongoing bookings response: %s\n", resp) + return json.loads(resp) diff --git a/resa_padel/gestion_sports/gestion_sports_html_parser.py b/resa_padel/gestion_sports/gestion_sports_html_parser.py new file mode 100644 index 0000000..ed7f12a --- /dev/null +++ b/resa_padel/gestion_sports/gestion_sports_html_parser.py @@ -0,0 +1,16 @@ +from bs4 import BeautifulSoup + + +def get_hash_input(html_doc: str) -> str: + """ + There is a secret hash generated by Gestion sports that is reused when trying to get + users bookings. This hash is stored in a hidden input with name "mesresas-hash" + + :param html_doc: the html document when getting the page mes-resas.html + :return: the value of the hash in the page + """ + soup = BeautifulSoup(html_doc, "html.parser") + inputs = soup.find_all("input") + for input_tag in inputs: + if input_tag.get("name") == "mesresas-hash": + return input_tag.get("value").strip() diff --git a/resa_padel/gestion_sports/gestion_sports_platform.py b/resa_padel/gestion_sports/gestion_sports_platform.py new file mode 100644 index 0000000..35679ce --- /dev/null +++ b/resa_padel/gestion_sports/gestion_sports_platform.py @@ -0,0 +1,116 @@ +import logging +import time + +import pendulum +from aiohttp import ClientSession +from gestion_sports.gestion_sports_connector import GestionSportsConnector +from models import BookingFilter, Club, User +from pendulum import DateTime + +LOGGER = logging.getLogger(__name__) + + +class GestionSportsPlatform: + def __init__(self, club: Club): + LOGGER.info("Initializing Gestion Sports platform at url %s", club.url) + self.connector: GestionSportsConnector | None = None + self.club: Club = club + self.session: ClientSession | None = None + + async def __aenter__(self): + self.session = ClientSession() + self.connector = GestionSportsConnector(self.session, self.club.url) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.session.close() + + async def book(self, user: User, booking_filter: BookingFilter) -> int | None: + """ + Book a court matching the booking filters for a user. + The steps to perform a booking are to go to the landing page, to log in, wait + and for the time when booking is open and then actually book the court + + :param user: the user that wants to book a court + :param booking_filter: the booking criteria + :return: the court number if the booking is successful, None otherwise + """ + if self.connector is None: + LOGGER.error("No connection to Gestion Sports is available") + return None + + if user is None or booking_filter is None: + LOGGER.error("Not enough information available to book a court") + return None + + await self.connector.land() + await self.connector.login(user, self.club) + wait_until_booking_time(self.club, booking_filter) + return await self.connector.book(booking_filter, self.club) + + async def user_has_no_ongoing_booking(self, user: User) -> bool: + """ + Check if the user has any ongoing booking. + The steps to perform this task are to go to the landing page, to log in and + then retrieve user information and extract the ongoing bookings + + :param user: the user to check the bookings + :return: True if the user has ongoing bookings, false otherwise + """ + if self.connector is None: + LOGGER.error("No connection to Gestion Sports is available") + return False + + if user is None: + LOGGER.error("Not enough information available to book a court") + return False + + await self.connector.land() + await self.connector.login(user, self.club) + bookings = await self.connector.get_ongoing_bookings() + return bookings == [] + + +def wait_until_booking_time(club: Club, booking_filter: BookingFilter): + """ + Wait until the booking is open. + The booking filter contains the date and time of the booking. + The club has the information about when the booking is open for that date. + + :param club: the club where to book a court + :param booking_filter: the booking information + """ + LOGGER.info("Waiting for booking time") + booking_datetime = build_booking_datetime(booking_filter, club) + now = pendulum.now() + duration_until_booking = booking_datetime - now + LOGGER.debug( + "Time to wait before booking: %s:%s:%s", + "{:0>2}".format(duration_until_booking.hours), + "{:0>2}".format(duration_until_booking.minutes), + "{:0>2}".format(duration_until_booking.seconds), + ) + + while now < booking_datetime: + time.sleep(1) + now = pendulum.now() + LOGGER.info("It's booking time!") + + +def build_booking_datetime(booking_filter: BookingFilter, club: Club) -> DateTime: + """ + Build the date and time when the booking is open for a given match date. + The booking filter contains the date and time of the booking. + The club has the information about when the booking is open for that date. + + :param booking_filter: the booking information + :param club: the club where to book a court + :return: the date and time when the booking is open + """ + date_to_book = booking_filter.date + booking_date = date_to_book.subtract(days=club.booking_open_days_before) + + booking_hour = club.booking_opening_time.hour + booking_minute = club.booking_opening_time.minute + + return booking_date.at(booking_hour, booking_minute) diff --git a/resa_padel/gestion_sports/payload_builders.py b/resa_padel/gestion_sports/payload_builders.py index 287febb..1509a52 100644 --- a/resa_padel/gestion_sports/payload_builders.py +++ b/resa_padel/gestion_sports/payload_builders.py @@ -1,31 +1,55 @@ from exceptions import ArgumentMissing -from gestion_sports.gestion_sports_config import BOOKING_TEMPLATE, LOGIN_TEMPLATE +from gestion_sports.gestion_sports_config import ( + BOOKING_TEMPLATE, + LOGIN_TEMPLATE, + USERS_BOOKINGS_TEMPLATE, +) from jinja2 import Environment, FileSystemLoader from models import BookingFilter, Club, User class GestionSportsLoginPayloadBuilder: + """ + Build the payload for the login page + """ + def __init__(self): self._user: User | None = None self._club: Club | None = None - self._template = LOGIN_TEMPLATE def user(self, user: User): + """ + Set the user + + :param user: the user + :return: the class itself + """ self._user = user return self def club(self, club: Club): + """ + Set the club + + :param club: the club + :return: the class itself + """ self._club = club return self - def build(self): + def build(self) -> str: + """ + Build the payload + + :return: the string representation of the payload + """ if self._user is None: raise ArgumentMissing("No user was provided") if self._club is None: raise ArgumentMissing("No club was provided") - environment = Environment(loader=FileSystemLoader(self._template.parent)) - template = environment.get_template(self._template.name) + environment = Environment(loader=FileSystemLoader(LOGIN_TEMPLATE.parent)) + template = environment.get_template(LOGIN_TEMPLATE.name) return template.render(club=self._club, user=self._user) @@ -34,25 +58,71 @@ class GestionSportsBookingPayloadBuilder: def __init__(self): self._booking_filter: BookingFilter | None = None self._court_id: int | None = None - self._template = BOOKING_TEMPLATE def booking_filter(self, booking_filter: BookingFilter): + """ + Set the booking filter + + :param booking_filter: the booking filter + :return: the class itself + """ self._booking_filter = booking_filter return self def court_id(self, court_id: int): + """ + Set the court id + + :param court_id: the court id + :return: the class itself + """ self._court_id = court_id return self - def build(self): + def build(self) -> str: + """ + Build the payload + + :return: the string representation of the payload + """ if self._booking_filter is None: raise ArgumentMissing("No booking filter was provided") if self.court_id is None: raise ArgumentMissing("No court id was provided") - environment = Environment(loader=FileSystemLoader(self._template.parent)) - template = environment.get_template(self._template.name) + environment = Environment(loader=FileSystemLoader(BOOKING_TEMPLATE.parent)) + template = environment.get_template(BOOKING_TEMPLATE.name) return template.render( court_id=self._court_id, booking_filter=self._booking_filter ) + + +class GestionSportsUsersBookingsPayloadBuilder: + def __init__(self): + self._hash: str | None = None + + def hash(self, hash_value: str): + """ + Set the hash + + :param hash_value: the hash + :return: the class itself + """ + self._hash = hash_value + + def build(self) -> str: + """ + Build the payload + + :return: the string representation of the payload + """ + if self._hash is None: + raise ArgumentMissing("No hash was provided") + + environment = Environment( + loader=FileSystemLoader(USERS_BOOKINGS_TEMPLATE.parent) + ) + template = environment.get_template(USERS_BOOKINGS_TEMPLATE.name) + + return template.render(hash=self._hash) diff --git a/resa_padel/resources/gestion-sports/booking-payload.txt b/resa_padel/resources/gestion-sports/booking-payload.txt index f2f5fe3..b5a7cc5 100644 --- a/resa_padel/resources/gestion-sports/booking-payload.txt +++ b/resa_padel/resources/gestion-sports/booking-payload.txt @@ -1 +1 @@ -ajax=addResa&date={{ booking_filter.date.date().strftime("%d/%m/%Y") }}&hour={{ booking_filter.date.time().strftime("%H:%M") }}&duration=90&partners=null|null|null&paiement=facultatif&idSport={{ booking_filter.sport_id }}&creaPartie=false&idCourt={{ court_id }}&pay=false&token=undefined&totalPrice=44&saveCard=0&foodNumber=0 \ No newline at end of file +ajax=addResa&date={{ booking_filter.date.date().strftime("%d/%m/%Y") }}&hour={{ booking_filter.date.time().strftime("%H:%M") }}&duration=90&partners=null|null|null&paiement=facultatif&idSport={{ booking_filter.sport_id }}&creaPartie=false&idCourt={{ court_id }}&pay=false&token=undefined&totalPrice=44&saveCard=0&foodNumber=0 diff --git a/resa_padel/resources/gestion-sports/login-payload.txt b/resa_padel/resources/gestion-sports/login-payload.txt index 5d64938..b0da065 100644 --- a/resa_padel/resources/gestion-sports/login-payload.txt +++ b/resa_padel/resources/gestion-sports/login-payload.txt @@ -1 +1 @@ -ajax=connexionUser&id_club={{ club.id }}&email={{ user.login }}&form_ajax=1&pass={{ user.password }}&compte=user&playeridonesignal=0&identifiant=identifiant&externCo=true \ No newline at end of file +ajax=connexionUser&id_club={{ club.id }}&email={{ user.login }}&form_ajax=1&pass={{ user.password }}&compte=user&playeridonesignal=0&identifiant=identifiant&externCo=true diff --git a/resa_padel/resources/gestion-sports/post-headers.json b/resa_padel/resources/gestion-sports/post-headers.json index dfde57e..8adccc1 100644 --- a/resa_padel/resources/gestion-sports/post-headers.json +++ b/resa_padel/resources/gestion-sports/post-headers.json @@ -9,4 +9,4 @@ "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin" -} \ No newline at end of file +} diff --git a/resa_padel/resources/gestion-sports/users_bookings.txt b/resa_padel/resources/gestion-sports/users_bookings.txt new file mode 100644 index 0000000..bc971e6 --- /dev/null +++ b/resa_padel/resources/gestion-sports/users_bookings.txt @@ -0,0 +1 @@ +ajax=loadResa&hash={{ hash }} diff --git a/tests/data/mes_resas.html b/tests/data/mes_resas.html new file mode 100644 index 0000000..c59445a --- /dev/null +++ b/tests/data/mes_resas.html @@ -0,0 +1,1363 @@ + + + + + + Mes réservations + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ + + Retour + + + + + +
+ +
+ Mes réservations
+
+ + + + +
+
+ + + +
+
+
+ + +
+
+
Mes événements
+
+
+
+
+
+ +
+
Mes réservations
+ +
+
+
+
+
Suivi de vos réservations par sport
+
+ + Heure pleines + Heure creuses +
+
+ Padel + +
+
0 /1
+
0 /1
+
+
+ +
+
illimitées
+
illimitées
+
+
+
+
+ Squash + +
+
0 /2
+
0 /2
+
+
+ +
+
illimitées
+
illimitées
+
+
+
+
+ Electrostimulation + +
+
illimitées
+
illimitées
+
+
+ +
+
illimitées
+
illimitées
+
+
+
+
+
+ + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + Accueil +
+
+ + +
+ + Actualités +
+
+ + +
+
+ + Réserver +
+
+ +
+ + +
+ + Compte +
+
+ + +
+ + Menu +
+
+ +
+ + + + + + + + + + + + + + + + + + +
+
+
+
+ image + Le Mas + à l'instant +
+ + + +
+
+
+

Titre de la notif

+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. +
+
+
+
+
+ + + + + + + + + + + +
+
+ +
+ Votre inscription à la partie de Tennis a bien été enregistrée !
+ Rendez-vous le 14 Janvier de 16h30 à 17h30. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures.py b/tests/fixtures.py index 220ee92..bef8442 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,4 +1,5 @@ import json +from pathlib import Path import pendulum import pytest @@ -42,6 +43,9 @@ booking_payload = ( .build() ) +html_file = Path(__file__).parent / "data" / "mes_resas.html" +_mes_resas_html = html_file.read_text(encoding="utf-8") + @pytest.fixture def a_user() -> User: @@ -71,3 +75,8 @@ def a_booking_failure_response() -> str: @pytest.fixture def a_booking_payload() -> str: return booking_payload + + +@pytest.fixture +def mes_resas_html() -> str: + return _mes_resas_html diff --git a/tests/gestion_sports/test_gestion_sports_connector.py b/tests/gestion_sports/test_gestion_sports_connector.py index c7731dd..c279263 100644 --- a/tests/gestion_sports/test_gestion_sports_connector.py +++ b/tests/gestion_sports/test_gestion_sports_connector.py @@ -13,6 +13,25 @@ from tests.fixtures import ( ) tpc_url = "https://toulousepadelclub.gestion-sports.com" +TPC_COURTS = [ + None, + 596, + 597, + 598, + 599, + 600, + 601, + 602, + 603, + 604, + 605, + 606, + 607, + 608, + 609, + 610, + 611, +] @pytest.mark.asyncio @@ -64,6 +83,7 @@ async def test_should_login_to_gestion_sports_website( @pytest.mark.asyncio +@pytest.mark.slow async def test_booking_url_should_be_reachable( a_user: User, a_booking_filter: BookingFilter, a_club: Club ) -> None: @@ -81,7 +101,7 @@ async def test_booking_url_should_be_reachable( court_booked = await gs_connector.book(a_booking_filter, a_club) # At 18:00 no chance to get a booking, any day of the week - assert court_booked is None + assert court_booked in TPC_COURTS @pytest.mark.asyncio @@ -126,7 +146,9 @@ def test_response_status_should_be_ok(a_booking_success_response: str) -> None: :param a_booking_success_response: the success response mock """ - is_booked = GestionSportsConnector.is_response_status_ok(a_booking_success_response) + is_booked = GestionSportsConnector.is_booking_response_status_ok( + a_booking_success_response + ) assert is_booked @@ -137,5 +159,27 @@ def test_response_status_should_be_not_ok(a_booking_failure_response: str) -> No :param a_booking_failure_response: the failure response mock """ - is_booked = GestionSportsConnector.is_response_status_ok(a_booking_failure_response) + is_booked = GestionSportsConnector.is_booking_response_status_ok( + a_booking_failure_response + ) assert not is_booked + + +@pytest.mark.asyncio +@pytest.mark.slow +async def test_get_user_ongoing_bookings(a_user: User, a_club: Club) -> None: + """ + Test that the user has 2 ongoing bookings + + :param a_user: + :param a_club: + :return: + """ + async with ClientSession() as session: + gs_connector = GestionSportsConnector(session, tpc_url) + await gs_connector.land() + await gs_connector.login(a_user, a_club) + + bookings = await gs_connector.get_ongoing_bookings() + + assert len(bookings) == 0 diff --git a/tests/gestion_sports/test_gestion_sports_html_parser.py b/tests/gestion_sports/test_gestion_sports_html_parser.py new file mode 100644 index 0000000..314e3e3 --- /dev/null +++ b/tests/gestion_sports/test_gestion_sports_html_parser.py @@ -0,0 +1,8 @@ +from gestion_sports import gestion_sports_html_parser as parser + +from tests.fixtures import mes_resas_html + + +def test_html_parser(mes_resas_html): + hash_value = parser.get_hash_input(mes_resas_html) + assert hash_value == "ef4403f4c44fa91060a92476aae011a2184323ec" diff --git a/tests/gestion_sports/test_gestion_sports_payload_builder.py b/tests/gestion_sports/test_gestion_sports_payload_builder.py index 0b917c3..4b7d23d 100644 --- a/tests/gestion_sports/test_gestion_sports_payload_builder.py +++ b/tests/gestion_sports/test_gestion_sports_payload_builder.py @@ -1,6 +1,7 @@ from resa_padel.gestion_sports.payload_builders import ( GestionSportsBookingPayloadBuilder, GestionSportsLoginPayloadBuilder, + GestionSportsUsersBookingsPayloadBuilder, ) from tests.fixtures import a_booking_filter, a_club, a_user @@ -48,3 +49,13 @@ def test_booking_payload_should_be_built(a_booking_filter): ) assert booking_payload == expected_payload + + +def test_users_bookings_payload_should_be_built(): + builder = GestionSportsUsersBookingsPayloadBuilder() + builder.hash("super_hash") + expected_payload = "ajax=loadResa&hash=super_hash" + + actual_payload = builder.build() + + assert actual_payload == expected_payload diff --git a/tests/gestion_sports/test_gestion_sports_platform.py b/tests/gestion_sports/test_gestion_sports_platform.py new file mode 100644 index 0000000..7332bc5 --- /dev/null +++ b/tests/gestion_sports/test_gestion_sports_platform.py @@ -0,0 +1,87 @@ +from unittest.mock import patch + +import pendulum +import pytest +from aioresponses import aioresponses +from gestion_sports.gestion_sports_platform import ( + GestionSportsPlatform, + wait_until_booking_time, +) +from models import BookingFilter, Club, User + +from tests import fixtures, utils +from tests.fixtures import ( + a_booking_failure_response, + a_booking_filter, + a_booking_success_response, + a_club, + a_user, + mes_resas_html, +) + + +@pytest.mark.asyncio +@patch("pendulum.now") +async def test_booking( + mock_now, + a_booking_success_response: str, + a_booking_failure_response: str, + a_user: User, + a_club: Club, + a_booking_filter: BookingFilter, + mes_resas_html: str, +): + """ + Test a single court booking without reading the conf from environment variables + + :param mock_now: the pendulum.now() mock + :param a_booking_success_response: the success json response + :param a_booking_failure_response: the failure json response + :param a_user: a test user + :param a_club:a test club + :param a_booking_filter: a test booking filter + """ + booking_datetime = utils.retrieve_booking_datetime(a_booking_filter, a_club) + mock_now.side_effect = [booking_datetime] + + # mock connection to the booking platform + with aioresponses() as aio_mock: + utils.mock_rest_api_from_connection_to_booking( + aio_mock, + fixtures.url, + a_booking_failure_response, + a_booking_success_response, + mes_resas_html, + ) + + async with GestionSportsPlatform(a_club) as gs_operations: + court_booked = await gs_operations.book(a_user, a_booking_filter) + assert court_booked == a_club.courts_ids[1] + + +@patch("pendulum.now") +def test_wait_until_booking_time( + mock_now, a_club: Club, a_booking_filter: BookingFilter +): + """ + Test the function that waits until the booking can be performed + + :param mock_now: the pendulum.now() mock + :param a_club: a club + :param a_booking_filter: a booking filter + """ + booking_datetime = utils.retrieve_booking_datetime(a_booking_filter, a_club) + + seconds = [ + booking_datetime.subtract(seconds=3), + booking_datetime.subtract(seconds=2), + booking_datetime.subtract(seconds=1), + booking_datetime, + booking_datetime.add(microseconds=1), + booking_datetime.add(microseconds=2), + ] + mock_now.side_effect = seconds + + wait_until_booking_time(a_club, a_booking_filter) + + assert pendulum.now() == booking_datetime.add(microseconds=1) diff --git a/tests/test_booking.py b/tests/test_booking.py index e2667fe..b60ae4d 100644 --- a/tests/test_booking.py +++ b/tests/test_booking.py @@ -1,25 +1,22 @@ -import asyncio import os from unittest.mock import patch -from urllib.parse import urljoin import pendulum from aioresponses import aioresponses -from models import BookingFilter, Club, User -from pendulum import DateTime, Time +from models import BookingFilter, Club +from pendulum import Time from resa_padel import booking -from tests import fixtures +from tests import fixtures, utils from tests.fixtures import ( a_booking_failure_response, - a_booking_filter, a_booking_success_response, - a_club, - a_user, + mes_resas_html, ) login = "user" password = "password" +available_credentials = login + ":" + password + ",some_user:some_password" club_id = "88" court_id = "11" paris_tz = "Europe/Paris" @@ -28,160 +25,6 @@ datetime_to_book = ( ) -def mock_successful_connection(aio_mock, url): - """ - Mock a call to the connection endpoint - - :param aio_mock: the aioresponses mock object - :param url: the URL of the connection endpoint - """ - aio_mock.get( - url, - status=200, - headers={"Set-Cookie": f"connection_called=True; Domain={url}"}, - ) - - -def mock_successful_login(aio_mock, url): - """ - Mock a call to the login endpoint - - :param aio_mock: the aioresponses mock object - :param url: the URL of the login endpoint - """ - aio_mock.post( - url, - status=200, - headers={"Set-Cookie": f"login_called=True; Domain={url}"}, - ) - - -def mock_booking(aio_mock, url, response): - """ - Mock a call to the booking endpoint - - :param aio_mock: the aioresponses mock object - :param url: the URL of the booking endpoint - :param response: the response from the booking endpoint - """ - aio_mock.post( - url, - status=200, - headers={"Set-Cookie": f"booking_called=True; Domain={url}"}, - body=response, - ) - - -def mock_rest_api_from_connection_to_booking( - aio_mock, url: str, a_booking_failure_response: str, a_booking_success_response: str -): - """ - Mock a REST API from a club. - It mocks the calls to the connexion to the website, a call to log in the user - and 2 calls to the booking endpoint - - :param mock_now: the pendulum.now() mock - :param url: the API root URL - :param a_booking_success_response: the success json response - :param a_booking_failure_response: the failure json response - :return: - """ - connexion_url = urljoin(url, "/connexion.php?") - mock_successful_connection(aio_mock, connexion_url) - - login_url = urljoin(url, "/connexion.php?") - mock_successful_login(aio_mock, login_url) - - booking_url = urljoin(url, "/membre/reservation.html?") - mock_booking(aio_mock, booking_url, a_booking_failure_response) - mock_booking(aio_mock, booking_url, a_booking_success_response) - - -@patch("pendulum.now") -def test_wait_until_booking_time( - mock_now, a_club: Club, a_booking_filter: BookingFilter -): - """ - Test the function that waits until the booking can be performed - - :param mock_now: the pendulum.now() mock - :param a_club: a club - :param a_booking_filter: a booking filter - """ - booking_datetime = retrieve_booking_datetime(a_booking_filter, a_club) - - seconds = [ - booking_datetime.subtract(seconds=3), - booking_datetime.subtract(seconds=2), - booking_datetime.subtract(seconds=1), - booking_datetime, - booking_datetime.add(microseconds=1), - booking_datetime.add(microseconds=2), - ] - mock_now.side_effect = seconds - - booking.wait_until_booking_time(a_club, a_booking_filter) - - assert pendulum.now() == booking_datetime.add(microseconds=1) - - -def retrieve_booking_datetime( - a_booking_filter: BookingFilter, a_club: Club -) -> DateTime: - """ - Utility to retrieve the booking datetime from the booking filter and the club - - :param a_booking_filter: the booking filter that contains the date to book - :param a_club: the club which has the number of days before the date and the booking time - """ - booking_hour = a_club.booking_opening_time.hour - booking_minute = a_club.booking_opening_time.minute - - date_to_book = a_booking_filter.date - return date_to_book.subtract(days=a_club.booking_open_days_before).at( - booking_hour, booking_minute - ) - - -@patch("pendulum.now") -def test_booking_does_the_rights_calls( - mock_now, - a_booking_success_response: str, - a_booking_failure_response: str, - a_user: User, - a_club: Club, - a_booking_filter: BookingFilter, -): - """ - Test a single court booking without reading the conf from environment variables - - :param mock_now: the pendulum.now() mock - :param a_booking_success_response: the success json response - :param a_booking_failure_response: the failure json response - :param a_user: a test user - :param a_club:a test club - :param a_booking_filter: a test booking filter - """ - booking_datetime = retrieve_booking_datetime(a_booking_filter, a_club) - mock_now.side_effect = [booking_datetime] - - # mock connection to the booking platform - with aioresponses() as aio_mock: - mock_rest_api_from_connection_to_booking( - aio_mock, - fixtures.url, - a_booking_failure_response, - a_booking_success_response, - ) - - loop = asyncio.get_event_loop() - - court_booked = loop.run_until_complete( - booking.book(a_club, a_user, a_booking_filter) - ) - assert court_booked == a_club.courts_ids[1] - - @patch("pendulum.now") @patch.dict( os.environ, @@ -193,11 +36,15 @@ def test_booking_does_the_rights_calls( "COURT_IDS": "7,8,10", "SPORT_ID": "217", "DATE_TIME": datetime_to_book.isoformat(), + "AVAILABLE_USERS_CREDENTIALS": available_credentials, }, clear=True, ) def test_main( - mock_now, a_booking_success_response: str, a_booking_failure_response: str + mock_now, + a_booking_success_response: str, + a_booking_failure_response: str, + mes_resas_html: str, ): """ Test the main function to book a court @@ -214,15 +61,16 @@ def test_main( booking_open_days_before=7, booking_opening_time=Time(hour=0, minute=0), ) - booking_datetime = retrieve_booking_datetime(booking_filter, club) + booking_datetime = utils.retrieve_booking_datetime(booking_filter, club) mock_now.side_effect = [booking_datetime] with aioresponses() as aio_mock: - mock_rest_api_from_connection_to_booking( + utils.mock_rest_api_from_connection_to_booking( aio_mock, fixtures.url, a_booking_failure_response, a_booking_success_response, + mes_resas_html, ) court_booked = booking.main() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..0c1e25b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,74 @@ +import os +from unittest.mock import patch + +import config +from pendulum import DateTime, Time, Timezone + + +@patch.dict( + os.environ, + { + "CLUB_URL": "club.url", + "COURT_IDS": "7,8,10", + "CLUB_ID": "666", + "BOOKING_OPEN_DAYS_BEFORE": "5", + "BOOKING_OPENING_TIME": "18:37", + }, + clear=True, +) +def test_get_club(): + club = config.get_club() + assert club.url == "club.url" + assert club.courts_ids == [7, 8, 10] + assert club.id == "666" + assert club.booking_open_days_before == 5 + assert club.booking_opening_time == Time(hour=18, minute=37) + + +@patch.dict( + os.environ, + { + "SPORT_ID": "666", + "DATE_TIME": "2024-02-03T22:38:45Z", + }, + clear=True, +) +def test_get_booking_filter(): + booking_filter = config.get_booking_filter() + assert booking_filter.sport_id == 666 + assert booking_filter.date == DateTime( + year=2024, + month=2, + day=3, + hour=23, + minute=38, + second=45, + tzinfo=Timezone("Europe/Paris"), + ) + + +@patch.dict( + os.environ, + { + "LOGIN": "login@user.tld", + "PASSWORD": "gloups", + }, + clear=True, +) +def test_get_available_user(): + user = config.get_user() + assert user.login == "login@user.tld" + assert user.password == "gloups" + + +@patch.dict( + os.environ, + {"AVAILABLE_USERS_CREDENTIALS": "login@user.tld:gloups,other@user.tld:patatras"}, + clear=True, +) +def test_user(): + users = config.get_available_users() + assert users[0].login == "login@user.tld" + assert users[0].password == "gloups" + assert users[1].login == "other@user.tld" + assert users[1].password == "patatras" diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..c5383bb --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,119 @@ +from urllib.parse import urljoin + +from models import BookingFilter, Club +from pendulum import DateTime + +from tests.fixtures import ( + a_booking_failure_response, + a_booking_filter, + a_booking_success_response, + a_club, + mes_resas_html, +) + + +def mock_successful_connection(aio_mock, url): + """ + Mock a call to the connection endpoint + + :param aio_mock: the aioresponses mock object + :param url: the URL of the connection endpoint + """ + aio_mock.get( + url, + status=200, + headers={"Set-Cookie": f"connection_called=True; Domain={url}"}, + ) + + +def mock_successful_login(aio_mock, url): + """ + Mock a call to the login endpoint + + :param aio_mock: the aioresponses mock object + :param url: the URL of the login endpoint + """ + aio_mock.post( + url, + status=200, + headers={"Set-Cookie": f"login_called=True; Domain={url}"}, + ) + + +def mock_booking(aio_mock, url, response): + """ + Mock a call to the booking endpoint + + :param aio_mock: the aioresponses mock object + :param url: the URL of the booking endpoint + :param response: the response from the booking endpoint + """ + aio_mock.post( + url, + status=200, + headers={"Set-Cookie": f"booking_called=True; Domain={url}"}, + body=response, + ) + + +def retrieve_booking_datetime( + a_booking_filter: BookingFilter, a_club: Club +) -> DateTime: + """ + Utility to retrieve the booking datetime from the booking filter and the club + + :param a_booking_filter: the booking filter that contains the date to book + :param a_club: the club which has the number of days before the date and the booking time + """ + booking_hour = a_club.booking_opening_time.hour + booking_minute = a_club.booking_opening_time.minute + + date_to_book = a_booking_filter.date + return date_to_book.subtract(days=a_club.booking_open_days_before).at( + booking_hour, booking_minute + ) + + +def mock_get_users_booking(aio_mock, url: str, booking_response: str): + return aio_mock.get(url, body=booking_response) + + +def mock_post_users_booking(aio_mock, url: str): + return aio_mock.post(url, payload=[]) + + +def mock_rest_api_from_connection_to_booking( + aio_mock, + url: str, + a_booking_failure_response: str, + a_booking_success_response: str, + mes_resas_html: str, +): + """ + Mock a REST API from a club. + It mocks the calls to the connexion to the website, a call to log in the user + and 2 calls to the booking endpoint + + :param aio_mock: the pendulum.now() mock + :param url: the API root URL + :param a_booking_success_response: the success json response + :param a_booking_failure_response: the failure json response + :param mes_resas_html: the html response for getting the bookings + :return: + """ + connexion_url = urljoin(url, "/connexion.php?") + mock_successful_connection(aio_mock, connexion_url) + mock_successful_connection(aio_mock, connexion_url) + + login_url = urljoin(url, "/connexion.php?") + mock_successful_login(aio_mock, login_url) + mock_successful_login(aio_mock, login_url) + + users_bookings_url = urljoin(url, "/membre/mesresas.html") + mock_get_users_booking(aio_mock, users_bookings_url, mes_resas_html) + mock_post_users_booking(aio_mock, users_bookings_url) + + booking_url = urljoin(url, "/membre/reservation.html?") + mock_booking(aio_mock, booking_url, a_booking_failure_response) + mock_booking(aio_mock, booking_url, a_booking_success_response) + mock_booking(aio_mock, booking_url, a_booking_failure_response)