mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-16 04:11:50 +01:00
Compare commits
13 Commits
release
...
fix/877-su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47c6311eb | ||
|
|
1abe233de5 | ||
|
|
6e562164b8 | ||
|
|
62fd5f6003 | ||
|
|
868f81239a | ||
|
|
67a4db0db6 | ||
|
|
03dbd54e85 | ||
|
|
80c0baf29f | ||
|
|
ddbe88e422 | ||
|
|
712b96e2f4 | ||
|
|
71028ea844 | ||
|
|
e151f0d104 | ||
|
|
5c4e0cc90d |
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
# https://github.com/github/codeql-action/blob/main/init/action.yml
|
||||
with:
|
||||
languages: actions,python
|
||||
@@ -102,5 +102,5 @@ jobs:
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
# https://github.com/github/codeql-action
|
||||
|
||||
566
pdm.lock
generated
566
pdm.lock
generated
@@ -5,7 +5,7 @@
|
||||
groups = ["default", "dev"]
|
||||
strategy = ["inherit_metadata"]
|
||||
lock_version = "4.5.0"
|
||||
content_hash = "sha256:8bd6fb4ab1ba3453b86efce9f5b6ac9c7285a31cdf144db6838811cd30b6ff52"
|
||||
content_hash = "sha256:1e64ae11f0ff2b537b3583f5f1be4c070edde5e2d87795767d2b7de929d9fd28"
|
||||
|
||||
[[metadata.targets]]
|
||||
requires_python = ">=3.10,<3.15"
|
||||
@@ -45,6 +45,17 @@ files = [
|
||||
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.4.0"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Classes Without Boilerplate"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"},
|
||||
{file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autopep8"
|
||||
version = "2.3.2"
|
||||
@@ -163,93 +174,93 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
version = "3.4.5"
|
||||
requires_python = ">=3.7"
|
||||
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"},
|
||||
{file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"},
|
||||
{file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05"},
|
||||
{file = "charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a"},
|
||||
{file = "charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636"},
|
||||
{file = "charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7"},
|
||||
{file = "charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa"},
|
||||
{file = "charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4"},
|
||||
{file = "charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0"},
|
||||
{file = "charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -595,13 +606,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.25.0"
|
||||
version = "3.25.2"
|
||||
requires_python = ">=3.10"
|
||||
summary = "A platform independent file lock."
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047"},
|
||||
{file = "filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3"},
|
||||
{file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"},
|
||||
{file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -628,7 +639,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-context"
|
||||
version = "6.1.0"
|
||||
version = "6.1.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Useful decorators and context managers"
|
||||
groups = ["default"]
|
||||
@@ -636,8 +647,8 @@ dependencies = [
|
||||
"backports-tarfile; python_version < \"3.12\"",
|
||||
]
|
||||
files = [
|
||||
{file = "jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda"},
|
||||
{file = "jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f"},
|
||||
{file = "jaraco_context-6.1.1-py3-none-any.whl", hash = "sha256:0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808"},
|
||||
{file = "jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -671,6 +682,37 @@ files = [
|
||||
{file = "jaraco_text-4.2.0.tar.gz", hash = "sha256:194e386aa5b15a6616019df87a6b29c00fd3c9c8b0475731b64633ca7afd495b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
requires_python = ">=3.10"
|
||||
summary = "An implementation of JSON Schema validation for Python"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"attrs>=22.2.0",
|
||||
"jsonschema-specifications>=2023.03.6",
|
||||
"referencing>=0.28.4",
|
||||
"rpds-py>=0.25.0",
|
||||
]
|
||||
files = [
|
||||
{file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"},
|
||||
{file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"referencing>=0.31.0",
|
||||
]
|
||||
files = [
|
||||
{file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"},
|
||||
{file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.8.1"
|
||||
@@ -1137,13 +1179,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.2"
|
||||
version = "4.9.4"
|
||||
requires_python = ">=3.10"
|
||||
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
groups = ["default", "dev"]
|
||||
files = [
|
||||
{file = "platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd"},
|
||||
{file = "platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291"},
|
||||
{file = "platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"},
|
||||
{file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1385,7 +1427,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller-hooks-contrib"
|
||||
version = "2026.1"
|
||||
version = "2026.3"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Community maintained hooks for PyInstaller"
|
||||
groups = ["dev"]
|
||||
@@ -1395,8 +1437,8 @@ dependencies = [
|
||||
"setuptools>=42.0.0",
|
||||
]
|
||||
files = [
|
||||
{file = "pyinstaller_hooks_contrib-2026.1-py3-none-any.whl", hash = "sha256:66ad4888ba67de6f3cfd7ef554f9dd1a4389e2eb19f84d7129a5a6818e3f2180"},
|
||||
{file = "pyinstaller_hooks_contrib-2026.1.tar.gz", hash = "sha256:a5f0891a1e81e92406ab917d9e76adfd7a2b68415ee2e35c950a7b3910bc361b"},
|
||||
{file = "pyinstaller_hooks_contrib-2026.3-py3-none-any.whl", hash = "sha256:5ecd1068ad262afecadf07556279d2be52ca93a88b049fae17f1a2eb2969254a"},
|
||||
{file = "pyinstaller_hooks_contrib-2026.3.tar.gz", hash = "sha256:800d3a198a49a6cd0de2d7fb795005fdca7a0222ed9cb47c0691abd1c27b9310"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1518,6 +1560,22 @@ files = [
|
||||
{file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
requires_python = ">=3.10"
|
||||
summary = "JSON Referencing + Python"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"attrs>=22.2.0",
|
||||
"rpds-py>=0.7.0",
|
||||
"typing-extensions>=4.4.0; python_version < \"3.13\"",
|
||||
]
|
||||
files = [
|
||||
{file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"},
|
||||
{file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
@@ -1550,6 +1608,130 @@ files = [
|
||||
{file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.30.0"
|
||||
requires_python = ">=3.10"
|
||||
summary = "Python bindings to Rust's persistent data structures (rpds)"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"},
|
||||
{file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"},
|
||||
{file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"},
|
||||
{file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"},
|
||||
{file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"},
|
||||
{file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"},
|
||||
{file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"},
|
||||
{file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruamel-yaml"
|
||||
version = "0.19.1"
|
||||
@@ -1563,29 +1745,29 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.4"
|
||||
version = "0.15.6"
|
||||
requires_python = ">=3.7"
|
||||
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0"},
|
||||
{file = "ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992"},
|
||||
{file = "ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec"},
|
||||
{file = "ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f"},
|
||||
{file = "ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338"},
|
||||
{file = "ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc"},
|
||||
{file = "ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68"},
|
||||
{file = "ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3"},
|
||||
{file = "ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22"},
|
||||
{file = "ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f"},
|
||||
{file = "ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453"},
|
||||
{file = "ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1"},
|
||||
{file = "ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff"},
|
||||
{file = "ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3"},
|
||||
{file = "ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e"},
|
||||
{file = "ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c"},
|
||||
{file = "ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512"},
|
||||
{file = "ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0"},
|
||||
{file = "ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb"},
|
||||
{file = "ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0"},
|
||||
{file = "ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c"},
|
||||
{file = "ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406"},
|
||||
{file = "ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837"},
|
||||
{file = "ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1616,13 +1798,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "82.0.0"
|
||||
version = "82.0.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||
summary = "Most extensible Python build backend with support for C/C++ extension modules"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0"},
|
||||
{file = "setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb"},
|
||||
{file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"},
|
||||
{file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1892,76 +2074,90 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "2.1.1"
|
||||
version = "2.1.2"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Module for decorators, wrappers and monkey patching."
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "wrapt-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7e927375e43fd5a985b27a8992327c22541b6dede1362fc79df337d26e23604f"},
|
||||
{file = "wrapt-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c99544b6a7d40ca22195563b6d8bc3986ee8bb82f272f31f0670fe9440c869"},
|
||||
{file = "wrapt-2.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b2be3fa5f4efaf16ee7c77d0556abca35f5a18ad4ac06f0ef3904c3399010ce9"},
|
||||
{file = "wrapt-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67c90c1ae6489a6cb1a82058902caa8006706f7b4e8ff766f943e9d2c8e608d0"},
|
||||
{file = "wrapt-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05c0db35ccffd7480143e62df1e829d101c7b86944ae3be7e4869a7efa621f53"},
|
||||
{file = "wrapt-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0c2ec9f616755b2e1e0bf4d0961f59bb5c2e7a77407e7e2c38ef4f7d2fdde12c"},
|
||||
{file = "wrapt-2.1.1-cp310-cp310-win32.whl", hash = "sha256:203ba6b3f89e410e27dbd30ff7dccaf54dcf30fda0b22aa1b82d560c7f9fe9a1"},
|
||||
{file = "wrapt-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f9426d9cfc2f8732922fc96198052e55c09bb9db3ddaa4323a18e055807410e"},
|
||||
{file = "wrapt-2.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c26f51b67076b40714cff81bdd5826c0b10c077fb6b0678393a6a2f952a5fc"},
|
||||
{file = "wrapt-2.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c366434a7fb914c7a5de508ed735ef9c133367114e1a7cb91dfb5cd806a1549"},
|
||||
{file = "wrapt-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d6a2068bd2e1e19e5a317c8c0b288267eec4e7347c36bc68a6e378a39f19ee7"},
|
||||
{file = "wrapt-2.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:891ab4713419217b2aed7dd106c9200f64e6a82226775a0d2ebd6bef2ebd1747"},
|
||||
{file = "wrapt-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8ef36a0df38d2dc9d907f6617f89e113c5892e0a35f58f45f75901af0ce7d81"},
|
||||
{file = "wrapt-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:76e9af3ebd86f19973143d4d592cbf3e970cf3f66ddee30b16278c26ae34b8ab"},
|
||||
{file = "wrapt-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ff562067485ebdeaef2fa3fe9b1876bc4e7b73762e0a01406ad81e2076edcebf"},
|
||||
{file = "wrapt-2.1.1-cp311-cp311-win32.whl", hash = "sha256:9e60a30aa0909435ec4ea2a3c53e8e1b50ac9f640c0e9fe3f21fd248a22f06c5"},
|
||||
{file = "wrapt-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7d79954f51fcf84e5ec4878ab4aea32610d70145c5bbc84b3370eabfb1e096c2"},
|
||||
{file = "wrapt-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:d3ffc6b0efe79e08fd947605fd598515aebefe45e50432dc3b5cd437df8b1ada"},
|
||||
{file = "wrapt-2.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab8e3793b239db021a18782a5823fcdea63b9fe75d0e340957f5828ef55fcc02"},
|
||||
{file = "wrapt-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c0300007836373d1c2df105b40777986accb738053a92fe09b615a7a4547e9f"},
|
||||
{file = "wrapt-2.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2b27c070fd1132ab23957bcd4ee3ba707a91e653a9268dc1afbd39b77b2799f7"},
|
||||
{file = "wrapt-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b0e36d845e8b6f50949b6b65fc6cd279f47a1944582ed4ec8258cd136d89a64"},
|
||||
{file = "wrapt-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aeea04a9889370fcfb1ef828c4cc583f36a875061505cd6cd9ba24d8b43cc36"},
|
||||
{file = "wrapt-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d88b46bb0dce9f74b6817bc1758ff2125e1ca9e1377d62ea35b6896142ab6825"},
|
||||
{file = "wrapt-2.1.1-cp312-cp312-win32.whl", hash = "sha256:63decff76ca685b5c557082dfbea865f3f5f6d45766a89bff8dc61d336348833"},
|
||||
{file = "wrapt-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b828235d26c1e35aca4107039802ae4b1411be0fe0367dd5b7e4d90e562fcbcd"},
|
||||
{file = "wrapt-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:75128507413a9f1bcbe2db88fd18fbdbf80f264b82fa33a6996cdeaf01c52352"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9646e17fa7c3e2e7a87e696c7de66512c2b4f789a8db95c613588985a2e139"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:428cfc801925454395aa468ba7ddb3ed63dc0d881df7b81626cdd433b4e2b11b"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5797f65e4d58065a49088c3b32af5410751cd485e83ba89e5a45e2aa8905af98"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a2db44a71202c5ae4bb5f27c6d3afbc5b23053f2e7e78aa29704541b5dad789"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8d5350c3590af09c1703dd60ec78a7370c0186e11eaafb9dda025a30eee6492d"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d9b076411bed964e752c01b49fd224cc385f3a96f520c797d38412d70d08359"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313-win32.whl", hash = "sha256:0bb7207130ce6486727baa85373503bf3334cc28016f6928a0fa7e19d7ecdc06"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:cbfee35c711046b15147b0ae7db9b976f01c9520e6636d992cd9e69e5e2b03b1"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:7d2756061022aebbf57ba14af9c16e8044e055c22d38de7bf40d92b565ecd2b0"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4814a3e58bc6971e46baa910ecee69699110a2bf06c201e24277c65115a20c20"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:106c5123232ab9b9f4903692e1fa0bdc231510098f04c13c3081f8ad71c3d612"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1a40b83ff2535e6e56f190aff123821eea89a24c589f7af33413b9c19eb2c738"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:789cea26e740d71cf1882e3a42bb29052bc4ada15770c90072cb47bf73fb3dbf"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ba49c14222d5e5c0ee394495a8655e991dc06cbca5398153aefa5ac08cd6ccd7"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ac8cda531fe55be838a17c62c806824472bb962b3afa47ecbd59b27b78496f4e"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313t-win32.whl", hash = "sha256:b8af75fe20d381dd5bcc9db2e86a86d7fcfbf615383a7147b85da97c1182225b"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:45c5631c9b6c792b78be2d7352129f776dd72c605be2c3a4e9be346be8376d83"},
|
||||
{file = "wrapt-2.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:da815b9263947ac98d088b6414ac83507809a1d385e4632d9489867228d6d81c"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aa1765054245bb01a37f615503290d4e207e3fd59226e78341afb587e9c1236"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:feff14b63a6d86c1eee33a57f77573649f2550935981625be7ff3cb7342efe05"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81fc5f22d5fcfdbabde96bb3f5379b9f4476d05c6d524d7259dc5dfb501d3281"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951b228ecf66def855d22e006ab9a1fc12535111ae7db2ec576c728f8ddb39e8"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ddf582a95641b9a8c8bd643e83f34ecbbfe1b68bc3850093605e469ab680ae3"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc5c500966bf48913f795f1984704e6d452ba2414207b15e1f8c339a059d5b16"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314-win32.whl", hash = "sha256:4aa4baadb1f94b71151b8e44a0c044f6af37396c3b8bcd474b78b49e2130a23b"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:860e9d3fd81816a9f4e40812f28be4439ab01f260603c749d14be3c0a1170d19"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3c59e103017a2c1ea0ddf589cbefd63f91081d7ce9d491d69ff2512bb1157e23"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9fa7c7e1bee9278fc4f5dd8275bc8d25493281a8ec6c61959e37cc46acf02007"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c35e12e8215628984248bd9c8897ce0a474be2a773db207eb93414219d8469"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:94ded4540cac9125eaa8ddf5f651a7ec0da6f5b9f248fe0347b597098f8ec14c"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0af328373f97ed9bdfea24549ac1b944096a5a71b30e41c9b8b53ab3eec04a"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4ad839b55f0bf235f8e337ce060572d7a06592592f600f3a3029168e838469d3"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d89c49356e5e2a50fa86b40e0510082abcd0530f926cbd71cf25bee6b9d82d7"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:f4c7dd22cf7f36aafe772f3d88656559205c3af1b7900adfccb70edeb0d2abc4"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f76bc12c583ab01e73ba0ea585465a41e48d968f6d1311b4daec4f8654e356e3"},
|
||||
{file = "wrapt-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7ea74fc0bec172f1ae5f3505b6655c541786a5cabe4bbc0d9723a56ac32eb9b9"},
|
||||
{file = "wrapt-2.1.1-py3-none-any.whl", hash = "sha256:3b0f4629eb954394a3d7c7a1c8cca25f0b07cefe6aa8545e862e9778152de5b7"},
|
||||
{file = "wrapt-2.1.1.tar.gz", hash = "sha256:5fdcb09bf6db023d88f312bd0767594b414655d58090fc1c46b3414415f67fac"},
|
||||
{file = "wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c"},
|
||||
{file = "wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f"},
|
||||
{file = "wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb"},
|
||||
{file = "wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e"},
|
||||
{file = "wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba"},
|
||||
{file = "wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f"},
|
||||
{file = "wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394"},
|
||||
{file = "wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45"},
|
||||
{file = "wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d"},
|
||||
{file = "wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71"},
|
||||
{file = "wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc"},
|
||||
{file = "wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb"},
|
||||
{file = "wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d"},
|
||||
{file = "wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894"},
|
||||
{file = "wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842"},
|
||||
{file = "wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8"},
|
||||
{file = "wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6"},
|
||||
{file = "wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9"},
|
||||
{file = "wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15"},
|
||||
{file = "wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b"},
|
||||
{file = "wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1"},
|
||||
{file = "wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a"},
|
||||
{file = "wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9"},
|
||||
{file = "wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748"},
|
||||
{file = "wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e"},
|
||||
{file = "wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8"},
|
||||
{file = "wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c"},
|
||||
{file = "wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c"},
|
||||
{file = "wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1"},
|
||||
{file = "wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2"},
|
||||
{file = "wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0"},
|
||||
{file = "wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63"},
|
||||
{file = "wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f"},
|
||||
{file = "wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5"},
|
||||
{file = "wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00"},
|
||||
{file = "wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8"},
|
||||
{file = "wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -63,6 +63,7 @@ dev = [
|
||||
"pyinstaller",
|
||||
"types-requests>=2.32.0.20250515",
|
||||
"pytest-mock>=3.14.0",
|
||||
"jsonschema>=4.26.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -20,7 +20,7 @@ from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, Contact, calc
|
||||
from .model.config_model import Config
|
||||
from .update_checker import UpdateChecker
|
||||
from .utils import diagnostics, dicts, error_handlers, loggers, misc, xdg_paths
|
||||
from .utils.exceptions import CaptchaEncountered
|
||||
from .utils.exceptions import CaptchaEncountered, PublishSubmissionUncertainError
|
||||
from .utils.files import abspath
|
||||
from .utils.i18n import Locale, get_current_locale, pluralize, set_current_locale
|
||||
from .utils.misc import ainput, ensure, is_frozen
|
||||
@@ -38,7 +38,10 @@ _LOGIN_DETECTION_SELECTORS:Final[list[tuple["By", str]]] = [
|
||||
(By.CLASS_NAME, "mr-medium"),
|
||||
(By.ID, "user-email"),
|
||||
]
|
||||
_LOGIN_DETECTION_SELECTOR_LABELS:Final[tuple[str, ...]] = ("user_info_primary", "user_info_secondary")
|
||||
_LOGGED_OUT_CTA_SELECTORS:Final[list[tuple["By", str]]] = [
|
||||
(By.CSS_SELECTOR, 'a[href*="einloggen"]'),
|
||||
(By.CSS_SELECTOR, 'a[href*="/m-einloggen"]'),
|
||||
]
|
||||
|
||||
colorama.just_fix_windows_console()
|
||||
|
||||
@@ -997,151 +1000,250 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
|
||||
await ainput(_("Press a key to continue..."))
|
||||
except TimeoutError:
|
||||
# No captcha detected within timeout.
|
||||
pass
|
||||
page_context = "login page" if is_login_page else "publish flow"
|
||||
LOG.debug("No captcha detected within timeout on %s", page_context)
|
||||
|
||||
async def login(self) -> None:
|
||||
sso_navigation_timeout = self._timeout("page_load")
|
||||
pre_login_gdpr_timeout = self._timeout("quick_dom")
|
||||
|
||||
LOG.info("Checking if already logged in...")
|
||||
await self.web_open(f"{self.root_url}")
|
||||
if getattr(self, "page", None) is not None:
|
||||
LOG.debug("Current page URL after opening homepage: %s", self.page.url)
|
||||
try:
|
||||
await self._click_gdpr_banner(timeout = pre_login_gdpr_timeout)
|
||||
except TimeoutError:
|
||||
LOG.debug("No GDPR banner detected before login")
|
||||
|
||||
state = await self.get_login_state(capture_diagnostics = False)
|
||||
if state == LoginState.LOGGED_IN:
|
||||
LOG.info("Already logged in. Skipping login.")
|
||||
return
|
||||
|
||||
LOG.debug("Navigating to SSO login page (Auth0)...")
|
||||
# m-einloggen-sso.html triggers immediate server-side redirect to Auth0
|
||||
# This avoids waiting for JS on m-einloggen.html which may not execute in headless mode
|
||||
try:
|
||||
await self.web_open(f"{self.root_url}/m-einloggen-sso.html", timeout = sso_navigation_timeout)
|
||||
except TimeoutError:
|
||||
LOG.warning("Timeout navigating to SSO login page after %.1fs", sso_navigation_timeout)
|
||||
await self._capture_login_detection_diagnostics_if_enabled()
|
||||
raise
|
||||
|
||||
self._login_detection_diagnostics_captured = False
|
||||
|
||||
try:
|
||||
await self.fill_login_data_and_send()
|
||||
await self.handle_after_login_logic()
|
||||
except (AssertionError, TimeoutError):
|
||||
# AssertionError is intentionally part of auth-boundary control flow so
|
||||
# diagnostics are captured before the original error is re-raised.
|
||||
await self._capture_login_detection_diagnostics_if_enabled()
|
||||
raise
|
||||
|
||||
await self._dismiss_consent_banner()
|
||||
|
||||
state = await self.get_login_state()
|
||||
if state == LoginState.LOGGED_IN:
|
||||
LOG.info("Already logged in as [%s]. Skipping login.", self.config.login.username)
|
||||
LOG.info("Login confirmed.")
|
||||
return
|
||||
|
||||
if state == LoginState.UNKNOWN:
|
||||
LOG.warning("Login state is UNKNOWN - cannot determine if already logged in. Skipping login attempt.")
|
||||
current_url = self._current_page_url()
|
||||
LOG.warning("Login state after attempt is %s (url=%s)", state.name, current_url)
|
||||
await self._capture_login_detection_diagnostics_if_enabled()
|
||||
raise AssertionError(_("Login could not be confirmed after Auth0 flow (state=%s, url=%s)") % (state.name, current_url))
|
||||
|
||||
def _current_page_url(self) -> str:
|
||||
page = getattr(self, "page", None)
|
||||
if page is None:
|
||||
return "unknown"
|
||||
url = getattr(page, "url", None)
|
||||
if not isinstance(url, str) or not url:
|
||||
return "unknown"
|
||||
|
||||
parsed = urllib_parse.urlparse(url)
|
||||
host = parsed.hostname or parsed.netloc.split("@")[-1]
|
||||
netloc = f"{host}:{parsed.port}" if parsed.port is not None and host else host
|
||||
sanitized = urllib_parse.urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
|
||||
return sanitized or "unknown"
|
||||
|
||||
async def _wait_for_auth0_login_context(self) -> None:
|
||||
redirect_timeout = self._timeout("login_detection")
|
||||
try:
|
||||
await self.web_await(
|
||||
lambda: "login.kleinanzeigen.de" in self._current_page_url() or "/u/login" in self._current_page_url(),
|
||||
timeout = redirect_timeout,
|
||||
timeout_error_message = f"Auth0 redirect did not start within {redirect_timeout} seconds",
|
||||
apply_multiplier = False,
|
||||
)
|
||||
except TimeoutError as ex:
|
||||
current_url = self._current_page_url()
|
||||
raise AssertionError(_("Auth0 redirect not detected (url=%s)") % current_url) from ex
|
||||
|
||||
async def _wait_for_auth0_password_step(self) -> None:
|
||||
password_step_timeout = self._timeout("login_detection")
|
||||
try:
|
||||
await self.web_await(
|
||||
lambda: "/u/login/password" in self._current_page_url(),
|
||||
timeout = password_step_timeout,
|
||||
timeout_error_message = f"Auth0 password page not reached within {password_step_timeout} seconds",
|
||||
apply_multiplier = False,
|
||||
)
|
||||
except TimeoutError as ex:
|
||||
current_url = self._current_page_url()
|
||||
raise AssertionError(_("Auth0 password step not reached (url=%s)") % current_url) from ex
|
||||
|
||||
async def _wait_for_post_auth0_submit_transition(self) -> None:
|
||||
post_submit_timeout = self._timeout("login_detection")
|
||||
quick_dom_timeout = self._timeout("quick_dom")
|
||||
fallback_max_ms = max(700, int(quick_dom_timeout * 1_000))
|
||||
fallback_min_ms = max(300, fallback_max_ms // 2)
|
||||
|
||||
try:
|
||||
await self.web_await(
|
||||
lambda: self._is_valid_post_auth0_destination(self._current_page_url()),
|
||||
timeout = post_submit_timeout,
|
||||
timeout_error_message = f"Auth0 post-submit transition did not complete within {post_submit_timeout} seconds",
|
||||
apply_multiplier = False,
|
||||
)
|
||||
return
|
||||
except TimeoutError:
|
||||
LOG.debug("Post-submit transition not detected via URL, checking logged-in selectors")
|
||||
|
||||
login_confirmed = False
|
||||
try:
|
||||
login_confirmed = await asyncio.wait_for(self.is_logged_in(include_probe = False), timeout = post_submit_timeout)
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
LOG.debug("Post-submit login verification did not complete within %.1fs", post_submit_timeout)
|
||||
|
||||
if login_confirmed:
|
||||
return
|
||||
|
||||
LOG.info("Opening login page...")
|
||||
await self.web_open(f"{self.root_url}/m-einloggen.html?targetUrl=/")
|
||||
LOG.debug("Auth0 post-submit verification remained inconclusive; applying bounded fallback pause")
|
||||
await self.web_sleep(min_ms = fallback_min_ms, max_ms = fallback_max_ms)
|
||||
|
||||
await self.fill_login_data_and_send()
|
||||
await self.handle_after_login_logic()
|
||||
try:
|
||||
if await asyncio.wait_for(self.is_logged_in(include_probe = False), timeout = quick_dom_timeout):
|
||||
return
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
LOG.debug("Final post-submit login confirmation did not complete within %.1fs", quick_dom_timeout)
|
||||
|
||||
# Sometimes a second login is required
|
||||
state = await self.get_login_state()
|
||||
if state == LoginState.UNKNOWN:
|
||||
LOG.warning("Login state is UNKNOWN after first login attempt - cannot determine login status. Aborting login process.")
|
||||
return
|
||||
current_url = self._current_page_url()
|
||||
raise TimeoutError(_("Auth0 post-submit verification remained inconclusive (url=%s)") % current_url)
|
||||
|
||||
if state == LoginState.LOGGED_OUT:
|
||||
LOG.debug("First login attempt did not succeed, trying second login attempt")
|
||||
await self.fill_login_data_and_send()
|
||||
await self.handle_after_login_logic()
|
||||
def _is_valid_post_auth0_destination(self, url:str) -> bool:
|
||||
if not url or url in {"unknown", "about:blank"}:
|
||||
return False
|
||||
|
||||
state = await self.get_login_state()
|
||||
if state == LoginState.LOGGED_IN:
|
||||
LOG.debug("Second login attempt succeeded")
|
||||
else:
|
||||
LOG.warning("Second login attempt also failed - login may not have succeeded")
|
||||
parsed = urllib_parse.urlparse(url)
|
||||
host = (parsed.hostname or "").lower()
|
||||
path = parsed.path.lower()
|
||||
|
||||
if host != "kleinanzeigen.de" and not host.endswith(".kleinanzeigen.de"):
|
||||
return False
|
||||
if host == "login.kleinanzeigen.de":
|
||||
return False
|
||||
if path.startswith("/u/login"):
|
||||
return False
|
||||
|
||||
return "error" not in path
|
||||
|
||||
async def fill_login_data_and_send(self) -> None:
|
||||
LOG.info("Logging in as [%s]...", self.config.login.username)
|
||||
await self.web_input(By.ID, "login-email", self.config.login.username)
|
||||
"""Auth0 2-step login via m-einloggen-sso.html (server-side redirect, no JS needed).
|
||||
|
||||
# clearing password input in case browser has stored login data set
|
||||
await self.web_input(By.ID, "login-password", "")
|
||||
await self.web_input(By.ID, "login-password", self.config.login.password)
|
||||
Step 1: /u/login/identifier - email
|
||||
Step 2: /u/login/password - password
|
||||
"""
|
||||
LOG.info("Logging in...")
|
||||
|
||||
await self._wait_for_auth0_login_context()
|
||||
|
||||
# Step 1: email identifier
|
||||
LOG.debug("Auth0 Step 1: entering email...")
|
||||
await self.web_input(By.ID, "username", self.config.login.username)
|
||||
await self.web_click(By.CSS_SELECTOR, "button[type='submit']")
|
||||
|
||||
# Step 2: wait for password page then enter password
|
||||
LOG.debug("Waiting for Auth0 password page...")
|
||||
await self._wait_for_auth0_password_step()
|
||||
|
||||
LOG.debug("Auth0 Step 2: entering password...")
|
||||
await self.web_input(By.CSS_SELECTOR, "input[type='password']", self.config.login.password)
|
||||
await self.check_and_wait_for_captcha(is_login_page = True)
|
||||
|
||||
await self.web_click(By.CSS_SELECTOR, "form#login-form button[type='submit']")
|
||||
await self.web_click(By.CSS_SELECTOR, "button[type='submit']")
|
||||
await self._wait_for_post_auth0_submit_transition()
|
||||
LOG.debug("Auth0 login submitted.")
|
||||
|
||||
async def handle_after_login_logic(self) -> None:
|
||||
try:
|
||||
sms_timeout = self._timeout("sms_verification")
|
||||
await self.web_find(By.TEXT, "Wir haben dir gerade einen 6-stelligen Code für die Telefonnummer", timeout = sms_timeout)
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Device verification message detected. Please follow the instruction displayed in the Browser.")
|
||||
LOG.warning("############################################")
|
||||
await ainput(_("Press ENTER when done..."))
|
||||
await self._check_sms_verification()
|
||||
except TimeoutError:
|
||||
# No SMS verification prompt detected.
|
||||
pass
|
||||
LOG.debug("No SMS verification prompt detected after login")
|
||||
|
||||
try:
|
||||
email_timeout = self._timeout("email_verification")
|
||||
await self.web_find(By.TEXT, "Um dein Konto zu schützen haben wir dir eine E-Mail geschickt", timeout = email_timeout)
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Device verification message detected. Please follow the instruction displayed in the Browser.")
|
||||
LOG.warning("############################################")
|
||||
await ainput(_("Press ENTER when done..."))
|
||||
await self._check_email_verification()
|
||||
except TimeoutError:
|
||||
# No email verification prompt detected.
|
||||
pass
|
||||
LOG.debug("No email verification prompt detected after login")
|
||||
|
||||
try:
|
||||
LOG.info("Handling GDPR disclaimer...")
|
||||
gdpr_timeout = self._timeout("gdpr_prompt")
|
||||
await self.web_find(By.ID, "gdpr-banner-accept", timeout = gdpr_timeout)
|
||||
await self.web_click(By.ID, "gdpr-banner-cmp-button")
|
||||
await self.web_click(
|
||||
By.XPATH, "//div[@id='ConsentManagementPage']//*//button//*[contains(., 'Alle ablehnen und fortfahren')]", timeout = gdpr_timeout
|
||||
)
|
||||
LOG.debug("Handling GDPR disclaimer...")
|
||||
await self._click_gdpr_banner()
|
||||
except TimeoutError:
|
||||
# GDPR banner not shown within timeout.
|
||||
pass
|
||||
LOG.debug("GDPR banner not found or timed out")
|
||||
|
||||
async def _auth_probe_login_state(self) -> LoginState:
|
||||
"""Probe an auth-required endpoint to classify login state.
|
||||
async def _check_sms_verification(self) -> None:
|
||||
sms_timeout = self._timeout("sms_verification")
|
||||
await self.web_find(By.TEXT, "Wir haben dir gerade einen 6-stelligen Code für die Telefonnummer", timeout = sms_timeout)
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Device verification message detected. Please follow the instruction displayed in the Browser.")
|
||||
LOG.warning("############################################")
|
||||
await ainput(_("Press ENTER when done..."))
|
||||
|
||||
The probe is non-mutating (GET request). It is used as a fallback method by
|
||||
get_login_state() when DOM-based checks are inconclusive.
|
||||
async def _dismiss_consent_banner(self) -> None:
|
||||
"""Dismiss the GDPR/TCF consent banner if it is present.
|
||||
|
||||
This banner can appear on any page navigation (not just after login) and blocks
|
||||
all form interaction until dismissed. Uses a short timeout to avoid slowing down
|
||||
the flow when the banner is already gone.
|
||||
"""
|
||||
|
||||
url = f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"
|
||||
try:
|
||||
response = await self.web_request(url, valid_response_codes = [200, 401, 403])
|
||||
except (TimeoutError, AssertionError):
|
||||
# AssertionError can occur when web_request() fails to parse the response (e.g., unexpected content type)
|
||||
# Treat both timeout and assertion failures as UNKNOWN to avoid false assumptions about login state
|
||||
return LoginState.UNKNOWN
|
||||
banner_timeout = self._timeout("quick_dom")
|
||||
await self.web_find(By.ID, "gdpr-banner-accept", timeout = banner_timeout)
|
||||
LOG.debug("Consent banner detected, clicking 'Alle akzeptieren'...")
|
||||
await self.web_click(By.ID, "gdpr-banner-accept")
|
||||
except TimeoutError:
|
||||
LOG.debug("Consent banner not present; continuing without dismissal")
|
||||
|
||||
status_code = response.get("statusCode")
|
||||
if status_code in {401, 403}:
|
||||
return LoginState.LOGGED_OUT
|
||||
async def _check_email_verification(self) -> None:
|
||||
email_timeout = self._timeout("email_verification")
|
||||
await self.web_find(By.TEXT, "Um dein Konto zu schützen haben wir dir eine E-Mail geschickt", timeout = email_timeout)
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Device verification message detected. Please follow the instruction displayed in the Browser.")
|
||||
LOG.warning("############################################")
|
||||
await ainput(_("Press ENTER when done..."))
|
||||
|
||||
content = response.get("content", "")
|
||||
if not isinstance(content, str):
|
||||
return LoginState.UNKNOWN
|
||||
async def _click_gdpr_banner(self, *, timeout:float | None = None) -> None:
|
||||
gdpr_timeout = self._timeout("quick_dom") if timeout is None else timeout
|
||||
await self.web_find(By.ID, "gdpr-banner-accept", timeout = gdpr_timeout)
|
||||
await self.web_click(By.ID, "gdpr-banner-accept", timeout = gdpr_timeout)
|
||||
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
lowered = content.lower()
|
||||
if "m-einloggen" in lowered or "login-email" in lowered or "login-password" in lowered or "login-form" in lowered:
|
||||
return LoginState.LOGGED_OUT
|
||||
return LoginState.UNKNOWN
|
||||
|
||||
if isinstance(payload, dict) and "ads" in payload:
|
||||
return LoginState.LOGGED_IN
|
||||
|
||||
return LoginState.UNKNOWN
|
||||
|
||||
async def get_login_state(self) -> LoginState:
|
||||
"""Determine current login state using layered detection.
|
||||
async def get_login_state(self, *, capture_diagnostics:bool = True) -> LoginState:
|
||||
"""Determine current login state using DOM - first detection.
|
||||
|
||||
Order:
|
||||
1) DOM-based check via `is_logged_in(include_probe=False)` (preferred - stealthy)
|
||||
2) Server-side auth probe via `_auth_probe_login_state` (fallback - more reliable)
|
||||
3) If still inconclusive, capture diagnostics via
|
||||
`_capture_login_detection_diagnostics_if_enabled` and return `UNKNOWN`
|
||||
1) DOM - based logged - in check via `is_logged_in(include_probe=False)`
|
||||
2) Logged - out CTA check
|
||||
3) If inconclusive, optionally capture diagnostics and return `UNKNOWN`
|
||||
"""
|
||||
# Prefer DOM-based checks first to minimize bot-like behavior.
|
||||
# The auth probe makes a JSON API request that normal users wouldn't trigger.
|
||||
# Prefer DOM-based checks first to minimize bot-like behavior and avoid
|
||||
# fragile API probing side effects. Server-side auth probing was removed.
|
||||
if await self.is_logged_in(include_probe = False):
|
||||
return LoginState.LOGGED_IN
|
||||
|
||||
# Fall back to the more reliable server-side auth probe.
|
||||
# SPA/hydration delays can cause DOM-based checks to temporarily miss login indicators.
|
||||
state = await self._auth_probe_login_state()
|
||||
if state != LoginState.UNKNOWN:
|
||||
return state
|
||||
if await self._has_logged_out_cta(log_timeout = False):
|
||||
return LoginState.LOGGED_OUT
|
||||
|
||||
await self._capture_login_detection_diagnostics_if_enabled()
|
||||
if capture_diagnostics:
|
||||
await self._capture_login_detection_diagnostics_if_enabled()
|
||||
return LoginState.UNKNOWN
|
||||
|
||||
def _diagnostics_output_dir(self) -> Path:
|
||||
@@ -1254,8 +1356,27 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
login_check_timeout,
|
||||
effective_timeout,
|
||||
)
|
||||
quick_dom_timeout = self._timeout("quick_dom")
|
||||
tried_login_selectors = _format_login_detection_selectors(_LOGIN_DETECTION_SELECTORS)
|
||||
|
||||
try:
|
||||
user_info, matched_selector = await self.web_text_first_available(
|
||||
_LOGIN_DETECTION_SELECTORS,
|
||||
timeout = quick_dom_timeout,
|
||||
key = "quick_dom",
|
||||
description = "login_detection(quick_logged_in)",
|
||||
)
|
||||
if username in user_info.lower():
|
||||
matched_selector_display = (
|
||||
f"{_LOGIN_DETECTION_SELECTORS[matched_selector][0].name}={_LOGIN_DETECTION_SELECTORS[matched_selector][1]}"
|
||||
if 0 <= matched_selector < len(_LOGIN_DETECTION_SELECTORS)
|
||||
else f"selector_index_{matched_selector}"
|
||||
)
|
||||
LOG.debug("Login detected via login detection selector '%s'", matched_selector_display)
|
||||
return True
|
||||
except TimeoutError:
|
||||
LOG.debug("No login detected via configured login detection selectors (%s)", tried_login_selectors)
|
||||
|
||||
try:
|
||||
user_info, matched_selector = await self.web_text_first_available(
|
||||
_LOGIN_DETECTION_SELECTORS,
|
||||
@@ -1264,29 +1385,57 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
description = "login_detection(selector_group)",
|
||||
)
|
||||
if username in user_info.lower():
|
||||
matched_selector_label = (
|
||||
_LOGIN_DETECTION_SELECTOR_LABELS[matched_selector]
|
||||
if 0 <= matched_selector < len(_LOGIN_DETECTION_SELECTOR_LABELS)
|
||||
matched_selector_display = (
|
||||
f"{_LOGIN_DETECTION_SELECTORS[matched_selector][0].name}={_LOGIN_DETECTION_SELECTORS[matched_selector][1]}"
|
||||
if 0 <= matched_selector < len(_LOGIN_DETECTION_SELECTORS)
|
||||
else f"selector_index_{matched_selector}"
|
||||
)
|
||||
LOG.debug("Login detected via login detection selector '%s'", matched_selector_label)
|
||||
LOG.debug("Login detected via login detection selector '%s'", matched_selector_display)
|
||||
return True
|
||||
except TimeoutError:
|
||||
LOG.debug("Timeout waiting for login detection selector group after %.1fs", effective_timeout)
|
||||
|
||||
if not include_probe:
|
||||
LOG.debug("No login detected via configured login detection selectors (%s)", tried_login_selectors)
|
||||
if await self._has_logged_out_cta():
|
||||
return False
|
||||
|
||||
state = await self._auth_probe_login_state()
|
||||
if state == LoginState.LOGGED_IN:
|
||||
return True
|
||||
if include_probe:
|
||||
LOG.debug("No login detected via configured login detection selectors (%s); auth probe is disabled", tried_login_selectors)
|
||||
return False
|
||||
|
||||
LOG.debug("No login detected via configured login detection selectors (%s)", tried_login_selectors)
|
||||
return False
|
||||
|
||||
async def _has_logged_out_cta(self, *, log_timeout:bool = True) -> bool:
|
||||
quick_dom_timeout = self._timeout("quick_dom")
|
||||
tried_logged_out_selectors = _format_login_detection_selectors(_LOGGED_OUT_CTA_SELECTORS)
|
||||
|
||||
try:
|
||||
cta_element, cta_index = await self.web_find_first_available(
|
||||
_LOGGED_OUT_CTA_SELECTORS,
|
||||
timeout = quick_dom_timeout,
|
||||
key = "quick_dom",
|
||||
description = "login_detection(logged_out_cta)",
|
||||
)
|
||||
cta_text = await self._extract_visible_text(cta_element)
|
||||
if cta_text.strip():
|
||||
matched_selector_display = (
|
||||
f"{_LOGGED_OUT_CTA_SELECTORS[cta_index][0].name}={_LOGGED_OUT_CTA_SELECTORS[cta_index][1]}"
|
||||
if 0 <= cta_index < len(_LOGGED_OUT_CTA_SELECTORS)
|
||||
else f"selector_index_{cta_index}"
|
||||
)
|
||||
if 0 <= cta_index < len(_LOGGED_OUT_CTA_SELECTORS):
|
||||
LOG.debug("Fast logged-out pre-check matched selector '%s'", matched_selector_display)
|
||||
return True
|
||||
LOG.debug("Fast logged-out pre-check got unexpected selector index '%s'; failing closed", cta_index)
|
||||
return False
|
||||
except TimeoutError:
|
||||
if log_timeout:
|
||||
LOG.debug(
|
||||
"Fast logged-out pre-check found no login CTA (%s) within %.1fs",
|
||||
tried_logged_out_selectors,
|
||||
quick_dom_timeout,
|
||||
)
|
||||
|
||||
LOG.debug(
|
||||
"No login detected - DOM login detection selectors (%s) did not confirm login and server probe returned %s",
|
||||
tried_login_selectors,
|
||||
state.name,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _fetch_published_ads(self) -> list[dict[str, Any]]:
|
||||
@@ -1309,13 +1458,25 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
try:
|
||||
response = await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum={page}")
|
||||
except TimeoutError as ex:
|
||||
LOG.warning("Pagination request timed out on page %s: %s", page, ex)
|
||||
LOG.warning("Pagination request failed on page %s: %s", page, ex)
|
||||
break
|
||||
|
||||
if not isinstance(response, dict):
|
||||
LOG.warning("Unexpected pagination response type on page %s: %s", page, type(response).__name__)
|
||||
break
|
||||
|
||||
content = response.get("content", "")
|
||||
if isinstance(content, bytearray):
|
||||
content = bytes(content)
|
||||
if isinstance(content, bytes):
|
||||
content = content.decode("utf-8", errors = "replace")
|
||||
if not isinstance(content, str):
|
||||
LOG.warning("Unexpected response content type on page %s: %s", page, type(content).__name__)
|
||||
break
|
||||
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as ex:
|
||||
except (json.JSONDecodeError, TypeError) as ex:
|
||||
if not content:
|
||||
LOG.warning("Empty JSON response content on page %s", page)
|
||||
break
|
||||
@@ -1336,7 +1497,24 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
LOG.warning("Unexpected 'ads' type on page %s: %s value: %s", page, type(page_ads).__name__, preview)
|
||||
break
|
||||
|
||||
ads.extend(page_ads)
|
||||
filtered_page_ads:list[dict[str, Any]] = []
|
||||
rejected_count = 0
|
||||
rejected_preview:str | None = None
|
||||
for entry in page_ads:
|
||||
if isinstance(entry, dict) and "id" in entry and "state" in entry:
|
||||
filtered_page_ads.append(entry)
|
||||
continue
|
||||
rejected_count += 1
|
||||
if rejected_preview is None:
|
||||
rejected_preview = repr(entry)
|
||||
|
||||
if rejected_count > 0:
|
||||
preview = rejected_preview or "<none>"
|
||||
if len(preview) > SNIPPET_LIMIT:
|
||||
preview = preview[:SNIPPET_LIMIT] + "..."
|
||||
LOG.warning("Filtered %s malformed ad entries on page %s (sample: %s)", rejected_count, page, preview)
|
||||
|
||||
ads.extend(filtered_page_ads)
|
||||
|
||||
paging = json_data.get("paging")
|
||||
if not isinstance(paging, dict):
|
||||
@@ -1554,7 +1732,6 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
count += 1
|
||||
success = False
|
||||
|
||||
# Retry loop only for publish_ad (before submission completes)
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
await self.publish_ad(ad_file, ad_cfg, ad_cfg_orig, published_ads, AdUpdateStrategy.REPLACE)
|
||||
@@ -1562,14 +1739,31 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
break # Publish succeeded, exit retry loop
|
||||
except asyncio.CancelledError:
|
||||
raise # Respect task cancellation
|
||||
except PublishSubmissionUncertainError as ex:
|
||||
await self._capture_publish_error_diagnostics_if_enabled(ad_cfg, ad_cfg_orig, ad_file, attempt, ex)
|
||||
LOG.warning(
|
||||
"Attempt %s/%s for '%s' reached submit boundary but failed: %s. Not retrying to prevent duplicate listings.",
|
||||
attempt,
|
||||
max_retries,
|
||||
ad_cfg.title,
|
||||
ex,
|
||||
)
|
||||
LOG.warning("Manual recovery required for '%s'. Check 'Meine Anzeigen' to confirm whether the ad was posted.", ad_cfg.title)
|
||||
LOG.warning(
|
||||
"If posted, sync local state with 'kleinanzeigen-bot download --ads=new' or 'kleinanzeigen-bot download --ads=<id>'; "
|
||||
"otherwise rerun publish for this ad."
|
||||
)
|
||||
failed_count += 1
|
||||
break
|
||||
except (TimeoutError, ProtocolException) as ex:
|
||||
await self._capture_publish_error_diagnostics_if_enabled(ad_cfg, ad_cfg_orig, ad_file, attempt, ex)
|
||||
if attempt < max_retries:
|
||||
LOG.warning("Attempt %s/%s failed for '%s': %s. Retrying...", attempt, max_retries, ad_cfg.title, ex)
|
||||
await self.web_sleep(2) # Wait before retry
|
||||
else:
|
||||
if attempt >= max_retries:
|
||||
LOG.error("All %s attempts failed for '%s': %s. Skipping ad.", max_retries, ad_cfg.title, ex)
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
LOG.warning("Attempt %s/%s failed for '%s': %s. Retrying...", attempt, max_retries, ad_cfg.title, ex)
|
||||
await self.web_sleep(2_000) # Wait before retry
|
||||
|
||||
# Check publishing result separately (no retry - ad is already submitted)
|
||||
if success:
|
||||
@@ -1593,10 +1787,10 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
self, ad_file:str, ad_cfg:Ad, ad_cfg_orig:dict[str, Any], published_ads:list[dict[str, Any]], mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE
|
||||
) -> None:
|
||||
"""
|
||||
@param ad_cfg: the effective ad config (i.e. with default values applied etc.)
|
||||
@param ad_cfg_orig: the ad config as present in the YAML file
|
||||
@param published_ads: json list of published ads
|
||||
@param mode: the mode of ad editing, either publishing a new or updating an existing ad
|
||||
@ param ad_cfg: the effective ad config(i.e. with default values applied etc.)
|
||||
@ param ad_cfg_orig: the ad config as present in the YAML file
|
||||
@ param published_ads: json list of published ads
|
||||
@ param mode: the mode of ad editing, either publishing a new or updating an existing ad
|
||||
"""
|
||||
|
||||
if mode == AdUpdateStrategy.REPLACE:
|
||||
@@ -1613,6 +1807,8 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
LOG.info("Updating ad '%s'...", ad_cfg.title)
|
||||
await self.web_open(f"{self.root_url}/p-anzeige-bearbeiten.html?adId={ad_cfg.id}")
|
||||
|
||||
await self._dismiss_consent_banner()
|
||||
|
||||
if loggers.is_debug(LOG):
|
||||
LOG.debug(" -> effective ad meta:")
|
||||
YAML().dump(ad_cfg.model_dump(), sys.stdout)
|
||||
@@ -1718,39 +1914,42 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
# submit
|
||||
#############################
|
||||
try:
|
||||
await self.web_click(By.ID, "pstad-submit")
|
||||
except TimeoutError:
|
||||
# https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues/40
|
||||
await self.web_click(By.XPATH, "//fieldset[@id='postad-publish']//*[contains(., 'Anzeige aufgeben')]")
|
||||
await self.web_click(By.ID, "imprint-guidance-submit")
|
||||
try:
|
||||
await self.web_click(By.ID, "pstad-submit")
|
||||
except TimeoutError:
|
||||
# https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues/40
|
||||
await self.web_click(By.XPATH, "//fieldset[@id='postad-publish']//*[contains(., 'Anzeige aufgeben')]")
|
||||
await self.web_click(By.ID, "imprint-guidance-submit")
|
||||
|
||||
# check for no image question
|
||||
try:
|
||||
image_hint_xpath = '//button[contains(., "Ohne Bild veröffentlichen")]'
|
||||
if not ad_cfg.images and await self.web_check(By.XPATH, image_hint_xpath, Is.DISPLAYED):
|
||||
await self.web_click(By.XPATH, image_hint_xpath)
|
||||
except TimeoutError:
|
||||
# Image hint not shown; continue publish flow.
|
||||
pass # nosec
|
||||
# check for no image question
|
||||
try:
|
||||
image_hint_xpath = '//button[contains(., "Ohne Bild veröffentlichen")]'
|
||||
if not ad_cfg.images and await self.web_check(By.XPATH, image_hint_xpath, Is.DISPLAYED):
|
||||
await self.web_click(By.XPATH, image_hint_xpath)
|
||||
except TimeoutError:
|
||||
# Image hint not shown; continue publish flow.
|
||||
pass # nosec
|
||||
|
||||
#############################
|
||||
# wait for payment form if commercial account is used
|
||||
#############################
|
||||
try:
|
||||
short_timeout = self._timeout("quick_dom")
|
||||
await self.web_find(By.ID, "myftr-shppngcrt-frm", timeout = short_timeout)
|
||||
#############################
|
||||
# wait for payment form if commercial account is used
|
||||
#############################
|
||||
try:
|
||||
short_timeout = self._timeout("quick_dom")
|
||||
await self.web_find(By.ID, "myftr-shppngcrt-frm", timeout = short_timeout)
|
||||
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Payment form detected! Please proceed with payment.")
|
||||
LOG.warning("############################################")
|
||||
await self.web_scroll_page_down()
|
||||
await ainput(_("Press a key to continue..."))
|
||||
except TimeoutError:
|
||||
# Payment form not present.
|
||||
pass
|
||||
LOG.warning("############################################")
|
||||
LOG.warning("# Payment form detected! Please proceed with payment.")
|
||||
LOG.warning("############################################")
|
||||
await self.web_scroll_page_down()
|
||||
await ainput(_("Press a key to continue..."))
|
||||
except TimeoutError:
|
||||
# Payment form not present.
|
||||
pass
|
||||
|
||||
confirmation_timeout = self._timeout("publishing_confirmation")
|
||||
await self.web_await(lambda: "p-anzeige-aufgeben-bestaetigung.html?adId=" in self.page.url, timeout = confirmation_timeout)
|
||||
confirmation_timeout = self._timeout("publishing_confirmation")
|
||||
await self.web_await(lambda: "p-anzeige-aufgeben-bestaetigung.html?adId=" in self.page.url, timeout = confirmation_timeout)
|
||||
except (TimeoutError, ProtocolException) as ex:
|
||||
raise PublishSubmissionUncertainError("submission may have succeeded before failure") from ex
|
||||
|
||||
# extract the ad id from the URL's query parameter
|
||||
current_url_query_params = urllib_parse.parse_qs(urllib_parse.urlparse(self.page.url).query)
|
||||
@@ -2033,11 +2232,17 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
await self.__set_shipping_options(ad_cfg, mode)
|
||||
else:
|
||||
special_shipping_selector = '//select[contains(@id, ".versand_s")]'
|
||||
if await self.web_check(By.XPATH, special_shipping_selector, Is.DISPLAYED):
|
||||
# try to set special attribute selector (then we have a commercial account)
|
||||
is_commercial_shipping = False
|
||||
try:
|
||||
has_commercial_selector = await self.web_check(By.XPATH, special_shipping_selector, Is.DISPLAYED, timeout = short_timeout)
|
||||
except TimeoutError:
|
||||
# Element does not exist in DOM (non-commercial account or UI change); fall through to dialog-based shipping.
|
||||
has_commercial_selector = False
|
||||
if has_commercial_selector:
|
||||
shipping_value = "ja" if ad_cfg.shipping_type == "SHIPPING" else "nein"
|
||||
await self.web_select(By.XPATH, special_shipping_selector, shipping_value)
|
||||
else:
|
||||
is_commercial_shipping = True
|
||||
if not is_commercial_shipping:
|
||||
try:
|
||||
# no options. only costs. Set custom shipping cost
|
||||
await self.web_click(By.XPATH, '//button//span[contains(., "Versandmethoden auswählen")]')
|
||||
@@ -2201,7 +2406,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
async def download_ads(self) -> None:
|
||||
"""
|
||||
Determines which download mode was chosen with the arguments, and calls the specified download routine.
|
||||
This downloads either all, only unsaved (new), or specific ads given by ID.
|
||||
This downloads either all, only unsaved(new), or specific ads given by ID.
|
||||
"""
|
||||
# Fetch published ads once from manage-ads JSON to avoid repetitive API calls during extraction
|
||||
# Build lookup dict inline and pass directly to extractor (no cache abstraction needed)
|
||||
@@ -2290,10 +2495,10 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
def __get_description(self, ad_cfg:Ad, *, with_affixes:bool) -> str:
|
||||
"""Get the ad description optionally with prefix and suffix applied.
|
||||
|
||||
Precedence (highest to lowest):
|
||||
1. Direct ad-level affixes (description_prefix/suffix)
|
||||
2. Global flattened affixes (ad_defaults.description_prefix/suffix)
|
||||
3. Legacy global nested affixes (ad_defaults.description.prefix/suffix)
|
||||
Precedence(highest to lowest):
|
||||
1. Direct ad - level affixes(description_prefix / suffix)
|
||||
2. Global flattened affixes(ad_defaults.description_prefix / suffix)
|
||||
3. Legacy global nested affixes(ad_defaults.description.prefix / suffix)
|
||||
|
||||
Args:
|
||||
ad_cfg: The ad configuration dictionary
|
||||
@@ -2365,8 +2570,8 @@ def main(args:list[str]) -> None:
|
||||
print(
|
||||
textwrap.dedent(rf"""
|
||||
_ _ _ _ _ _
|
||||
| | _| | ___(_)_ __ __ _ _ __ _______(_) __ _ ___ _ __ | |__ ___ | |_
|
||||
| |/ / |/ _ \ | '_ \ / _` | '_ \|_ / _ \ |/ _` |/ _ \ '_ \ ____| '_ \ / _ \| __|
|
||||
| | _ | | ___(_)_ __ __ _ _ __ _______(_) __ _ ___ _ __ | |__ ___ | |_
|
||||
| | / / | / _ \ | '_ \ / _` | '_ \|_ / _ \ |/ _` |/ _ \ '_ \ ____| '_ \ / _ \| __|
|
||||
| <| | __/ | | | | (_| | | | |/ / __/ | (_| | __/ | | |____| |_) | (_) | |_
|
||||
|_|\_\_|\___|_|_| |_|\__,_|_| |_/___\___|_|\__, |\___|_| |_| |_.__/ \___/ \__|
|
||||
|___/
|
||||
|
||||
@@ -65,7 +65,7 @@ class AdExtractor(WebScrapingMixin):
|
||||
header_string = (
|
||||
"# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json"
|
||||
)
|
||||
await asyncio.get_running_loop().run_in_executor(None, lambda: dicts.save_dict(ad_file_path, ad_cfg.model_dump(), header = header_string))
|
||||
await asyncio.get_running_loop().run_in_executor(None, lambda: dicts.save_dict(ad_file_path, ad_cfg.model_dump(mode = "json"), header = header_string))
|
||||
|
||||
@staticmethod
|
||||
def _download_and_save_image_sync(url:str, directory:str, filename_prefix:str, img_nr:int) -> str | None:
|
||||
|
||||
@@ -37,9 +37,12 @@ kleinanzeigen_bot/__init__.py:
|
||||
"Empty JSON response content on page %s": "Leerer JSON-Antwortinhalt auf Seite %s"
|
||||
"Failed to parse JSON response on page %s: %s (content: %s)": "Fehler beim Parsen der JSON-Antwort auf Seite %s: %s (Inhalt: %s)"
|
||||
"Stopping pagination after %s pages to avoid infinite loop": "Stoppe die Seitenaufschaltung nach %s Seiten, um eine Endlosschleife zu vermeiden"
|
||||
"Pagination request timed out on page %s: %s": "Zeitueberschreitung bei der Seitenabfrage auf Seite %s: %s"
|
||||
"Pagination request failed on page %s: %s": "Seitenabfrage auf Seite %s fehlgeschlagen: %s"
|
||||
"Unexpected pagination response type on page %s: %s": "Unerwarteter Typ der Paginierungsantwort auf Seite %s: %s"
|
||||
"Unexpected response content type on page %s: %s": "Unerwarteter Antwortinhalt-Typ auf Seite %s: %s"
|
||||
"Unexpected JSON payload on page %s (content: %s)": "Unerwartete JSON-Antwort auf Seite %s (Inhalt: %s)"
|
||||
"Unexpected 'ads' type on page %s: %s value: %s": "Unerwarteter 'ads'-Typ auf Seite %s: %s Wert: %s"
|
||||
"Filtered %s malformed ad entries on page %s (sample: %s)": "%s fehlerhafte Anzeigen-Einträge auf Seite %s gefiltert (Beispiel: %s)"
|
||||
"Reached last page %s of %s, stopping pagination": "Letzte Seite %s von %s erreicht, beende Paginierung"
|
||||
"No ads found on page %s, stopping pagination": "Keine Anzeigen auf Seite %s gefunden, beende Paginierung"
|
||||
"Invalid 'next' page value in paging info: %s, stopping pagination": "Ungültiger 'next'-Seitenwert in Paginierungsinfo: %s, beende Paginierung"
|
||||
@@ -86,14 +89,36 @@ kleinanzeigen_bot/__init__.py:
|
||||
|
||||
login:
|
||||
"Checking if already logged in...": "Überprüfe, ob bereits eingeloggt..."
|
||||
"Current page URL after opening homepage: %s": "Aktuelle Seiten-URL nach dem Öffnen der Startseite: %s"
|
||||
"Already logged in as [%s]. Skipping login.": "Bereits eingeloggt als [%s]. Überspringe Anmeldung."
|
||||
"Opening login page...": "Öffne Anmeldeseite..."
|
||||
"Login state is UNKNOWN - cannot determine if already logged in. Skipping login attempt.": "Login-Status ist UNKNOWN - kann nicht bestimmt werden, ob bereits eingeloggt ist. Überspringe Anmeldeversuch."
|
||||
"Login state is UNKNOWN after first login attempt - cannot determine login status. Aborting login process.": "Login-Status ist UNKNOWN nach dem ersten Anmeldeversuch - kann Login-Status nicht bestimmen. Breche Anmeldeprozess ab."
|
||||
"First login attempt did not succeed, trying second login attempt": "Erster Anmeldeversuch war nicht erfolgreich, versuche zweiten Anmeldeversuch"
|
||||
"Second login attempt succeeded": "Zweiter Anmeldeversuch erfolgreich"
|
||||
"Second login attempt also failed - login may not have succeeded": "Zweiter Anmeldeversuch ebenfalls fehlgeschlagen - Anmeldung möglicherweise nicht erfolgreich"
|
||||
"Already logged in. Skipping login.": "Bereits eingeloggt. Überspringe Anmeldung."
|
||||
"Navigating to SSO login page (Auth0)...": "Navigiere zur SSO-Anmeldeseite (Auth0)..."
|
||||
"Timeout navigating to SSO login page after %.1fs": "Zeitüberschreitung beim Navigieren zur SSO-Anmeldeseite nach %.1fs"
|
||||
"Login confirmed.": "Anmeldung bestätigt."
|
||||
"Login state after attempt is %s (url=%s)": "Login-Status nach dem Versuch ist %s (URL=%s)"
|
||||
"Login could not be confirmed after Auth0 flow (state=%s, url=%s)": "Anmeldung nach Auth0-Flow konnte nicht bestätigt werden (Status=%s, URL=%s)"
|
||||
|
||||
_wait_for_auth0_login_context:
|
||||
"Auth0 redirect not detected (url=%s)": "Auth0-Weiterleitung nicht erkannt (URL=%s)"
|
||||
|
||||
_wait_for_auth0_password_step:
|
||||
"Auth0 password step not reached (url=%s)": "Auth0-Passwortschritt nicht erreicht (URL=%s)"
|
||||
|
||||
_wait_for_post_auth0_submit_transition:
|
||||
"Auth0 post-submit verification remained inconclusive (url=%s)": "Auth0-Verifikation nach Absenden blieb unklar (URL=%s)"
|
||||
|
||||
fill_login_data_and_send:
|
||||
"Logging in...": "Anmeldung..."
|
||||
"Auth0 Step 1: entering email...": "Auth0 Schritt 1: E-Mail wird eingegeben..."
|
||||
"Waiting for Auth0 password page...": "Warte auf Auth0-Passwortseite..."
|
||||
"Auth0 Step 2: entering password...": "Auth0 Schritt 2: Passwort wird eingegeben..."
|
||||
"Auth0 login submitted.": "Auth0-Anmeldung abgesendet."
|
||||
|
||||
_check_sms_verification:
|
||||
"# Device verification message detected. Please follow the instruction displayed in the Browser.": "# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen."
|
||||
"Press ENTER when done...": "EINGABETASTE drücken, wenn erledigt..."
|
||||
|
||||
_check_email_verification:
|
||||
"# Device verification message detected. Please follow the instruction displayed in the Browser.": "# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen."
|
||||
"Press ENTER when done...": "EINGABETASTE drücken, wenn erledigt..."
|
||||
|
||||
is_logged_in:
|
||||
"Starting login detection (timeout: %.1fs base, %.1fs effective with multiplier/backoff)": "Starte Login-Erkennung (Timeout: %.1fs Basis, %.1fs effektiv mit Multiplikator/Backoff)"
|
||||
@@ -101,8 +126,6 @@ kleinanzeigen_bot/__init__.py:
|
||||
"Timeout waiting for login detection selector group after %.1fs": "Timeout beim Warten auf die Login-Erkennungs-Selektorgruppe nach %.1fs"
|
||||
|
||||
handle_after_login_logic:
|
||||
"# Device verification message detected. Please follow the instruction displayed in the Browser.": "# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen."
|
||||
"Press ENTER when done...": "EINGABETASTE drücken, wenn erledigt..."
|
||||
"Handling GDPR disclaimer...": "Verarbeite DSGVO-Hinweis..."
|
||||
|
||||
delete_ads:
|
||||
@@ -154,10 +177,15 @@ kleinanzeigen_bot/__init__.py:
|
||||
"Skipping because ad is reserved": "Überspringen, da Anzeige reserviert ist"
|
||||
" -> Could not confirm publishing for '%s', but ad may be online": " -> Veröffentlichung für '%s' konnte nicht bestätigt werden, aber Anzeige ist möglicherweise online"
|
||||
"Attempt %s/%s failed for '%s': %s. Retrying...": "Versuch %s/%s fehlgeschlagen für '%s': %s. Erneuter Versuch..."
|
||||
"Attempt %s/%s for '%s' reached submit boundary but failed: %s. Not retrying to prevent duplicate listings.": "Versuch %s/%s für '%s' hat die Submit-Grenze erreicht, ist aber fehlgeschlagen: %s. Kein erneuter Versuch, um doppelte Anzeigen zu vermeiden."
|
||||
"Manual recovery required for '%s'. Check 'Meine Anzeigen' to confirm whether the ad was posted.": "Manuelle Wiederherstellung für '%s' erforderlich. Prüfen Sie in 'Meine Anzeigen', ob die Anzeige veröffentlicht wurde."
|
||||
? "If posted, sync local state with 'kleinanzeigen-bot download --ads=new' or 'kleinanzeigen-bot download --ads=<id>'; otherwise rerun publish for this ad."
|
||||
: "Falls veröffentlicht, lokalen Stand mit 'kleinanzeigen-bot download --ads=new' oder 'kleinanzeigen-bot download --ads=<id>' synchronisieren; andernfalls Veröffentlichung für diese Anzeige erneut starten."
|
||||
"All %s attempts failed for '%s': %s. Skipping ad.": "Alle %s Versuche fehlgeschlagen für '%s': %s. Überspringe Anzeige."
|
||||
"DONE: (Re-)published %s (%s failed after retries)": "FERTIG: %s (erneut) veröffentlicht (%s fehlgeschlagen nach Wiederholungen)"
|
||||
"DONE: (Re-)published %s": "FERTIG: %s (erneut) veröffentlicht"
|
||||
"ad": "Anzeige"
|
||||
|
||||
apply_auto_price_reduction:
|
||||
"Auto price reduction is enabled for [%s] but no price is configured.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber es wurde kein Preis konfiguriert."
|
||||
"Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber min_price entspricht dem Preis (%s) - es werden keine Reduktionen auftreten."
|
||||
@@ -261,9 +289,6 @@ kleinanzeigen_bot/__init__.py:
|
||||
"Unknown command: %s": "Unbekannter Befehl: %s"
|
||||
"Timing collector flush failed: %s": "Zeitmessdaten konnten nicht gespeichert werden: %s"
|
||||
|
||||
fill_login_data_and_send:
|
||||
"Logging in as [%s]...": "Anmeldung als [%s]..."
|
||||
|
||||
__set_shipping:
|
||||
"Unable to close shipping dialog!": "Versanddialog konnte nicht geschlossen werden!"
|
||||
|
||||
|
||||
@@ -14,3 +14,10 @@ class CaptchaEncountered(KleinanzeigenBotError):
|
||||
def __init__(self, restart_delay:timedelta) -> None:
|
||||
super().__init__()
|
||||
self.restart_delay = restart_delay
|
||||
|
||||
|
||||
class PublishSubmissionUncertainError(KleinanzeigenBotError):
|
||||
"""Raised when publish submission may have reached the server state boundary."""
|
||||
|
||||
def __init__(self, reason:str) -> None:
|
||||
super().__init__(reason)
|
||||
|
||||
@@ -2,19 +2,28 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import json # isort: skip
|
||||
import asyncio
|
||||
from gettext import gettext as _
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any, Final, TypedDict
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
from urllib.error import URLError
|
||||
|
||||
import pytest
|
||||
from jsonschema import Draft202012Validator
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
import kleinanzeigen_bot.extract as extract_module
|
||||
from kleinanzeigen_bot.model.ad_model import AdPartial, ContactPartial
|
||||
from kleinanzeigen_bot.model.config_model import Config, DownloadConfig
|
||||
from kleinanzeigen_bot.utils.web_scraping_mixin import Browser, By, Element
|
||||
|
||||
SCHEMA_PATH:Final[Path] = Path(__file__).resolve().parents[2] / "schemas" / "ad.schema.json"
|
||||
|
||||
|
||||
def _read_text_file(path:Path) -> str:
|
||||
return path.read_text(encoding = "utf-8")
|
||||
|
||||
|
||||
class _DimensionsDict(TypedDict):
|
||||
ad_attributes:str
|
||||
@@ -1255,7 +1264,38 @@ class TestAdExtractorDownload:
|
||||
actual_call = mock_save_dict.call_args
|
||||
actual_path = Path(actual_call[0][0])
|
||||
assert actual_path == yaml_path
|
||||
assert actual_call[0][1] == mock_extract_with_dir.return_value[0].model_dump()
|
||||
assert actual_call[0][1] == mock_extract_with_dir.return_value[0].model_dump(mode = "json")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_ad_writes_schema_compliant_yaml(self, extractor:extract_module.AdExtractor, tmp_path:Path) -> None:
|
||||
"""Test that downloaded ad YAML validates against ad.schema.json."""
|
||||
download_base = tmp_path / "downloaded-ads"
|
||||
final_dir = download_base / "ad_12345_Test Advertisement Title"
|
||||
yaml_path = final_dir / "ad_12345.yaml"
|
||||
extractor.download_dir = download_base
|
||||
|
||||
with patch.object(extractor, "_extract_ad_page_info_with_directory_handling", new_callable = AsyncMock) as mock_extract_with_dir:
|
||||
mock_extract_with_dir.return_value = (
|
||||
AdPartial.model_validate(
|
||||
{
|
||||
"title": "Test Advertisement Title",
|
||||
"description": "Test Description",
|
||||
"category": "Dienstleistungen",
|
||||
"created_on": "2026-03-08T00:00:00+01:00",
|
||||
"updated_on": "2026-03-09T01:02:03+01:00",
|
||||
}
|
||||
),
|
||||
final_dir,
|
||||
)
|
||||
|
||||
await extractor.download_ad(12345)
|
||||
|
||||
loaded_ad = YAML(typ = "safe").load(await asyncio.to_thread(_read_text_file, yaml_path))
|
||||
schema = json.loads(await asyncio.to_thread(_read_text_file, SCHEMA_PATH))
|
||||
|
||||
Draft202012Validator(schema).validate(loaded_ad)
|
||||
assert isinstance(loaded_ad["created_on"], str)
|
||||
assert isinstance(loaded_ad["updated_on"], str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import copy, fnmatch, io, json, logging, os, tempfile # isort: skip
|
||||
import asyncio, copy, fnmatch, io, json, logging, os, tempfile # isort: skip
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import redirect_stdout
|
||||
from datetime import timedelta
|
||||
@@ -10,6 +10,7 @@ from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from nodriver.core.connection import ProtocolException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from kleinanzeigen_bot import LOG, PUBLISH_MAX_RETRIES, AdUpdateStrategy, KleinanzeigenBot, LoginState, misc
|
||||
@@ -17,6 +18,7 @@ from kleinanzeigen_bot._version import __version__
|
||||
from kleinanzeigen_bot.model.ad_model import Ad
|
||||
from kleinanzeigen_bot.model.config_model import AdDefaults, Config, DiagnosticsConfig, PublishingConfig
|
||||
from kleinanzeigen_bot.utils import dicts, loggers, xdg_paths
|
||||
from kleinanzeigen_bot.utils.exceptions import PublishSubmissionUncertainError
|
||||
from kleinanzeigen_bot.utils.web_scraping_mixin import By, Element
|
||||
|
||||
|
||||
@@ -442,7 +444,12 @@ class TestKleinanzeigenBotAuthentication:
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_returns_true_when_logged_in(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify that login check returns true when logged in."""
|
||||
with patch.object(test_bot, "web_text_first_available", new_callable = AsyncMock, return_value = ("Welcome dummy_user", 0)):
|
||||
with patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
return_value = ("Welcome dummy_user", 0),
|
||||
):
|
||||
assert await test_bot.is_logged_in() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -460,45 +467,96 @@ class TestKleinanzeigenBotAuthentication:
|
||||
async def test_is_logged_in_returns_false_when_not_logged_in(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify that login check returns false when not logged in."""
|
||||
with (
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(
|
||||
test_bot,
|
||||
"web_request",
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
return_value = {"statusCode": 200, "content": "<html><a href='/m-einloggen.html'>login</a></html>"},
|
||||
side_effect = [("nicht-eingeloggt", 0), ("kein user signal", 0)],
|
||||
),
|
||||
patch.object(test_bot, "_has_logged_out_cta", new_callable = AsyncMock, return_value = False),
|
||||
):
|
||||
assert await test_bot.is_logged_in() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_uses_selector_group_timeout_key(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify login detection uses selector-group lookup with login_detection timeout key."""
|
||||
with patch.object(test_bot, "web_text_first_available", new_callable = AsyncMock, return_value = ("Welcome dummy_user", 0)) as group_text:
|
||||
assert await test_bot.is_logged_in(include_probe = False) is True
|
||||
|
||||
group_text.assert_awaited_once()
|
||||
call_args = group_text.await_args
|
||||
assert call_args is not None
|
||||
assert call_args.args[0] == [(By.CLASS_NAME, "mr-medium"), (By.ID, "user-email")]
|
||||
assert call_args.kwargs["key"] == "login_detection"
|
||||
assert call_args.kwargs["timeout"] == test_bot._timeout("login_detection")
|
||||
async def test_has_logged_out_cta_requires_visible_candidate(self, test_bot:KleinanzeigenBot) -> None:
|
||||
matched_element = MagicMock(spec = Element)
|
||||
with (
|
||||
patch.object(test_bot, "web_find_first_available", new_callable = AsyncMock, return_value = (matched_element, 0)),
|
||||
patch.object(test_bot, "_extract_visible_text", new_callable = AsyncMock, return_value = ""),
|
||||
):
|
||||
assert await test_bot._has_logged_out_cta() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_logs_selector_label_without_raw_selector_literals(
|
||||
async def test_has_logged_out_cta_accepts_visible_candidate(self, test_bot:KleinanzeigenBot) -> None:
|
||||
matched_element = MagicMock(spec = Element)
|
||||
with (
|
||||
patch.object(test_bot, "web_find_first_available", new_callable = AsyncMock, return_value = (matched_element, 0)),
|
||||
patch.object(test_bot, "_extract_visible_text", new_callable = AsyncMock, return_value = "Einloggen"),
|
||||
):
|
||||
assert await test_bot._has_logged_out_cta() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_uses_selector_group_timeout_key(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify login detection uses selector-group lookup with login_detection timeout key."""
|
||||
with patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
side_effect = [TimeoutError(), ("Welcome dummy_user", 0)],
|
||||
) as group_text:
|
||||
assert await test_bot.is_logged_in(include_probe = False) is True
|
||||
|
||||
group_text.assert_awaited()
|
||||
assert any(call.kwargs.get("timeout") == test_bot._timeout("login_detection") for call in group_text.await_args_list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_runs_full_selector_group_before_cta_precheck(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Quick CTA checks must not short-circuit before full logged-in selector checks."""
|
||||
with patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
side_effect = [TimeoutError(), ("Welcome dummy_user", 0)],
|
||||
) as group_text:
|
||||
assert await test_bot.is_logged_in(include_probe = False) is True
|
||||
|
||||
group_text.assert_awaited()
|
||||
assert group_text.await_count >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_short_circuits_before_cta_check_when_quick_user_signal_matches(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Logged-in quick pre-check should win even if incidental login links exist elsewhere."""
|
||||
with patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
return_value = ("angemeldet als: dummy_user", 0),
|
||||
) as group_text:
|
||||
assert await test_bot.is_logged_in(include_probe = False) is True
|
||||
|
||||
group_text.assert_awaited()
|
||||
assert group_text.await_count >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_logs_matched_raw_selector(
|
||||
self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Login detection logs should reference stable labels, not raw selector values."""
|
||||
"""Login detection logs should show the matched raw selector."""
|
||||
caplog.set_level("DEBUG")
|
||||
|
||||
with (
|
||||
caplog.at_level("DEBUG"),
|
||||
patch.object(test_bot, "web_text_first_available", new_callable = AsyncMock, return_value = ("angemeldet als: dummy_user", 1)),
|
||||
patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
return_value = ("angemeldet als: dummy_user", 0),
|
||||
),
|
||||
):
|
||||
assert await test_bot.is_logged_in(include_probe = False) is True
|
||||
|
||||
assert "Login detected via login detection selector 'user_info_secondary'" in caplog.text
|
||||
for forbidden in (".mr-medium", "#user-email", "mr-medium", "user-email"):
|
||||
assert forbidden not in caplog.text
|
||||
assert "Login detected via login detection selector" in caplog.text
|
||||
assert "CLASS_NAME=mr-medium" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_logs_generic_message_when_selector_group_does_not_match(
|
||||
@@ -509,78 +567,87 @@ class TestKleinanzeigenBotAuthentication:
|
||||
|
||||
with (
|
||||
caplog.at_level("DEBUG"),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError()]),
|
||||
patch.object(test_bot, "_has_logged_out_cta", new_callable = AsyncMock, return_value = False),
|
||||
):
|
||||
assert await test_bot.is_logged_in(include_probe = False) is False
|
||||
|
||||
assert any(
|
||||
record.message == "No login detected via configured login detection selectors (CLASS_NAME=mr-medium, ID=user-email)"
|
||||
for record in caplog.records
|
||||
)
|
||||
assert "No login detected via configured login detection selectors" in caplog.text
|
||||
assert "CLASS_NAME=mr-medium" in caplog.text
|
||||
assert "ID=user-email" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_logged_in_logs_raw_selectors_when_probe_reports_logged_out(
|
||||
async def test_is_logged_in_logs_raw_selectors_when_dom_checks_fail_and_probe_disabled(
|
||||
self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Probe-based final failure should include the tried raw selectors for debugging."""
|
||||
"""Final failure should report selectors and disabled-probe state."""
|
||||
caplog.set_level("DEBUG")
|
||||
|
||||
with (
|
||||
caplog.at_level("DEBUG"),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.LOGGED_OUT),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError()]),
|
||||
patch.object(test_bot, "_has_logged_out_cta", new_callable = AsyncMock, return_value = False),
|
||||
):
|
||||
assert await test_bot.is_logged_in() is False
|
||||
|
||||
assert any(
|
||||
record.message == (
|
||||
"No login detected - DOM login detection selectors (CLASS_NAME=mr-medium, ID=user-email) "
|
||||
"did not confirm login and server probe returned LOGGED_OUT"
|
||||
)
|
||||
for record in caplog.records
|
||||
)
|
||||
assert "No login detected via configured login detection selectors" in caplog.text
|
||||
assert "auth probe is disabled" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_prefers_dom_over_auth_probe(self, test_bot:KleinanzeigenBot) -> None:
|
||||
async def test_get_login_state_prefers_dom_checks(self, test_bot:KleinanzeigenBot) -> None:
|
||||
with (
|
||||
patch.object(test_bot, "web_text_first_available", new_callable = AsyncMock, return_value = ("Welcome dummy_user", 0)) as web_text,
|
||||
patch.object(
|
||||
test_bot, "_auth_probe_login_state", new_callable = AsyncMock, side_effect = AssertionError("Probe must not run when DOM is deterministic")
|
||||
) as probe,
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
new_callable = AsyncMock,
|
||||
return_value = ("Welcome dummy_user", 0),
|
||||
) as web_text,
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.LOGGED_IN
|
||||
web_text.assert_awaited_once()
|
||||
probe.assert_not_called()
|
||||
|
||||
def test_current_page_url_strips_query_and_fragment(self, test_bot:KleinanzeigenBot) -> None:
|
||||
page = MagicMock()
|
||||
page.url = "https://login.kleinanzeigen.de/u/login/password?state=secret&code=abc#frag"
|
||||
test_bot.page = page
|
||||
|
||||
assert test_bot._current_page_url() == "https://login.kleinanzeigen.de/u/login/password"
|
||||
|
||||
def test_is_valid_post_auth0_destination_filters_invalid_urls(self, test_bot:KleinanzeigenBot) -> None:
|
||||
assert test_bot._is_valid_post_auth0_destination("https://www.kleinanzeigen.de/") is True
|
||||
assert test_bot._is_valid_post_auth0_destination("https://www.kleinanzeigen.de/m-meine-anzeigen.html") is True
|
||||
assert test_bot._is_valid_post_auth0_destination("https://foo.kleinanzeigen.de/") is True
|
||||
assert test_bot._is_valid_post_auth0_destination("unknown") is False
|
||||
assert test_bot._is_valid_post_auth0_destination("about:blank") is False
|
||||
assert test_bot._is_valid_post_auth0_destination("https://evilkleinanzeigen.de/") is False
|
||||
assert test_bot._is_valid_post_auth0_destination("https://kleinanzeigen.de.evil.com/") is False
|
||||
assert test_bot._is_valid_post_auth0_destination("https://login.kleinanzeigen.de/u/login/password") is False
|
||||
assert test_bot._is_valid_post_auth0_destination("https://www.kleinanzeigen.de/login-error-500") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_falls_back_to_auth_probe_when_dom_inconclusive(self, test_bot:KleinanzeigenBot) -> None:
|
||||
async def test_get_login_state_returns_unknown_when_dom_checks_are_inconclusive(self, test_bot:KleinanzeigenBot) -> None:
|
||||
with (
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError) as web_text,
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.LOGGED_IN) as probe,
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.LOGGED_IN
|
||||
web_text.assert_awaited_once()
|
||||
probe.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_falls_back_to_auth_probe_when_dom_logged_out(self, test_bot:KleinanzeigenBot) -> None:
|
||||
with (
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError) as web_text,
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.LOGGED_OUT) as probe,
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.LOGGED_OUT
|
||||
web_text.assert_awaited_once()
|
||||
probe.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_returns_unknown_when_probe_unknown_and_dom_inconclusive(self, test_bot:KleinanzeigenBot) -> None:
|
||||
with (
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.UNKNOWN) as probe,
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError) as web_text,
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError()]) as web_text,
|
||||
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()) as cta_find,
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.UNKNOWN
|
||||
probe.assert_awaited_once()
|
||||
web_text.assert_awaited_once()
|
||||
assert web_text.await_count == 2
|
||||
assert cta_find.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_returns_logged_out_when_cta_detected(self, test_bot:KleinanzeigenBot) -> None:
|
||||
matched_element = MagicMock(spec = Element)
|
||||
with (
|
||||
patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
side_effect = [TimeoutError(), TimeoutError()],
|
||||
) as web_text,
|
||||
patch.object(test_bot, "web_find_first_available", new_callable = AsyncMock, return_value = (matched_element, 0)),
|
||||
patch.object(test_bot, "_extract_visible_text", new_callable = AsyncMock, return_value = "Hier einloggen"),
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.LOGGED_OUT
|
||||
assert web_text.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_state_unknown_captures_diagnostics_when_enabled(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||
@@ -592,8 +659,8 @@ class TestKleinanzeigenBotAuthentication:
|
||||
test_bot.page = page
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.UNKNOWN),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError(), TimeoutError(), TimeoutError()]),
|
||||
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.UNKNOWN
|
||||
|
||||
@@ -610,8 +677,8 @@ class TestKleinanzeigenBotAuthentication:
|
||||
test_bot.page = page
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.UNKNOWN),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError(), TimeoutError(), TimeoutError()]),
|
||||
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
|
||||
):
|
||||
assert await test_bot.get_login_state() == LoginState.UNKNOWN
|
||||
|
||||
@@ -633,8 +700,21 @@ class TestKleinanzeigenBotAuthentication:
|
||||
stdin_mock.isatty.return_value = True
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.UNKNOWN),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(
|
||||
test_bot,
|
||||
"web_text_first_available",
|
||||
side_effect = [
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
TimeoutError(),
|
||||
],
|
||||
),
|
||||
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
|
||||
patch("kleinanzeigen_bot.sys.stdin", stdin_mock),
|
||||
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
|
||||
):
|
||||
@@ -661,8 +741,8 @@ class TestKleinanzeigenBotAuthentication:
|
||||
stdin_mock.isatty.return_value = False
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "_auth_probe_login_state", new_callable = AsyncMock, return_value = LoginState.UNKNOWN),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = TimeoutError),
|
||||
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError(), TimeoutError(), TimeoutError()]),
|
||||
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
|
||||
patch("kleinanzeigen_bot.sys.stdin", stdin_mock),
|
||||
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
|
||||
):
|
||||
@@ -676,65 +756,71 @@ class TestKleinanzeigenBotAuthentication:
|
||||
with (
|
||||
patch.object(test_bot, "web_open") as mock_open,
|
||||
patch.object(test_bot, "get_login_state", new_callable = AsyncMock, side_effect = [LoginState.LOGGED_OUT, LoginState.LOGGED_IN]) as mock_logged_in,
|
||||
patch.object(test_bot, "web_find", side_effect = TimeoutError),
|
||||
patch.object(test_bot, "web_input") as mock_input,
|
||||
patch.object(test_bot, "web_click") as mock_click,
|
||||
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "fill_login_data_and_send", new_callable = AsyncMock) as mock_fill,
|
||||
patch.object(test_bot, "handle_after_login_logic", new_callable = AsyncMock) as mock_after_login,
|
||||
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
|
||||
):
|
||||
await test_bot.login()
|
||||
|
||||
mock_open.assert_called()
|
||||
mock_logged_in.assert_called()
|
||||
mock_input.assert_called()
|
||||
mock_click.assert_called()
|
||||
opened_urls = [call.args[0] for call in mock_open.call_args_list]
|
||||
assert any(url.startswith(test_bot.root_url) for url in opened_urls)
|
||||
assert any(url.endswith("/m-einloggen-sso.html") for url in opened_urls)
|
||||
mock_logged_in.assert_awaited()
|
||||
mock_fill.assert_awaited_once()
|
||||
mock_after_login.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_flow_handles_captcha(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify that login flow handles captcha correctly."""
|
||||
async def test_login_flow_returns_early_when_already_logged_in(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Login should return early when state is already LOGGED_IN."""
|
||||
with (
|
||||
patch.object(test_bot, "web_open"),
|
||||
patch.object(
|
||||
test_bot,
|
||||
"get_login_state",
|
||||
new_callable = AsyncMock,
|
||||
side_effect = [LoginState.LOGGED_OUT, LoginState.LOGGED_OUT, LoginState.LOGGED_IN],
|
||||
),
|
||||
patch.object(test_bot, "web_find") as mock_find,
|
||||
patch.object(test_bot, "web_input") as mock_input,
|
||||
patch.object(test_bot, "web_click") as mock_click,
|
||||
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
|
||||
patch.object(test_bot, "web_open") as mock_open,
|
||||
patch.object(test_bot, "get_login_state", new_callable = AsyncMock, return_value = LoginState.LOGGED_IN) as mock_state,
|
||||
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "fill_login_data_and_send", new_callable = AsyncMock) as mock_fill,
|
||||
patch.object(test_bot, "handle_after_login_logic", new_callable = AsyncMock) as mock_after_login,
|
||||
):
|
||||
# Mock the sequence of web_find calls:
|
||||
# First login attempt:
|
||||
# 1. Captcha iframe found (in check_and_wait_for_captcha)
|
||||
# 2. Phone verification not found (in handle_after_login_logic)
|
||||
# 3. Email verification not found (in handle_after_login_logic)
|
||||
# 4. GDPR banner not found (in handle_after_login_logic)
|
||||
# Second login attempt:
|
||||
# 5. Captcha iframe found (in check_and_wait_for_captcha)
|
||||
# 6. Phone verification not found (in handle_after_login_logic)
|
||||
# 7. Email verification not found (in handle_after_login_logic)
|
||||
# 8. GDPR banner not found (in handle_after_login_logic)
|
||||
mock_find.side_effect = [
|
||||
AsyncMock(), # Captcha iframe (first login)
|
||||
TimeoutError(), # Phone verification (first login)
|
||||
TimeoutError(), # Email verification (first login)
|
||||
TimeoutError(), # GDPR banner (first login)
|
||||
AsyncMock(), # Captcha iframe (second login)
|
||||
TimeoutError(), # Phone verification (second login)
|
||||
TimeoutError(), # Email verification (second login)
|
||||
TimeoutError(), # GDPR banner (second login)
|
||||
]
|
||||
mock_ainput.return_value = ""
|
||||
mock_input.return_value = AsyncMock()
|
||||
mock_click.return_value = AsyncMock()
|
||||
|
||||
await test_bot.login()
|
||||
|
||||
# Verify the complete flow
|
||||
assert mock_find.call_count == 8 # Exactly 8 web_find calls
|
||||
assert mock_ainput.call_count == 2 # Two captcha prompts
|
||||
assert mock_input.call_count == 6 # Two login attempts with username, clear password, and set password
|
||||
assert mock_click.call_count == 2 # Two submit button clicks
|
||||
mock_open.assert_awaited_once()
|
||||
assert mock_open.await_args is not None
|
||||
assert mock_open.await_args.args[0] == test_bot.root_url
|
||||
mock_state.assert_awaited_once()
|
||||
mock_fill.assert_not_called()
|
||||
mock_after_login.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_flow_raises_when_state_remains_unknown(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Post-login UNKNOWN state should fail fast with diagnostics."""
|
||||
with (
|
||||
patch.object(test_bot, "web_open"),
|
||||
patch.object(test_bot, "get_login_state", new_callable = AsyncMock, side_effect = [LoginState.LOGGED_OUT, LoginState.UNKNOWN]) as mock_state,
|
||||
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "fill_login_data_and_send", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "handle_after_login_logic", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_capture_login_detection_diagnostics_if_enabled", new_callable = AsyncMock) as mock_diagnostics,
|
||||
):
|
||||
with pytest.raises(AssertionError, match = "Login could not be confirmed"):
|
||||
await test_bot.login()
|
||||
|
||||
mock_diagnostics.assert_awaited_once()
|
||||
mock_state.assert_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_flow_raises_when_sso_navigation_times_out(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""SSO navigation timeout should trigger diagnostics and re-raise."""
|
||||
with (
|
||||
patch.object(test_bot, "web_open", new_callable = AsyncMock, side_effect = [None, TimeoutError("sso timeout")]),
|
||||
patch.object(test_bot, "get_login_state", new_callable = AsyncMock, return_value = LoginState.LOGGED_OUT) as mock_state,
|
||||
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_capture_login_detection_diagnostics_if_enabled", new_callable = AsyncMock) as mock_diagnostics,
|
||||
):
|
||||
with pytest.raises(TimeoutError, match = "sso timeout"):
|
||||
await test_bot.login()
|
||||
|
||||
mock_diagnostics.assert_awaited_once()
|
||||
mock_state.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_and_wait_for_captcha(self, test_bot:KleinanzeigenBot) -> None:
|
||||
@@ -762,62 +848,142 @@ class TestKleinanzeigenBotAuthentication:
|
||||
async def test_fill_login_data_and_send(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify that login form filling works correctly."""
|
||||
with (
|
||||
patch.object(test_bot, "_wait_for_auth0_login_context", new_callable = AsyncMock) as wait_context,
|
||||
patch.object(test_bot, "_wait_for_auth0_password_step", new_callable = AsyncMock) as wait_password,
|
||||
patch.object(test_bot, "_wait_for_post_auth0_submit_transition", new_callable = AsyncMock) as wait_transition,
|
||||
patch.object(test_bot, "web_input") as mock_input,
|
||||
patch.object(test_bot, "web_click") as mock_click,
|
||||
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock) as mock_captcha,
|
||||
):
|
||||
# Mock successful login form interaction
|
||||
mock_input.return_value = AsyncMock()
|
||||
mock_click.return_value = AsyncMock()
|
||||
|
||||
await test_bot.fill_login_data_and_send()
|
||||
|
||||
wait_context.assert_awaited_once()
|
||||
wait_password.assert_awaited_once()
|
||||
wait_transition.assert_awaited_once()
|
||||
assert mock_captcha.call_count == 1
|
||||
assert mock_input.call_count == 3 # Username, clear password, set password
|
||||
assert mock_click.call_count == 1 # Submit button
|
||||
assert mock_input.call_count == 2
|
||||
assert mock_click.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fill_login_data_and_send_logs_generic_start_message(
|
||||
self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with (
|
||||
caplog.at_level("INFO"),
|
||||
patch.object(test_bot, "_wait_for_auth0_login_context", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_wait_for_auth0_password_step", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_wait_for_post_auth0_submit_transition", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_input"),
|
||||
patch.object(test_bot, "web_click"),
|
||||
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
|
||||
):
|
||||
await test_bot.fill_login_data_and_send()
|
||||
|
||||
assert "Logging in..." in caplog.text
|
||||
assert test_bot.config.login.username not in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fill_login_data_and_send_fails_when_password_step_missing(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Missing Auth0 password step should fail fast."""
|
||||
with (
|
||||
patch.object(test_bot, "_wait_for_auth0_login_context", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_wait_for_auth0_password_step", new_callable = AsyncMock, side_effect = AssertionError("missing password")),
|
||||
patch.object(test_bot, "web_input") as mock_input,
|
||||
patch.object(test_bot, "web_click") as mock_click,
|
||||
):
|
||||
with pytest.raises(AssertionError, match = "missing password"):
|
||||
await test_bot.fill_login_data_and_send()
|
||||
|
||||
assert mock_input.call_count == 1
|
||||
assert mock_click.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_post_auth0_submit_transition_url_branch(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""URL transition success should return without fallback checks."""
|
||||
with (
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True) as mock_wait,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
|
||||
):
|
||||
await test_bot._wait_for_post_auth0_submit_transition()
|
||||
|
||||
mock_wait.assert_awaited_once()
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_post_auth0_submit_transition_dom_fallback_branch(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""DOM fallback should run when URL transition is inconclusive."""
|
||||
with (
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]) as mock_wait,
|
||||
patch.object(test_bot, "is_logged_in", new_callable = AsyncMock, return_value = True) as mock_is_logged_in,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
|
||||
):
|
||||
await test_bot._wait_for_post_auth0_submit_transition()
|
||||
|
||||
mock_wait.assert_awaited_once()
|
||||
mock_is_logged_in.assert_awaited_once()
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_post_auth0_submit_transition_sleep_fallback_branch(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Sleep fallback should run when bounded login check times out."""
|
||||
with (
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]) as mock_wait,
|
||||
patch.object(test_bot, "is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError) as mock_is_logged_in,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
|
||||
):
|
||||
with pytest.raises(TimeoutError, match = "Auth0 post-submit verification remained inconclusive"):
|
||||
await test_bot._wait_for_post_auth0_submit_transition()
|
||||
|
||||
mock_wait.assert_awaited_once()
|
||||
assert mock_is_logged_in.await_count == 2
|
||||
mock_sleep.assert_awaited_once()
|
||||
assert mock_sleep.await_args is not None
|
||||
sleep_kwargs = cast(Any, mock_sleep.await_args).kwargs
|
||||
assert sleep_kwargs["min_ms"] < sleep_kwargs["max_ms"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_post_auth0_submit_transition_sleep_fallback_when_login_not_confirmed(
|
||||
self, test_bot:KleinanzeigenBot
|
||||
) -> None:
|
||||
"""Sleep fallback should run when bounded login check returns False."""
|
||||
with (
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]) as mock_wait,
|
||||
patch.object(test_bot, "is_logged_in", new_callable = AsyncMock, return_value = False) as mock_is_logged_in,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
|
||||
):
|
||||
with pytest.raises(TimeoutError, match = "Auth0 post-submit verification remained inconclusive"):
|
||||
await test_bot._wait_for_post_auth0_submit_transition()
|
||||
|
||||
mock_wait.assert_awaited_once()
|
||||
assert mock_is_logged_in.await_count == 2
|
||||
mock_sleep.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_click_gdpr_banner_uses_quick_dom_timeout_and_passes_click_timeout(self, test_bot:KleinanzeigenBot) -> None:
|
||||
with (
|
||||
patch.object(test_bot, "_timeout", return_value = 1.25) as mock_timeout,
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||
):
|
||||
await test_bot._click_gdpr_banner()
|
||||
|
||||
mock_timeout.assert_called_once_with("quick_dom")
|
||||
mock_find.assert_awaited_once_with(By.ID, "gdpr-banner-accept", timeout = 1.25)
|
||||
mock_click.assert_awaited_once_with(By.ID, "gdpr-banner-accept", timeout = 1.25)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_after_login_logic(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Verify that post-login handling works correctly."""
|
||||
with (
|
||||
patch.object(test_bot, "web_find") as mock_find,
|
||||
patch.object(test_bot, "web_click") as mock_click,
|
||||
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
|
||||
patch.object(test_bot, "_check_sms_verification", new_callable = AsyncMock, side_effect = TimeoutError()) as mock_sms,
|
||||
patch.object(test_bot, "_check_email_verification", new_callable = AsyncMock, side_effect = TimeoutError()) as mock_email,
|
||||
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock, side_effect = TimeoutError()) as mock_gdpr,
|
||||
):
|
||||
# Test case 1: No special handling needed
|
||||
mock_find.side_effect = [TimeoutError(), TimeoutError(), TimeoutError()] # No phone verification, no email verification, no GDPR
|
||||
mock_click.return_value = AsyncMock()
|
||||
mock_ainput.return_value = ""
|
||||
|
||||
await test_bot.handle_after_login_logic()
|
||||
|
||||
assert mock_find.call_count == 3
|
||||
assert mock_click.call_count == 0
|
||||
assert mock_ainput.call_count == 0
|
||||
|
||||
# Test case 2: Phone verification needed
|
||||
mock_find.reset_mock()
|
||||
mock_click.reset_mock()
|
||||
mock_ainput.reset_mock()
|
||||
mock_find.side_effect = [AsyncMock(), TimeoutError(), TimeoutError()] # Phone verification found, no email verification, no GDPR
|
||||
|
||||
await test_bot.handle_after_login_logic()
|
||||
|
||||
assert mock_find.call_count == 3
|
||||
assert mock_click.call_count == 0 # No click needed, just wait for user
|
||||
assert mock_ainput.call_count == 1 # Wait for user to complete verification
|
||||
|
||||
# Test case 3: GDPR banner present
|
||||
mock_find.reset_mock()
|
||||
mock_click.reset_mock()
|
||||
mock_ainput.reset_mock()
|
||||
mock_find.side_effect = [TimeoutError(), TimeoutError(), AsyncMock()] # No phone verification, no email verification, GDPR found
|
||||
|
||||
await test_bot.handle_after_login_logic()
|
||||
|
||||
assert mock_find.call_count == 3
|
||||
assert mock_click.call_count == 2 # Click to accept GDPR and continue
|
||||
assert mock_ainput.call_count == 0
|
||||
mock_sms.assert_awaited_once()
|
||||
mock_email.assert_awaited_once()
|
||||
mock_gdpr.assert_awaited_once()
|
||||
|
||||
|
||||
class TestKleinanzeigenBotDiagnostics:
|
||||
@@ -864,9 +1030,10 @@ class TestKleinanzeigenBotDiagnostics:
|
||||
ad_cfg = Ad.model_validate(diagnostics_ad_config)
|
||||
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
|
||||
ad_file = str(tmp_path / "ad_000001_Test.yml")
|
||||
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps({"ads": []})}),
|
||||
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
|
||||
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
|
||||
):
|
||||
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
|
||||
@@ -905,9 +1072,10 @@ class TestKleinanzeigenBotDiagnostics:
|
||||
ad_cfg = Ad.model_validate(diagnostics_ad_config)
|
||||
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
|
||||
ad_file = str(tmp_path / "ad_000001_Test.yml")
|
||||
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps({"ads": []})}),
|
||||
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
|
||||
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
|
||||
):
|
||||
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
|
||||
@@ -1005,12 +1173,163 @@ class TestKleinanzeigenBotBasics:
|
||||
):
|
||||
await test_bot.publish_ads(ad_cfgs)
|
||||
|
||||
# With pagination, the URL now includes pageNum parameter
|
||||
web_request_mock.assert_awaited_once_with(f"{test_bot.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum=1")
|
||||
# web_request is called once for initial published-ads snapshot
|
||||
expected_url = f"{test_bot.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum=1"
|
||||
web_request_mock.assert_awaited_once_with(expected_url)
|
||||
publish_ad_mock.assert_awaited_once_with("ad.yaml", ad_cfgs[0][1], {}, [], AdUpdateStrategy.REPLACE)
|
||||
web_await_mock.assert_awaited_once()
|
||||
delete_ad_mock.assert_awaited_once_with(ad_cfgs[0][1], [], delete_old_ads_by_title = False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_ads_uses_millisecond_retry_delay_on_retryable_failure(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
mock_page:MagicMock,
|
||||
) -> None:
|
||||
"""Retry branch should sleep with explicit millisecond delay."""
|
||||
test_bot.page = mock_page
|
||||
test_bot.keep_old_ads = True
|
||||
|
||||
ad_cfg = Ad.model_validate(base_ad_config)
|
||||
ad_cfg_orig = copy.deepcopy(base_ad_config)
|
||||
ad_file = "ad.yaml"
|
||||
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
|
||||
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = [TimeoutError("transient"), None]) as publish_mock,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
|
||||
):
|
||||
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
|
||||
|
||||
assert publish_mock.await_count == 2
|
||||
sleep_mock.assert_awaited_once_with(2_000)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_ads_does_not_retry_when_submission_state_is_uncertain(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
mock_page:MagicMock,
|
||||
) -> None:
|
||||
"""Post-submit uncertainty must fail closed and skip retries."""
|
||||
test_bot.page = mock_page
|
||||
test_bot.keep_old_ads = True
|
||||
|
||||
ad_cfg = Ad.model_validate(base_ad_config)
|
||||
ad_cfg_orig = copy.deepcopy(base_ad_config)
|
||||
ad_file = "ad.yaml"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
test_bot,
|
||||
"web_request",
|
||||
new_callable = AsyncMock,
|
||||
return_value = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})},
|
||||
),
|
||||
patch.object(
|
||||
test_bot,
|
||||
"publish_ad",
|
||||
new_callable = AsyncMock,
|
||||
side_effect = PublishSubmissionUncertainError("submission may have succeeded before failure"),
|
||||
) as publish_mock,
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
|
||||
):
|
||||
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
|
||||
|
||||
assert publish_mock.await_count == 1
|
||||
sleep_mock.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_ad_keeps_pre_submit_timeouts_retryable(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
) -> None:
|
||||
"""Timeouts before submit boundary should remain plain retryable failures."""
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"})
|
||||
ad_cfg_orig = copy.deepcopy(base_ad_config)
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_open", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock, side_effect = TimeoutError("image upload timeout")),
|
||||
pytest.raises(TimeoutError, match = "image upload timeout"),
|
||||
):
|
||||
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_ad_marks_post_submit_timeout_as_uncertain(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
mock_page:MagicMock,
|
||||
) -> None:
|
||||
"""Timeouts after submit click should be converted to non-retryable uncertainty."""
|
||||
test_bot.page = mock_page
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"})
|
||||
ad_cfg_orig = copy.deepcopy(base_ad_config)
|
||||
|
||||
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
|
||||
if selector_type == By.ID and selector_value == "myftr-shppngcrt-frm":
|
||||
raise TimeoutError("no payment form")
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_open", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_special_attributes", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_input", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
|
||||
patch.object(test_bot, "web_execute", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect),
|
||||
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []),
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = TimeoutError("confirmation timeout")),
|
||||
pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"),
|
||||
):
|
||||
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_ad_marks_post_submit_protocol_exception_as_uncertain(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
mock_page:MagicMock,
|
||||
) -> None:
|
||||
"""Protocol exceptions after submit click should be converted to uncertainty."""
|
||||
test_bot.page = mock_page
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"})
|
||||
ad_cfg_orig = copy.deepcopy(base_ad_config)
|
||||
|
||||
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
|
||||
if selector_type == By.ID and selector_value == "myftr-shppngcrt-frm":
|
||||
raise TimeoutError("no payment form")
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_open", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_special_attributes", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_input", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
|
||||
patch.object(test_bot, "web_execute", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect),
|
||||
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []),
|
||||
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = ProtocolException(MagicMock(), "connection lost", 0)),
|
||||
pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"),
|
||||
):
|
||||
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
|
||||
|
||||
def test_get_root_url(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Test root URL retrieval."""
|
||||
assert test_bot.root_url == "https://www.kleinanzeigen.de"
|
||||
@@ -1817,6 +2136,84 @@ class TestKleinanzeigenBotShippingOptions:
|
||||
mock_set_condition.assert_called_once_with("67890") # Converted to string
|
||||
|
||||
|
||||
class TestShippingSelectorTimeout:
|
||||
"""Regression tests for commercial shipping selector (versand_s) timeout handling.
|
||||
|
||||
Ensures that TimeoutError from web_check (element absent) is caught gracefully,
|
||||
while TimeoutError from web_select (element found but interaction fails) propagates.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_versand_s_falls_back_to_dialog(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
|
||||
"""When versand_s selector is absent, web_check raises TimeoutError and the bot falls through to dialog-based shipping."""
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING"})
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_check", new_callable = AsyncMock, side_effect = TimeoutError("element not found")) as mock_check,
|
||||
patch.object(test_bot, "web_select", new_callable = AsyncMock) as mock_select,
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "web_input", new_callable = AsyncMock),
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
|
||||
|
||||
# Probe must have been awaited with quick_dom timeout
|
||||
mock_check.assert_awaited_once()
|
||||
assert mock_check.await_args is not None
|
||||
assert mock_check.await_args.kwargs["timeout"] == test_bot._timeout("quick_dom")
|
||||
|
||||
# web_select must NOT have been called with versand_s (commercial path was skipped)
|
||||
for call in mock_select.call_args_list:
|
||||
assert "versand_s" not in str(call), "web_select should not be called for versand_s when element is absent"
|
||||
|
||||
# Dialog-based fallback should have been triggered (click on "Versandmethoden auswählen")
|
||||
clicked_selectors = [str(c) for c in mock_click.call_args_list]
|
||||
assert any("Versandmethoden" in s for s in clicked_selectors), \
|
||||
"Expected dialog-based shipping fallback when versand_s is absent"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visible_versand_s_uses_commercial_select(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
|
||||
"""When versand_s selector is present, web_check succeeds and web_select sets the value."""
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING"})
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True) as mock_check,
|
||||
patch.object(test_bot, "web_select", new_callable = AsyncMock) as mock_select,
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
|
||||
|
||||
# Probe must have been awaited with quick_dom timeout
|
||||
mock_check.assert_awaited_once()
|
||||
assert mock_check.await_args is not None
|
||||
assert mock_check.await_args.kwargs["timeout"] == test_bot._timeout("quick_dom")
|
||||
|
||||
# web_select must have been awaited with versand_s and "ja" (SHIPPING)
|
||||
mock_select.assert_awaited_once_with(By.XPATH, '//select[contains(@id, ".versand_s")]', "ja")
|
||||
|
||||
# Dialog-based fallback should NOT have been triggered
|
||||
clicked_selectors = [str(c) for c in mock_click.call_args_list]
|
||||
assert not any("Versandmethoden" in s for s in clicked_selectors), \
|
||||
"Dialog-based shipping should not be triggered when versand_s is present"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_select_timeout_propagates_after_successful_probe(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
|
||||
"""When web_check succeeds but web_select raises TimeoutError, the error must propagate (not be swallowed)."""
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING"})
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True) as mock_check,
|
||||
patch.object(test_bot, "web_select", new_callable = AsyncMock, side_effect = TimeoutError("select timed out")),
|
||||
pytest.raises(TimeoutError, match = "select timed out"),
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
|
||||
|
||||
# Probe must have been awaited with quick_dom timeout
|
||||
mock_check.assert_awaited_once()
|
||||
assert mock_check.await_args is not None
|
||||
assert mock_check.await_args.kwargs["timeout"] == test_bot._timeout("quick_dom")
|
||||
|
||||
|
||||
class TestKleinanzeigenBotUrlConstruction:
|
||||
"""Tests for URL construction functionality."""
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class TestJSONPagination:
|
||||
async def test_fetch_published_ads_single_page_no_paging(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test fetching ads from single page with no paging info."""
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": '{"ads": [{"id": 1, "title": "Ad 1"}, {"id": 2, "title": "Ad 2"}]}'}
|
||||
mock_request.return_value = {"content": '{"ads": [{"id": 1, "state": "active", "title": "Ad 1"}, {"id": 2, "state": "active", "title": "Ad 2"}]}'}
|
||||
|
||||
result = await bot._fetch_published_ads()
|
||||
|
||||
@@ -109,7 +109,7 @@ class TestJSONPagination:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_single_page_with_paging(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test fetching ads from single page with paging info showing 1/1."""
|
||||
response_data = {"ads": [{"id": 1, "title": "Ad 1"}], "paging": {"pageNum": 1, "last": 1}}
|
||||
response_data = {"ads": [{"id": 1, "state": "active", "title": "Ad 1"}], "paging": {"pageNum": 1, "last": 1}}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": json.dumps(response_data)}
|
||||
@@ -125,9 +125,9 @@ class TestJSONPagination:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_multi_page(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test fetching ads from multiple pages (3 pages, 2 ads each)."""
|
||||
page1_data = {"ads": [{"id": 1}, {"id": 2}], "paging": {"pageNum": 1, "last": 3, "next": 2}}
|
||||
page2_data = {"ads": [{"id": 3}, {"id": 4}], "paging": {"pageNum": 2, "last": 3, "next": 3}}
|
||||
page3_data = {"ads": [{"id": 5}, {"id": 6}], "paging": {"pageNum": 3, "last": 3}}
|
||||
page1_data = {"ads": [{"id": 1, "state": "active"}, {"id": 2, "state": "active"}], "paging": {"pageNum": 1, "last": 3, "next": 2}}
|
||||
page2_data = {"ads": [{"id": 3, "state": "active"}, {"id": 4, "state": "active"}], "paging": {"pageNum": 2, "last": 3, "next": 3}}
|
||||
page3_data = {"ads": [{"id": 5, "state": "active"}, {"id": 6, "state": "active"}], "paging": {"pageNum": 3, "last": 3}}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.side_effect = [
|
||||
@@ -176,7 +176,7 @@ class TestJSONPagination:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_missing_paging_dict(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test handling of missing paging dict."""
|
||||
response_data = {"ads": [{"id": 1}, {"id": 2}]}
|
||||
response_data = {"ads": [{"id": 1, "state": "active"}, {"id": 2, "state": "active"}]}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": json.dumps(response_data)}
|
||||
@@ -190,7 +190,7 @@ class TestJSONPagination:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_non_integer_paging_values(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test handling of non-integer paging values."""
|
||||
response_data = {"ads": [{"id": 1}], "paging": {"pageNum": "invalid", "last": "also-invalid"}}
|
||||
response_data = {"ads": [{"id": 1, "state": "active"}], "paging": {"pageNum": "invalid", "last": "also-invalid"}}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": json.dumps(response_data)}
|
||||
@@ -219,6 +219,50 @@ class TestJSONPagination:
|
||||
if len(result) != 0:
|
||||
pytest.fail(f"expected empty list when 'ads' is not a list, got: {result}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_filters_non_dict_entries(self, bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Malformed entries should be filtered and logged."""
|
||||
response_data = {"ads": [42, {"id": 1, "state": "active"}, "broken"], "paging": {"pageNum": 1, "last": 1}}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": json.dumps(response_data)}
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = await bot._fetch_published_ads()
|
||||
|
||||
if result != [{"id": 1, "state": "active"}]:
|
||||
pytest.fail(f"expected malformed entries to be filtered out, got: {result}")
|
||||
if "Filtered 2 malformed ad entries on page 1" not in caplog.text:
|
||||
pytest.fail(f"expected malformed-entry warning in logs, got: {caplog.text}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_filters_dict_entries_missing_required_keys(
|
||||
self,
|
||||
bot:KleinanzeigenBot,
|
||||
caplog:pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Dict entries without required id/state keys should be rejected."""
|
||||
response_data = {
|
||||
"ads": [
|
||||
{"id": 1},
|
||||
{"state": "active"},
|
||||
{"title": "missing both"},
|
||||
{"id": 2, "state": "paused"},
|
||||
],
|
||||
"paging": {"pageNum": 1, "last": 1},
|
||||
}
|
||||
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": json.dumps(response_data)}
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = await bot._fetch_published_ads()
|
||||
|
||||
if result != [{"id": 2, "state": "paused"}]:
|
||||
pytest.fail(f"expected only entries with id and state to remain, got: {result}")
|
||||
if "Filtered 3 malformed ad entries on page 1" not in caplog.text:
|
||||
pytest.fail(f"expected malformed-entry warning in logs, got: {caplog.text}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_timeout(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test handling of timeout during pagination."""
|
||||
@@ -229,3 +273,17 @@ class TestJSONPagination:
|
||||
|
||||
if result != []:
|
||||
pytest.fail(f"Expected empty list on timeout, got {result}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_published_ads_handles_non_string_content_type(self, bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Unexpected non-string content types should stop pagination with warning."""
|
||||
with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request:
|
||||
mock_request.return_value = {"content": None}
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = await bot._fetch_published_ads()
|
||||
|
||||
if result != []:
|
||||
pytest.fail(f"expected empty result on non-string content, got: {result}")
|
||||
if "Unexpected response content type on page 1: NoneType" not in caplog.text:
|
||||
pytest.fail(f"expected non-string content warning in logs, got: {caplog.text}")
|
||||
|
||||
Reference in New Issue
Block a user