diff --git a/.pylint-disabled-rules b/.pylint-disabled-rules index 2cd55d43..de24b7e4 100644 --- a/.pylint-disabled-rules +++ b/.pylint-disabled-rules @@ -50,3 +50,4 @@ unnecessary-dunder-call, unnecessary-ellipsis, unused-private-member, typevar-name-incorrect-variance, +anomalous-backslash-in-string, diff --git a/.run/lint_pycodestyle.sh b/.run/lint_pycodestyle.sh index 5da1f3f5..530fad4e 100755 --- a/.run/lint_pycodestyle.sh +++ b/.run/lint_pycodestyle.sh @@ -1,3 +1,3 @@ #!/bin/bash -pycodestyle $(pwd) --ignore=E501,W503,E402,E731,E203,E704 --exclude=.venv +pycodestyle $(pwd) --ignore=E501,W503,E402,E731,E203,E704,W605 --exclude=.venv diff --git a/.run/serve_docs.sh b/.run/serve_docs.sh new file mode 100755 index 00000000..e0ca706c --- /dev/null +++ b/.run/serve_docs.sh @@ -0,0 +1,2 @@ +#!/bin/bash +mkdocs serve diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c3605e..d61592f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,12 @@ TODOs: ### TODO: implement multi entity conditions +### TODO: doc «How to extend Selene» – from my_project_tests.extensions.selene import browser, have + +### TODO: what about such style of filtering: balanced[balanced > 1] + +### TODO: consider making description in Condition optional, and support tuple queries + ## 2.0.0rc10: «copy&paste, frames, shadow & texts_like» (to be released on DD.05.2024) ### TODO: should we help users do not shoot their legs when using browser.all(selector) in for loops? #534 @@ -113,15 +119,19 @@ TODOs: ### TODO: ensure sub-classed condition == condition.not_.not_ +### TODO: clean docstrings for Condition and ConditionMismatch ### TODO: ENSURE ALL Condition.as_not USAGES ARE NOW CORRECT ... -### TODO: deprecate and/or rename condition.not_ to condition.negated or condition.inverted + +### TODO: ENSURE composed conditions work as expected (or/and, etc.) ... +### TODO: decide on Match fate (alias or subclass, or subclass + match* 2 in 1) + ### Text related conditions now accepts int and floats as text item `.have.exact_texts(1, 2.0, '3')` is now possible, and will be treated as `['1', '2.0', '3']` @@ -442,6 +452,10 @@ Thanks to [Cameron Shimmin](https://github.com/cshimm) and Edale Miguel for PR [ - there is also an experimental `condition._match`, that is actually aliased by `condition.__call__` - `ConditionNotMatchedError` in favor of `ConditionMismatch` +### Refactorings + +- moved `Query` & Co from `core/wait.py` to `common/_typing_functioins.py` + ## 2.0.0rc9 (to be released on 06.03.2024) ### Click with offsets diff --git a/docs/faq/iframes-howto.md b/docs/faq/iframes-howto.md index 13f36b5a..ea2c7323 100644 --- a/docs/faq/iframes-howto.md +++ b/docs/faq/iframes-howto.md @@ -1,5 +1,7 @@ # How to work with iframes in Selene? +{% include-markdown 'warn-from-next-release.md' %} + You allways can work with iframes same way [as you do in pure Selenium](https://www.selenium.dev/documentation/webdriver/interactions/frames), by using `browser.driver.switch_to.*` commands: ```python diff --git a/docs/faq/index.md b/docs/faq/index.md index 5bf81d53..ef0937d3 100644 --- a/docs/faq/index.md +++ b/docs/faq/index.md @@ -2,4 +2,7 @@ ## Browser configuration +- [How to work with iFrames](iframes-howto.md) +- [How to work with Shadow DOM](shadow-dom-howto.md) - [How to use custom profile](custom-user-profile-howto.md) +- [Ho to extend Selene](extending-selene-howto.md) diff --git a/docs/faq/shadow-dom-howto.md b/docs/faq/shadow-dom-howto.md index b43e0d73..d8be0370 100644 --- a/docs/faq/shadow-dom-howto.md +++ b/docs/faq/shadow-dom-howto.md @@ -1,5 +1,7 @@ # How to work with Shadow DOM in Selene? +{% include-markdown 'warn-from-next-release.md' %} + – By using advanced [query.js.shadow_root][selene.core.query.js.shadow_root] and [query.js.shadow_roots][selene.core.query.js.shadow_roots] queries, as simply as: ```python diff --git a/docs/reference/condition.md b/docs/reference/condition.md new file mode 100644 index 00000000..7549fec7 --- /dev/null +++ b/docs/reference/condition.md @@ -0,0 +1,11 @@ +# Expected conditions + +{% include-markdown 'warn-from-next-release.md' %} + +::: selene.core.condition + options: + show_root_toc_entry: false + show_if_no_docstring: false + members_order: alphabetical + filters: + - "!__.*" diff --git a/docs/reference/index.md b/docs/reference/index.md index 2f88e722..b594e767 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -3,3 +3,6 @@ ## Core - [Config](configuration.md) +- [Expected Conditions](condition.md) +- [Command](command.md) +- [Query](query.md) diff --git a/docs/reference/query.md b/docs/reference/query.md index 4c34eb7e..f291053f 100644 --- a/docs/reference/query.md +++ b/docs/reference/query.md @@ -1,5 +1,7 @@ # +{% include-markdown 'warn-from-next-release.md' %} + ::: selene.core.query options: show_root_toc_entry: false diff --git a/docs/warn-from-next-release.md b/docs/warn-from-next-release.md new file mode 100644 index 00000000..fee0481b --- /dev/null +++ b/docs/warn-from-next-release.md @@ -0,0 +1,4 @@ +!!! warning "This is the latest development version" + + Some features documented on this page may not yet be available + in the published stable version. diff --git a/mkdocs.yml b/mkdocs.yml index e0f19c98..15e57afb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,6 +28,7 @@ nav: - Config: reference/configuration.md - command.*: reference/command.md - query.*: reference/query.md + - Expected Conditions: reference/condition.md - Contribution: - How to contribute: contribution/to-source-code-guide.md - Code conventions: contribution/code-conventions-guide.md @@ -72,6 +73,7 @@ theme: - content.action.view extra: + version: 2.0.0rc10 social: - icon: material/web link: https://autotest.how/sdet-start-ru @@ -93,7 +95,9 @@ extra: # Plugins plugins: - search + - macros - autorefs + - include-markdown - mkdocstrings: handlers: python: diff --git a/poetry.lock b/poetry.lock index a14c4a79..943c65a0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -127,15 +127,26 @@ d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "bracex" +version = "2.4" +description = "Bash style brace expander." +optional = false +python-versions = ">=3.8" +files = [ + {file = "bracex-2.4-py3-none-any.whl", hash = "sha256:efdc71eff95eaff5e0f8cfebe7d01adf2c8637c8c92edaf63ef348c241a82418"}, + {file = "bracex-2.4.tar.gz", hash = "sha256:a27eaf1df42cf561fed58b7a8f3fdf129d1ea16a81e1fadd1d17989bc6384beb"}, +] + [[package]] name = "certifi" -version = "2024.2.2" +version = "2024.6.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, + {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, ] [[package]] @@ -328,63 +339,63 @@ files = [ [[package]] name = "coverage" -version = "7.5.1" +version = "7.5.3" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0884920835a033b78d1c73b6d3bbcda8161a900f38a488829a83982925f6c2e"}, - {file = "coverage-7.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:39afcd3d4339329c5f58de48a52f6e4e50f6578dd6099961cf22228feb25f38f"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7b0ceee8147444347da6a66be737c9d78f3353b0681715b668b72e79203e4a"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a9ca3f2fae0088c3c71d743d85404cec8df9be818a005ea065495bedc33da35"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd215c0c7d7aab005221608a3c2b46f58c0285a819565887ee0b718c052aa4e"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4bf0655ab60d754491004a5efd7f9cccefcc1081a74c9ef2da4735d6ee4a6223"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61c4bf1ba021817de12b813338c9be9f0ad5b1e781b9b340a6d29fc13e7c1b5e"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:db66fc317a046556a96b453a58eced5024af4582a8dbdc0c23ca4dbc0d5b3146"}, - {file = "coverage-7.5.1-cp310-cp310-win32.whl", hash = "sha256:b016ea6b959d3b9556cb401c55a37547135a587db0115635a443b2ce8f1c7228"}, - {file = "coverage-7.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:df4e745a81c110e7446b1cc8131bf986157770fa405fe90e15e850aaf7619bc8"}, - {file = "coverage-7.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:796a79f63eca8814ca3317a1ea443645c9ff0d18b188de470ed7ccd45ae79428"}, - {file = "coverage-7.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fc84a37bfd98db31beae3c2748811a3fa72bf2007ff7902f68746d9757f3746"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6175d1a0559986c6ee3f7fccfc4a90ecd12ba0a383dcc2da30c2b9918d67d8a3"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fc81d5878cd6274ce971e0a3a18a8803c3fe25457165314271cf78e3aae3aa2"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:556cf1a7cbc8028cb60e1ff0be806be2eded2daf8129b8811c63e2b9a6c43bca"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9981706d300c18d8b220995ad22627647be11a4276721c10911e0e9fa44c83e8"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d7fed867ee50edf1a0b4a11e8e5d0895150e572af1cd6d315d557758bfa9c057"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef48e2707fb320c8f139424a596f5b69955a85b178f15af261bab871873bb987"}, - {file = "coverage-7.5.1-cp311-cp311-win32.whl", hash = "sha256:9314d5678dcc665330df5b69c1e726a0e49b27df0461c08ca12674bcc19ef136"}, - {file = "coverage-7.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fa567e99765fe98f4e7d7394ce623e794d7cabb170f2ca2ac5a4174437e90dd"}, - {file = "coverage-7.5.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b6cf3764c030e5338e7f61f95bd21147963cf6aa16e09d2f74f1fa52013c1206"}, - {file = "coverage-7.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ec92012fefebee89a6b9c79bc39051a6cb3891d562b9270ab10ecfdadbc0c34"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16db7f26000a07efcf6aea00316f6ac57e7d9a96501e990a36f40c965ec7a95d"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beccf7b8a10b09c4ae543582c1319c6df47d78fd732f854ac68d518ee1fb97fa"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8748731ad392d736cc9ccac03c9845b13bb07d020a33423fa5b3a36521ac6e4e"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7352b9161b33fd0b643ccd1f21f3a3908daaddf414f1c6cb9d3a2fd618bf2572"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7a588d39e0925f6a2bff87154752481273cdb1736270642aeb3635cb9b4cad07"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:68f962d9b72ce69ea8621f57551b2fa9c70509af757ee3b8105d4f51b92b41a7"}, - {file = "coverage-7.5.1-cp312-cp312-win32.whl", hash = "sha256:f152cbf5b88aaeb836127d920dd0f5e7edff5a66f10c079157306c4343d86c19"}, - {file = "coverage-7.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:5a5740d1fb60ddf268a3811bcd353de34eb56dc24e8f52a7f05ee513b2d4f596"}, - {file = "coverage-7.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e2213def81a50519d7cc56ed643c9e93e0247f5bbe0d1247d15fa520814a7cd7"}, - {file = "coverage-7.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5037f8fcc2a95b1f0e80585bd9d1ec31068a9bcb157d9750a172836e98bc7a90"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3721c2c9e4c4953a41a26c14f4cef64330392a6d2d675c8b1db3b645e31f0e"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca498687ca46a62ae590253fba634a1fe9836bc56f626852fb2720f334c9e4e5"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cdcbc320b14c3e5877ee79e649677cb7d89ef588852e9583e6b24c2e5072661"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:57e0204b5b745594e5bc14b9b50006da722827f0b8c776949f1135677e88d0b8"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fe7502616b67b234482c3ce276ff26f39ffe88adca2acf0261df4b8454668b4"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9e78295f4144f9dacfed4f92935fbe1780021247c2fabf73a819b17f0ccfff8d"}, - {file = "coverage-7.5.1-cp38-cp38-win32.whl", hash = "sha256:1434e088b41594baa71188a17533083eabf5609e8e72f16ce8c186001e6b8c41"}, - {file = "coverage-7.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:0646599e9b139988b63704d704af8e8df7fa4cbc4a1f33df69d97f36cb0a38de"}, - {file = "coverage-7.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4cc37def103a2725bc672f84bd939a6fe4522310503207aae4d56351644682f1"}, - {file = "coverage-7.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fc0b4d8bfeabd25ea75e94632f5b6e047eef8adaed0c2161ada1e922e7f7cece"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d0a0f5e06881ecedfe6f3dd2f56dcb057b6dbeb3327fd32d4b12854df36bf26"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9735317685ba6ec7e3754798c8871c2f49aa5e687cc794a0b1d284b2389d1bd5"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d21918e9ef11edf36764b93101e2ae8cc82aa5efdc7c5a4e9c6c35a48496d601"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c3e757949f268364b96ca894b4c342b41dc6f8f8b66c37878aacef5930db61be"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:79afb6197e2f7f60c4824dd4b2d4c2ec5801ceb6ba9ce5d2c3080e5660d51a4f"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d1d0d98d95dd18fe29dc66808e1accf59f037d5716f86a501fc0256455219668"}, - {file = "coverage-7.5.1-cp39-cp39-win32.whl", hash = "sha256:1cc0fe9b0b3a8364093c53b0b4c0c2dd4bb23acbec4c9240b5f284095ccf7981"}, - {file = "coverage-7.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:dde0070c40ea8bb3641e811c1cfbf18e265d024deff6de52c5950677a8fb1e0f"}, - {file = "coverage-7.5.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:6537e7c10cc47c595828b8a8be04c72144725c383c4702703ff4e42e44577312"}, - {file = "coverage-7.5.1.tar.gz", hash = "sha256:54de9ef3a9da981f7af93eafde4ede199e0846cd819eb27c88e2b712aae9708c"}, + {file = "coverage-7.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a6519d917abb15e12380406d721e37613e2a67d166f9fb7e5a8ce0375744cd45"}, + {file = "coverage-7.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aea7da970f1feccf48be7335f8b2ca64baf9b589d79e05b9397a06696ce1a1ec"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:923b7b1c717bd0f0f92d862d1ff51d9b2b55dbbd133e05680204465f454bb286"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62bda40da1e68898186f274f832ef3e759ce929da9a9fd9fcf265956de269dbc"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8b7339180d00de83e930358223c617cc343dd08e1aa5ec7b06c3a121aec4e1d"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:25a5caf742c6195e08002d3b6c2dd6947e50efc5fc2c2205f61ecb47592d2d83"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:05ac5f60faa0c704c0f7e6a5cbfd6f02101ed05e0aee4d2822637a9e672c998d"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:239a4e75e09c2b12ea478d28815acf83334d32e722e7433471fbf641c606344c"}, + {file = "coverage-7.5.3-cp310-cp310-win32.whl", hash = "sha256:a5812840d1d00eafae6585aba38021f90a705a25b8216ec7f66aebe5b619fb84"}, + {file = "coverage-7.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:33ca90a0eb29225f195e30684ba4a6db05dbef03c2ccd50b9077714c48153cac"}, + {file = "coverage-7.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f81bc26d609bf0fbc622c7122ba6307993c83c795d2d6f6f6fd8c000a770d974"}, + {file = "coverage-7.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cec2af81f9e7569280822be68bd57e51b86d42e59ea30d10ebdbb22d2cb7232"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55f689f846661e3f26efa535071775d0483388a1ccfab899df72924805e9e7cd"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50084d3516aa263791198913a17354bd1dc627d3c1639209640b9cac3fef5807"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:341dd8f61c26337c37988345ca5c8ccabeff33093a26953a1ac72e7d0103c4fb"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ab0b028165eea880af12f66086694768f2c3139b2c31ad5e032c8edbafca6ffc"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5bc5a8c87714b0c67cfeb4c7caa82b2d71e8864d1a46aa990b5588fa953673b8"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:38a3b98dae8a7c9057bd91fbf3415c05e700a5114c5f1b5b0ea5f8f429ba6614"}, + {file = "coverage-7.5.3-cp311-cp311-win32.whl", hash = "sha256:fcf7d1d6f5da887ca04302db8e0e0cf56ce9a5e05f202720e49b3e8157ddb9a9"}, + {file = "coverage-7.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:8c836309931839cca658a78a888dab9676b5c988d0dd34ca247f5f3e679f4e7a"}, + {file = "coverage-7.5.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:296a7d9bbc598e8744c00f7a6cecf1da9b30ae9ad51c566291ff1314e6cbbed8"}, + {file = "coverage-7.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:34d6d21d8795a97b14d503dcaf74226ae51eb1f2bd41015d3ef332a24d0a17b3"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e317953bb4c074c06c798a11dbdd2cf9979dbcaa8ccc0fa4701d80042d4ebf1"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:705f3d7c2b098c40f5b81790a5fedb274113373d4d1a69e65f8b68b0cc26f6db"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1196e13c45e327d6cd0b6e471530a1882f1017eb83c6229fc613cd1a11b53cd"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:015eddc5ccd5364dcb902eaecf9515636806fa1e0d5bef5769d06d0f31b54523"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fd27d8b49e574e50caa65196d908f80e4dff64d7e592d0c59788b45aad7e8b35"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:33fc65740267222fc02975c061eb7167185fef4cc8f2770267ee8bf7d6a42f84"}, + {file = "coverage-7.5.3-cp312-cp312-win32.whl", hash = "sha256:7b2a19e13dfb5c8e145c7a6ea959485ee8e2204699903c88c7d25283584bfc08"}, + {file = "coverage-7.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0bbddc54bbacfc09b3edaec644d4ac90c08ee8ed4844b0f86227dcda2d428fcb"}, + {file = "coverage-7.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f78300789a708ac1f17e134593f577407d52d0417305435b134805c4fb135adb"}, + {file = "coverage-7.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b368e1aee1b9b75757942d44d7598dcd22a9dbb126affcbba82d15917f0cc155"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f836c174c3a7f639bded48ec913f348c4761cbf49de4a20a956d3431a7c9cb24"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:244f509f126dc71369393ce5fea17c0592c40ee44e607b6d855e9c4ac57aac98"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4c2872b3c91f9baa836147ca33650dc5c172e9273c808c3c3199c75490e709d"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dd4b3355b01273a56b20c219e74e7549e14370b31a4ffe42706a8cda91f19f6d"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f542287b1489c7a860d43a7d8883e27ca62ab84ca53c965d11dac1d3a1fab7ce"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:75e3f4e86804023e991096b29e147e635f5e2568f77883a1e6eed74512659ab0"}, + {file = "coverage-7.5.3-cp38-cp38-win32.whl", hash = "sha256:c59d2ad092dc0551d9f79d9d44d005c945ba95832a6798f98f9216ede3d5f485"}, + {file = "coverage-7.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:fa21a04112c59ad54f69d80e376f7f9d0f5f9123ab87ecd18fbb9ec3a2beed56"}, + {file = "coverage-7.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5102a92855d518b0996eb197772f5ac2a527c0ec617124ad5242a3af5e25f85"}, + {file = "coverage-7.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d1da0a2e3b37b745a2b2a678a4c796462cf753aebf94edcc87dcc6b8641eae31"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8383a6c8cefba1b7cecc0149415046b6fc38836295bc4c84e820872eb5478b3d"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9aad68c3f2566dfae84bf46295a79e79d904e1c21ccfc66de88cd446f8686341"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e079c9ec772fedbade9d7ebc36202a1d9ef7291bc9b3a024ca395c4d52853d7"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bde997cac85fcac227b27d4fb2c7608a2c5f6558469b0eb704c5726ae49e1c52"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:990fb20b32990b2ce2c5f974c3e738c9358b2735bc05075d50a6f36721b8f303"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3d5a67f0da401e105753d474369ab034c7bae51a4c31c77d94030d59e41df5bd"}, + {file = "coverage-7.5.3-cp39-cp39-win32.whl", hash = "sha256:e08c470c2eb01977d221fd87495b44867a56d4d594f43739a8028f8646a51e0d"}, + {file = "coverage-7.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:1d2a830ade66d3563bb61d1e3c77c8def97b30ed91e166c67d0632c018f380f0"}, + {file = "coverage-7.5.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:3538d8fb1ee9bdd2e2692b3b18c22bb1c19ffbefd06880f5ac496e42d7bb3884"}, + {file = "coverage-7.5.3.tar.gz", hash = "sha256:04aefca5190d1dc7a53a4c1a5a7f8568811306d7a8ee231c42fb69215571944f"}, ] [package.dependencies] @@ -484,13 +495,13 @@ test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", [[package]] name = "griffe" -version = "0.45.0" +version = "0.45.2" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." optional = false python-versions = ">=3.8" files = [ - {file = "griffe-0.45.0-py3-none-any.whl", hash = "sha256:90fe5c90e1b0ca7dd6fee78f9009f4e01b37dbc9ab484a9b2c1578915db1e571"}, - {file = "griffe-0.45.0.tar.gz", hash = "sha256:85cb2868d026ea51c89bdd589ad3ccc94abc5bd8d5d948e3d4450778a2a05b4a"}, + {file = "griffe-0.45.2-py3-none-any.whl", hash = "sha256:297ec8530d0c68e5b98ff86fb588ebc3aa3559bb5dc21f3caea8d9542a350133"}, + {file = "griffe-0.45.2.tar.gz", hash = "sha256:83ce7dcaafd8cb7f43cbf1a455155015a1eb624b1ffd93249e5e1c4a22b2fdb2"}, ] [package.dependencies] @@ -801,13 +812,13 @@ pyyaml = ">=5.1" [[package]] name = "mkdocs-git-revision-date-localized-plugin" -version = "1.2.5" +version = "1.2.6" description = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file." optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_git_revision_date_localized_plugin-1.2.5-py3-none-any.whl", hash = "sha256:d796a18b07cfcdb154c133e3ec099d2bb5f38389e4fd54d3eb516a8a736815b8"}, - {file = "mkdocs_git_revision_date_localized_plugin-1.2.5.tar.gz", hash = "sha256:0c439816d9d0dba48e027d9d074b2b9f1d7cd179f74ba46b51e4da7bb3dc4b9b"}, + {file = "mkdocs_git_revision_date_localized_plugin-1.2.6-py3-none-any.whl", hash = "sha256:f015cb0f3894a39b33447b18e270ae391c4e25275cac5a626e80b243784e2692"}, + {file = "mkdocs_git_revision_date_localized_plugin-1.2.6.tar.gz", hash = "sha256:e432942ce4ee8aa9b9f4493e993dee9d2cc08b3ea2b40a3d6b03ca0f2a4bcaa2"}, ] [package.dependencies] @@ -816,15 +827,54 @@ GitPython = "*" mkdocs = ">=1.0" pytz = "*" +[[package]] +name = "mkdocs-include-markdown-plugin" +version = "6.1.1" +description = "Mkdocs Markdown includer plugin." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_include_markdown_plugin-6.1.1-py3-none-any.whl", hash = "sha256:b05dc442bd5ffc34694e96c41ee23f7cea0d34f11bdbe0186f8a3a4407e8ddd4"}, + {file = "mkdocs_include_markdown_plugin-6.1.1.tar.gz", hash = "sha256:5f1d6e4a49e43a4d0139e5a20a74caccc390fb65f24f7616d960211502a548d5"}, +] + +[package.dependencies] +mkdocs = ">=1.4" +wcmatch = ">=8,<9" + +[package.extras] +cache = ["platformdirs"] + +[[package]] +name = "mkdocs-macros-plugin" +version = "1.0.5" +description = "Unleash the power of MkDocs with macros and variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs-macros-plugin-1.0.5.tar.gz", hash = "sha256:fe348d75f01c911f362b6d998c57b3d85b505876dde69db924f2c512c395c328"}, + {file = "mkdocs_macros_plugin-1.0.5-py3-none-any.whl", hash = "sha256:f60e26f711f5a830ddf1e7980865bf5c0f1180db56109803cdd280073c1a050a"}, +] + +[package.dependencies] +jinja2 = "*" +mkdocs = ">=0.17" +python-dateutil = "*" +pyyaml = "*" +termcolor = "*" + +[package.extras] +test = ["mkdocs-include-markdown-plugin", "mkdocs-macros-test", "mkdocs-material (>=6.2)"] + [[package]] name = "mkdocs-material" -version = "9.5.23" +version = "9.5.25" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.23-py3-none-any.whl", hash = "sha256:ffd08a5beaef3cd135aceb58ded8b98bbbbf2b70e5b656f6a14a63c917d9b001"}, - {file = "mkdocs_material-9.5.23.tar.gz", hash = "sha256:4627fc3f15de2cba2bde9debc2fd59b9888ef494beabfe67eb352e23d14bf288"}, + {file = "mkdocs_material-9.5.25-py3-none-any.whl", hash = "sha256:68fdab047a0b9bfbefe79ce267e8a7daaf5128bcf7867065fcd201ee335fece1"}, + {file = "mkdocs_material-9.5.25.tar.gz", hash = "sha256:d0662561efb725b712207e0ee01f035ca15633f29a64628e24f01ec99d7078f4"}, ] [package.dependencies] @@ -1204,13 +1254,13 @@ files = [ [[package]] name = "pytest" -version = "8.2.0" +version = "8.2.1" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"}, - {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"}, + {file = "pytest-8.2.1-py3-none-any.whl", hash = "sha256:faccc5d332b8c3719f40283d0d44aa5cf101cec36f88cde9ed8f2bc0538612b1"}, + {file = "pytest-8.2.1.tar.gz", hash = "sha256:5046e5b46d8e4cac199c373041f26be56fdb81eb4e67dc11d4e10811fc3408fd"}, ] [package.dependencies] @@ -1434,13 +1484,13 @@ files = [ [[package]] name = "requests" -version = "2.31.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -1455,13 +1505,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "selenium" -version = "4.20.0" +version = "4.21.0" description = "" optional = false python-versions = ">=3.8" files = [ - {file = "selenium-4.20.0-py3-none-any.whl", hash = "sha256:b1d0c33b38ca27d0499183e48e1dd09ff26973481f5d3ef2983073813ae6588d"}, - {file = "selenium-4.20.0.tar.gz", hash = "sha256:0bd564ee166980d419a8aaf4ac00289bc152afcf2eadca5efe8c8e36711853fd"}, + {file = "selenium-4.21.0-py3-none-any.whl", hash = "sha256:4770ffe5a5264e609de7dc914be6b89987512040d5a8efb2abb181330d097993"}, + {file = "selenium-4.21.0.tar.gz", hash = "sha256:650dbfa5159895ff00ad16e5ddb6ceecb86b90c7ed2012b3f041f64e6e4904fe"}, ] [package.dependencies] @@ -1473,19 +1523,18 @@ urllib3 = {version = ">=1.26,<3", extras = ["socks"]} [[package]] name = "setuptools" -version = "69.5.1" +version = "70.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, - {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, + {file = "setuptools-70.0.0-py3-none-any.whl", hash = "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4"}, + {file = "setuptools-70.0.0.tar.gz", hash = "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "six" @@ -1531,6 +1580,20 @@ files = [ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] +[[package]] +name = "termcolor" +version = "2.4.0" +description = "ANSI color formatting for output in terminal" +optional = false +python-versions = ">=3.8" +files = [ + {file = "termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63"}, + {file = "termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a"}, +] + +[package.extras] +tests = ["pytest", "pytest-cov"] + [[package]] name = "tomli" version = "2.0.1" @@ -1544,13 +1607,13 @@ files = [ [[package]] name = "trio" -version = "0.25.0" +version = "0.25.1" description = "A friendly Python library for async concurrency and I/O" optional = false python-versions = ">=3.8" files = [ - {file = "trio-0.25.0-py3-none-any.whl", hash = "sha256:e6458efe29cc543e557a91e614e2b51710eba2961669329ce9c862d50c6e8e81"}, - {file = "trio-0.25.0.tar.gz", hash = "sha256:9b41f5993ad2c0e5f62d0acca320ec657fdb6b2a2c22b8c7aed6caf154475c4e"}, + {file = "trio-0.25.1-py3-none-any.whl", hash = "sha256:e42617ba091e7b2e50c899052e83a3c403101841de925187f61e7b7eaebdf3fb"}, + {file = "trio-0.25.1.tar.gz", hash = "sha256:9f5314f014ea3af489e77b001861c535005c3858d38ec46b6b071ebfa339d7fb"}, ] [package.dependencies] @@ -1580,13 +1643,13 @@ wsproto = ">=0.14" [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.1" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"}, + {file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"}, ] [[package]] @@ -1611,45 +1674,62 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "watchdog" -version = "4.0.0" +version = "4.0.1" description = "Filesystem events monitoring" optional = false python-versions = ">=3.8" files = [ - {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, - {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, - {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8"}, - {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, - {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, - {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, - {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, - {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, - {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, - {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, + {file = "watchdog-4.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:da2dfdaa8006eb6a71051795856bedd97e5b03e57da96f98e375682c48850645"}, + {file = "watchdog-4.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e93f451f2dfa433d97765ca2634628b789b49ba8b504fdde5837cdcf25fdb53b"}, + {file = "watchdog-4.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ef0107bbb6a55f5be727cfc2ef945d5676b97bffb8425650dadbb184be9f9a2b"}, + {file = "watchdog-4.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:17e32f147d8bf9657e0922c0940bcde863b894cd871dbb694beb6704cfbd2fb5"}, + {file = "watchdog-4.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:03e70d2df2258fb6cb0e95bbdbe06c16e608af94a3ffbd2b90c3f1e83eb10767"}, + {file = "watchdog-4.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123587af84260c991dc5f62a6e7ef3d1c57dfddc99faacee508c71d287248459"}, + {file = "watchdog-4.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:093b23e6906a8b97051191a4a0c73a77ecc958121d42346274c6af6520dec175"}, + {file = "watchdog-4.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:611be3904f9843f0529c35a3ff3fd617449463cb4b73b1633950b3d97fa4bfb7"}, + {file = "watchdog-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:62c613ad689ddcb11707f030e722fa929f322ef7e4f18f5335d2b73c61a85c28"}, + {file = "watchdog-4.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d4925e4bf7b9bddd1c3de13c9b8a2cdb89a468f640e66fbfabaf735bd85b3e35"}, + {file = "watchdog-4.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cad0bbd66cd59fc474b4a4376bc5ac3fc698723510cbb64091c2a793b18654db"}, + {file = "watchdog-4.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a3c2c317a8fb53e5b3d25790553796105501a235343f5d2bf23bb8649c2c8709"}, + {file = "watchdog-4.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9904904b6564d4ee8a1ed820db76185a3c96e05560c776c79a6ce5ab71888ba"}, + {file = "watchdog-4.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:667f3c579e813fcbad1b784db7a1aaa96524bed53437e119f6a2f5de4db04235"}, + {file = "watchdog-4.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d10a681c9a1d5a77e75c48a3b8e1a9f2ae2928eda463e8d33660437705659682"}, + {file = "watchdog-4.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0144c0ea9997b92615af1d94afc0c217e07ce2c14912c7b1a5731776329fcfc7"}, + {file = "watchdog-4.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:998d2be6976a0ee3a81fb8e2777900c28641fb5bfbd0c84717d89bca0addcdc5"}, + {file = "watchdog-4.0.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e7921319fe4430b11278d924ef66d4daa469fafb1da679a2e48c935fa27af193"}, + {file = "watchdog-4.0.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:f0de0f284248ab40188f23380b03b59126d1479cd59940f2a34f8852db710625"}, + {file = "watchdog-4.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bca36be5707e81b9e6ce3208d92d95540d4ca244c006b61511753583c81c70dd"}, + {file = "watchdog-4.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab998f567ebdf6b1da7dc1e5accfaa7c6992244629c0fdaef062f43249bd8dee"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:dddba7ca1c807045323b6af4ff80f5ddc4d654c8bce8317dde1bd96b128ed253"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_armv7l.whl", hash = "sha256:4513ec234c68b14d4161440e07f995f231be21a09329051e67a2118a7a612d2d"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_i686.whl", hash = "sha256:4107ac5ab936a63952dea2a46a734a23230aa2f6f9db1291bf171dac3ebd53c6"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_ppc64.whl", hash = "sha256:6e8c70d2cd745daec2a08734d9f63092b793ad97612470a0ee4cbb8f5f705c57"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f27279d060e2ab24c0aa98363ff906d2386aa6c4dc2f1a374655d4e02a6c5e5e"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:f8affdf3c0f0466e69f5b3917cdd042f89c8c63aebdb9f7c078996f607cdb0f5"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ac7041b385f04c047fcc2951dc001671dee1b7e0615cde772e84b01fbf68ee84"}, + {file = "watchdog-4.0.1-py3-none-win32.whl", hash = "sha256:206afc3d964f9a233e6ad34618ec60b9837d0582b500b63687e34011e15bb429"}, + {file = "watchdog-4.0.1-py3-none-win_amd64.whl", hash = "sha256:7577b3c43e5909623149f76b099ac49a1a01ca4e167d1785c76eb52fa585745a"}, + {file = "watchdog-4.0.1-py3-none-win_ia64.whl", hash = "sha256:d7b9f5f3299e8dd230880b6c55504a1f69cf1e4316275d1b215ebdd8187ec88d"}, + {file = "watchdog-4.0.1.tar.gz", hash = "sha256:eebaacf674fa25511e8867028d281e602ee6500045b57f43b08778082f7f8b44"}, ] [package.extras] watchmedo = ["PyYAML (>=3.10)"] +[[package]] +name = "wcmatch" +version = "8.5.2" +description = "Wildcard/glob file name matcher." +optional = false +python-versions = ">=3.8" +files = [ + {file = "wcmatch-8.5.2-py3-none-any.whl", hash = "sha256:17d3ad3758f9d0b5b4dedc770b65420d4dac62e680229c287bf24c9db856a478"}, + {file = "wcmatch-8.5.2.tar.gz", hash = "sha256:a70222b86dea82fb382dd87b73278c10756c138bd6f8f714e2183128887b9eb2"}, +] + +[package.dependencies] +bracex = ">=2.1.1" + [[package]] name = "wheel" version = "0.43.0" @@ -1759,20 +1839,20 @@ h11 = ">=0.9.0,<1" [[package]] name = "zipp" -version = "3.18.1" +version = "3.19.1" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"}, - {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"}, + {file = "zipp-3.19.1-py3-none-any.whl", hash = "sha256:2828e64edb5386ea6a52e7ba7cdb17bb30a73a858f5eb6eb93d8d36f5ea26091"}, + {file = "zipp-3.19.1.tar.gz", hash = "sha256:35427f6d5594f4acf82d25541438348c26736fa9b3afa2754bcd63cdb99d8e8f"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "d642bbe5e32ede7cae8856ef232cf5db474062d1e6509372b6316763b0a316f4" +content-hash = "dbebecacb78c7e3285a02b72e11e23082eaf141e6880cf8920797243710f7d04" diff --git a/pyproject.toml b/pyproject.toml index 89764871..aa737e98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,8 @@ mkdocs-material = "^9.1.6" mkdocstrings = {extras = ["python"], version = "^0.21.2"} mkdocs-redirects = "^1.2.0" mkdocs-git-revision-date-localized-plugin = "^1.2.0" +mkdocs-include-markdown-plugin = "^6.1.1" +mkdocs-macros-plugin = "^1.0.5" [build-system] requires = ["poetry-core>=1.0.0"] @@ -200,14 +202,15 @@ skip-string-normalization = 1 exception-escape, comprehension-escape unused-private-member, - typevar-name-incorrect-variance'''] + typevar-name-incorrect-variance, + anomalous-backslash-in-string'''] [tool.pylint.'REPORTS'] evaluation=['10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)'] output-format=['colorized'] reports=['yes'] score=['yes'] - [tool.pylintt.'REFACTORING'] + [tool.pylint.'REFACTORING'] max-nested-blocks=5 never-returning-functions=['sys.exit'] @@ -215,14 +218,14 @@ skip-string-normalization = 1 logging-format-style=['old'] logging-modules=['logging'] - [tool.pylint.'SPELLING'] + [tool.pylint.'SPELLING'] max-spelling-suggestions=4 spelling-store-unknown-words=['no'] - [tool.pylint.'MISCELLANEOUS'] + [tool.pylint.'MISCELLANEOUS'] notes=['FIXME,XXX'] - [tool.pylint.'TYPECHECK'] + [tool.pylint.'TYPECHECK'] contextmanager-decorators=['contextlib.contextmanager'] ignore-mixin-members=['yes'] ignore-none=['yes'] @@ -232,7 +235,7 @@ skip-string-normalization = 1 missing-member-hint-distance=1 missing-member-max-choices=1 - [tool.pylint.'VARIABLES'] + [tool.pylint.'VARIABLES'] allow-global-unused-variables=['yes'] callbacks=['cb_,_cb'] dummy-variables-rgx=['_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_'] @@ -240,7 +243,7 @@ skip-string-normalization = 1 init-import=['no'] redefining-builtins-modules=['six.moves,past.builtins,future.builtins,builtins,io'] - [tool.pylint.'FORMAT'] + [tool.pylint.'FORMAT'] ignore-long-lines=['^\s*(# )??$'] indent-after-paren=4 indent-string=' ' @@ -249,13 +252,13 @@ skip-string-normalization = 1 single-line-class-stmt=['no'] single-line-if-stmt=['no'] - [tool.pylint.'SIMILARITIES'] + [tool.pylint.'SIMILARITIES'] ignore-comments=['yes'] ignore-docstrings=['yes'] ignore-imports=['no'] min-similarity-lines=4 - [tool.pylint.'BASIC'] + [tool.pylint.'BASIC'] argument-naming-style=['snake_case'] attr-naming-style=['snake_case'] bad-names=['foo,bar,baz,toto,tutu,tata'] @@ -273,22 +276,22 @@ skip-string-normalization = 1 property-classes=['abc.abstractproperty'] variable-naming-style=['snake_case'] - [tool.pylint.'STRING'] + [tool.pylint.'STRING'] check-quote-consistency=['no'] check-str-concat-over-line-jumps=['no'] - [tool.pylint.'IMPORTS'] + [tool.pylint.'IMPORTS'] allow-wildcard-with-all=['no'] analyse-fallback-blocks=['no'] deprecated-modules=['optparse,tkinter.tix'] known-third-party=['enchant'] - [tool.pylint.'CLASSES'] + [tool.pylint.'CLASSES'] defining-attr-methods=['__init__,__new__,setUp,__post_init__'] valid-classmethod-first-arg=['cls'] valid-metaclass-classmethod-first-arg=['cls'] - [tool.pylint.'DESIGN'] + [tool.pylint.'DESIGN'] max-args=5 max-attributes=7 max-bool-expr=5 @@ -300,5 +303,5 @@ skip-string-normalization = 1 max-statements=80 min-public-methods=2 - [tool.pylint.'EXCEPTIONS'] + [tool.pylint.'EXCEPTIONS'] overgeneral-exceptions=['BaseException,Exception'] diff --git a/selene/common/_typing_functions.py b/selene/common/_typing_functions.py index be7e8df5..39f40216 100644 --- a/selene/common/_typing_functions.py +++ b/selene/common/_typing_functions.py @@ -21,8 +21,13 @@ # SOFTWARE. from __future__ import annotations -from typing_extensions import TypeVar, Callable, Generic, Optional +import functools +import inspect +import re +from typing_extensions import TypeVar, Callable, Generic, Optional, overload + +from selene.common.fp import thread_last T = TypeVar('T') E = TypeVar('E') @@ -54,16 +59,91 @@ def full_name_for(callable_: Optional[Callable]) -> str | None: if isinstance(callable_, Query): return str(callable_) + # callable_ has its own __str__ implementation in its class + if type(callable_).__str__ != object.__str__: + return str(callable_) + + # callable has its own __str__ implementation on the object itself + # TODO: do we even need to bother? And how should it property be done? + # TODO: should it override previous? + maybe_obj__str__ = getattr(callable_, '__str__', None) + if maybe_obj__str__ and 'at 0x' not in (obj_as_str := maybe_obj__str__()): + return obj_as_str + + callable_ = ( + callable_ + if inspect.isfunction(callable_) + else (getattr(callable_, '__class__', None) or callable_) + ) + qualname = getattr(callable_, '__qualname__', None) if qualname is not None and not qualname.endswith(''): return ( qualname if '.' not in qualname - else getattr(callable_, '__name__', None) + else qualname.split('.')[1] ) return None + @staticmethod + def full_description_for(callable_: Optional[Callable]) -> str | None: + full_name = Query.full_name_for(callable_) + return ( + thread_last( + full_name, + (re.sub, r'([a-z0-9])([A-Z])', r'\1 \2'), + (re.sub, r'(\w)\.(\w)', r'\1 \2'), + (re.sub, r'(^_+|_+$)', ''), + (re.sub, r'_+', ' '), + (re.sub, r'(\s)+', r'\1'), + str.lower, + ) + if full_name + else None + ) + + @staticmethod + @overload + def _inverted(predicate: Predicate[E]) -> Predicate[E]: ... + + @staticmethod + @overload + def _inverted(predicate: Query[E, bool]) -> Query[E, bool]: ... + + @staticmethod + def _inverted( + predicate: Predicate[E] | Query[E, bool] + ) -> Predicate[E] | Query[E, bool]: + # TODO: ensure it works correctly:) e.g. unit test it + if isinstance(predicate, Query): + return Query( + f'not {predicate}', + lambda entity: not predicate(entity), + ) + + def not_predicate(entity: E) -> bool: + return not predicate(entity) + + not_predicate.__module__ = predicate.__module__ + not_predicate.__annotations__ = predicate.__annotations__ + + predicate_name = getattr(predicate, '__name__', None) + predicate_qualname = getattr(predicate, '__qualname__', None) + if not predicate_name or not predicate_qualname: + return not_predicate + + if '' in predicate_name: + not_predicate.__name__ = predicate_name + not_predicate.__qualname__ = predicate_qualname + else: + not_predicate.__name__ = f'not_{predicate_name}' + not_predicate.__qualname__ = f'not_{predicate_name}'.join( + predicate_qualname.split(predicate_name) + ) + + return not_predicate + # TODO: should we define __name__ and __qualname__ on it? diff --git a/selene/common/fp.py b/selene/common/fp.py index 11c703ce..83da8392 100644 --- a/selene/common/fp.py +++ b/selene/common/fp.py @@ -20,6 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import functools +import inspect from typing import TypeVar, Callable, Any, Tuple, Optional # T = TypeVar('T', bound=Callable[..., Any]) @@ -83,6 +84,38 @@ def thread(arg, *functions): return pipe(*functions)(arg) +def thread_first(arg, *iterable_of_fn_or_tuple): + return ( + functools.reduce( + lambda acc, fn_or_tuple: ( + fn_or_tuple(acc) + if callable(fn_or_tuple) + else fn_or_tuple[0](acc, *fn_or_tuple[1:]) + ), + iterable_of_fn_or_tuple, + arg, + ) + if iterable_of_fn_or_tuple + else arg + ) + + +def thread_last(arg, *iterable_of_fn_or_tuple): + return ( + functools.reduce( + lambda acc, fn_or_tuple: ( + fn_or_tuple(acc) + if callable(fn_or_tuple) + else fn_or_tuple[0](*fn_or_tuple[1:], acc) + ), + iterable_of_fn_or_tuple, + arg, + ) + if iterable_of_fn_or_tuple + else arg + ) + + def do(function: Callable[[T], Any]) -> Callable[[T], T]: def func(arg: T) -> T: function(arg) @@ -100,3 +133,8 @@ def write_silently( return file, string except OSError: return None + + +def map_with(fn: Callable): + """Curried version of map function.""" + return lambda *iterables: map(fn, *iterables) diff --git a/selene/core/condition.py b/selene/core/condition.py index f949a561..74085e9a 100644 --- a/selene/core/condition.py +++ b/selene/core/condition.py @@ -19,7 +19,416 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +""" +## Overview +Conditions, or "expected conditions" (as they +[are called in Selenium WebDriver](https://www.selenium.dev/documentation/webdriver/support_features/expected_conditions/)), +or matchers (as they are called in [PyHamcrest](https://github.com/hamcrest/PyHamcrest)) +– are callable objects or functions that once called on some entity – +test if the entity matches the corresponding criteria of a condition, +and then, if matched, simply pass, or raise an error otherwise. +They are used in testing to flexibly implement test assertions +and explicit waits that are also relevant in context of +"asserting dynamic behavior of some entities", like elements +on the dynamically loaded page. + +!!! note + + In Selenium WebDriver the valid condition is also a simple predicate function, + i.e. the function returning True or False, and it's not mandatory + for the condition in Selenium to raise an error to signal "not matched" state + – returning False is enough. In Selene, it's not the case – + the condition should raise an Error to signal "not matched" state. + In Selene such design decision is a base for powerful logging abilities + of conditions in context of their failures. + Since a condition owns a knowledge of its criteria to be matched, + it seems to be the best place + (in context of following the "high cohesion" principle) + to define how this criteria will be logged if not matched – + what naturally happens on raising a "not matched" error. + + From other point of view – the True|False-predicate-based conditions + are easier to define. To keep similar level of easiness, Selene provides + additional helpers (like + [`ConditionMismatch._to_raise_on_not(predicate)`][selene.core.exceptions.ConditionMismatch._to_raise_on_not]) + and classes ([`Condition`][selene.core.condition.Condition], + [`Match`][selene.core.condition.Match]) + to build conditions based on predicates. More on their usage below. + +## Predefined conditions + +### match.\* VS be.\* & have.\* + +Usually you don't need to build conditions yourself, +they are predefined for easier reuse. +In Selene, they are predefined in [`match.*`](selene.core.match) +and can be accessed additionally via [`be.*`](selene.support.conditions.be) +and [`have.*`](selene.support.conditions.have) syntax. + +`match` is handy because it's a "one term to learn and one import to use": + +```python +from selene import browser, match + +browser.should(match.title('Selene')) +browser.element('#save').should(match.enabled) +browser.element('#loading').should(match.visible.not_) +browser.element('#field').should(match.exact_text('hello')) +browser.element('#field').should(match.css_class('required').not_) +``` + +`be` and `have` force to use "more imports" but are more "high level" +and might help to write more readable code: + +```python +from selene import browser, be, have + +browser.should(have.title('Selene')) +browser.element('#save').should(be.enabled) +browser.element('#loading').should(be.not_.visible) +browser.element('#field').should(have.exact_text('hello')) +browser.element('#field').should(have.no.css_class('required')) +``` + +### Extending predefined conditions (Demo) + +Because `match` is "just one term", it might be easier also +to "extend Selene's predefined conditions" with your custom ones, +because you have to deal only with "one module re-definition", like in: + +```python +# Full path can be: my_tests_project/extensions/selene/match.py +from selene.core.match import * +from selene.core import match as __selene_match +from selene.support.conditions import not_ as __selene_not_ + +not_ = __selene_not_ + + +# An example of a new condition definition +def no_visible_alert(browser): + try: + text = browser.driver.switch_to.alert.text + raise AssertionError('visible alert found with text: ' + text) + except NoAlertPresentException: + pass + + +# Maybe you don't like that in Selene the match.text condition +# tests for entity text by contains and decide to override it: + +def text(expected): + return __selene_match.exact_text(expected) + + +def partial_text(expected): + return __selene_match.text(expected) +``` + +So we can use it like: + +```python +from selene import browser +from my_tests_project.extensions.selene import match + +browser.should(match.no_visible_alert) +browser.should(match.title('Selene')) +browser.element('#save').should(match.enabled) +browser.element('#loading').should(match.not_.visible) +browser.element('#field').should(match.text('hello')) +browser.element('#field').should(match.partial_text('hel')) +browser.element('#field').should(match.not_.css_class('required')) +``` + +From other side, you do it once, so maybe it's not like that hard +to redefine "more readable" `be` and `have` +to extend predefined Selene conditions and forget about it:). +The choice is yours... +Maybe even all your extended conditions will be "have" ones: + +```python +from selene import browser, be +from my_tests_project.extensions.selene import have + +browser.should(have.no_visible_alert) +browser.should(have.title('Selene')) +browser.element('#save').should(be.enabled) +browser.element('#loading').should(be.not_.visible) +browser.element('#field').should(have.text('hello')) +browser.element('#field').should(have.partial_text('hel')) +browser.element('#field').should(have.no.css_class('required')) +``` + +!!! info + + If you need a more detailed explanation of + how we "extended" Selene's predefined conditions in the example above, + look at the + [How to implement custom advanced commands?][selene.core.command--how-to-implement-custom-advanced-commands] + article, that explains same pattern for the case of + extending Selene predefined commands. + +## Functional conditions definition + +Ok, now, let's go deeper into how to define custom conditions +starting from function-based conditions. + +!!! tip + + Function-based conditions are the simplest ones in Selene, + and they are limited in reusability during definition of new conditions + based on existing ones. + [Object-oriented conditions][selene.core.condition--object-oriented-re-composable-conditions-demo] + are more powerful. But understanding how to define functional conditions + is crucial to understand how to define object-oriented ones, + as the latter are built on top of the former. + Thus, we recommend not to skip this section if you are new to the topic. + +!!! tip + + If you are an experienced SDET engineer, and are familiar with the concept + of expected conditions and how to define them e.g. in Selenium WebDriver, + and want a fast way to get familiar with how to define most powerful + custom conditions in Selene, jump directly to the examples + of [`Match`](selene.core.condition.Match) usage. + In case of any doubts on the topic, + read on this article without skipping any section. + +### Pass|Fail-function-based definition + +The simplest way to implement a condition is to define a function +that raises AssertionError if the argument passed to the function +does not match some criteria: + +```python +def is_positive(x): + if not x > 0: + raise AssertionError(f'Expected positive number, but got {x}') +``` + +### True|False-predicate-based definition + +Or in one-liner if we use +[`ConditionMismatch`][selene.core.exceptions.ConditionMismatch] factory method +to build a condition from predicate: + +```python +is_positive = ConditionMismatch._to_raise_if_not(lambda x: x > 0) +``` + +### Condition application + +Then we test a condition simply by calling it: + +```python +is_positive(1) # passes +try: + is_positive(0) # fails +except AssertionError as error: + assert 'Expected positive number, but got 0' in str(error) +``` + +But really useful conditions become when used in waits: + +```python +def has_positive_number(entity): + number = entity.number + if not number > 0: + raise AssertionError(f'Expected positive number, but got {number}') + +class Storage: + number = 0 + +# imagine that here we created an async operation p +# that after some timeout will update number in storage - to > 0 + +from selene.core.wait import Wait +Wait(Storage, at_most=1.0).for_(has_positive_number) +``` + +!!! note + + In Selene the Wait object is usually built under the hood, + and we would see something like: + + ```python + Storage.should(has_positive_number) + ``` + + where: + ```python + class Storage: + number = 0 + + @classmethod + def should(cls, condition): + Wait(cls, at_most=1.0).for_(condition) + ``` + + Now recall the actual Selene assertion on browser object: + + ```python + browser.element('#save').should(be.enabled) + ``` + + 😉 + +### Rendering in error messages + +If wait did not succeed, it will raise an error with a message like: + +```text +Timed out after 1.0s, while waiting for: +.has_positive_number +Reason: AssertionError: Expected positive number, but got 0 +``` + +– as you see the message is quite informative and helps to understand +what happened. Pay attention that the name `has_positive_number` +of our condition-function was used in error message to explain +what we were waiting for. + +#### Improving error messages of lambda-based conditions + +But what if we used lambda predicate to define the condition: + +```python +from selene.core.exceptions import ConditionMismatch + +has_positive_number = ConditionMismatch._to_raise_if_not( + lambda entity: entity.number > 0 +) +``` + +Then error would be less informative, because lambda is anonymous – +there is no way to build a description for it: + +```text +Timed out after 1.0s, while waiting for: +. at 0x106b5d8b0> +Reason: ConditionMismatch: actual: 0 +``` + +To fix this, we can provide a description for the lambda +by wrapping it into Query: + +```python +from selene.core.exceptions import ConditionMismatch +from selene.common._typing_functions import Query + +has_positive_number = ConditionMismatch._to_raise_if_not( + Query('has positive number', lambda entity: entity.number > 0) +) +``` + +Now error would look quite informative again: + +```text +Timed out after 1.0s, while waiting for: +.has positive number +Reason: ConditionMismatch: actual: 0 +``` + +### Choosing the style to define functional conditions + +Feel free to choose the way that fits your needs best among: + +- Pass|Fail-function-condition +- True|False-lambda-predicate-condition +built with wrapped lambda into `Query` and `ConditionMismatch._to_raise_if_not`. + +#### Basic refactoring of conditions + +Utilizing `ConditionMismatch` gives also an option +to "break down the predicate logic" into two steps: + +- querying the entity for needed value +- applying predicate to the value + +This is performed by adding additional query function, +to get something useful from the entity before applying the predicate: + +```python +has_positive_number = ConditionMismatch._to_raise_if_not( + lambda number: number > 0, + lambda entity: entity.number, +) +``` + +And now we know how to benefit from more descriptive error messages +by providing descriptions for our lambdas as follows: + +```python +has_positive_number = ConditionMismatch._to_raise_if_not( + Query('is positive', lambda number: number > 0), + Query('number', lambda entity: entity.number), +) +``` + +In case we already have somewhere defined queries: + +```python +is_positive = Query('is positive', lambda number: number > 0) +number = Query('number', lambda entity: entity.number) +``` + +The condition definition becomes even more concise and readable: + +```python +has_positive_number = ConditionMismatch._to_raise_if_not(is_positive, number) +``` + +#### Parametrized conditions + +Another example of common usage is the definition of a parametrized condition: + +```python +has_number_more_than = lambda limit: ConditionMismatch._to_raise_if_not( + is_more_than(limit), + number, +) +``` + +– where: + +```python +is_more_than = lambda limit: Query('is more than', lambda number: number > limit) +number = Query('number', lambda entity: entity.number) +``` + +Or with regular functions: + +```python +def has_number_more_than(limit): + return ConditionMismatch._to_raise_if_not(is_more_than(limit), number) +``` + +– where: + +```python +number = Query('number', lambda entity: entity.number) +def is_more_than(limit): + return Query('is more than', lambda number: number > limit) +``` + +## Object-Oriented re-composable conditions Demo + +This is enough for simpler cases, +but what if we want to be able to compose new conditions based on existing ones, +like in: + +``` +# wait for not negative even number +Wait(Storage, at_most=1.0).for_(has_positive_number.and_(has_even_number).not_) +``` + +Here comes in rescue the [`Condition`][selene.core.condition.Condition] class +and its [`Match`][selene.core.condition.Match] subclass, +allowing to build such "re-composable" conditions. + +## ⬇️ Classes to build and recompose conditions +""" from __future__ import annotations import sys @@ -36,10 +445,12 @@ Self, override, cast, + overload, + Union, ) from selene.core.exceptions import ConditionMismatch -from selene.common._typing_functions import Lambda, Predicate +from selene.common._typing_functions import Lambda, Predicate, E, R, Query # TODO: shouldn't we just import if from typing_extensions? if sys.version_info >= (3, 10): @@ -48,99 +459,234 @@ from typing import Callable -E = TypeVar('E') -R = TypeVar('R') - - +# TODO: Consider renaming to Match, while keeping Condition name as functional interface class Condition(Generic[E]): """Class to build, invert and compose "callable matcher" objects, - that match to `Callable[[E], None | ]` interface + that conforms to `Callable[[E], None | ]` protocol, and represent the "conditions that can pass or fail when tested against an entity". So, once called on some entity of type E such condition object should test if the entity matches the condition object, and then simply pass if matched or raise AssertionError otherwise. - Examples of constructing a new condition: + ### When to use Condition-object-based definition + + For most cases you don't need this class directly, + you can simply reuse the conditions predefined in [`match.*`](selene.core.match). + + You will start to need this class when you want to build your own custom conditions + to use in a specific to your case assertions. + And even then, it might be enough to use a simpler version of this class – + its [Match][selene.core.condition.Match] subclass that has smaller bunch of params + to set on constructing new condition, that is especially handy + when you build conditions inline without the need to store and reuse them later, + like in: ```python - # These are dead simple "kind of standalone" callable conditions... - - # A function-based named condition - def is_positive(x): - if not x > 0: - raise AssertionError(f'Expected positive number, but got {x}') - # A predicate-function-based named condition - def _is_positive(x): - return x > 0 - is_positive = ConditionMismatch.to_raise_if_not(_is_positive) - # Fully anonymous condition (i.e. no name will be rendered on failure) - is_positive = ConditionMismatch.to_raise_if_not(lambda x: x > 0) - - # Now the Condition class allows to build conditions - # that can be inverted via `.not_` and composed with each other via `and_` and `or_` - is_positive = Condition('is positive', ConditionMismatch.to_raise_if_not(lambda x: x > 0)) - is_positive = Condition(ConditionMismatch.to_raise_if_not(Query('is positive', lambda x: x > 0))) - is_not_positive = Condition('is positive', ConditionMismatch.to_raise_if_not(lambda x: x > 0), _inverted=True) - is_not_positive = Condition('is positive', ConditionMismatch.to_raise_if_not(lambda x: x > 0)).not_ - - # 💬 this does not make sense because we can't get _inverted from result of to_raise_if_not - has_positive_decrement_by = lambda amount: Condition( - ConditionMismatch.to_raise_if_not( - Query('has positive', lambda res: res > 0), - Query(f'decrement by {amount}', lambda x: x - amount), - _inverted=False # == DEFAULT - ), + from selene import browser + from selene.core.condition import Match + input = browser.element('#field') + input.should(Match( + 'normalized value', + by=lambda it: not re.find_all(r'(\s)\1+', it.locate().get_attribute(value)), + )) + ``` + + For other examples of using `Match`, up to something as concise + as `input.should(Match(query.value, is_normalized))`, + see [its section][selene.core.condition.Match]. + + And for simplest scenarios you may keep it most KISS + with "Pass|Fail-function-based conditions" or + "True|False-predicate-based conditions" as described in + [Functional conditions definition][selene.core.condition--functional-conditions-definition]. + + But when you start bothering about reusing existing conditions + to build new ones on top of them by applying logical `and`, `or`, + or `not` operators you start to face some limitations... + + Compare: + + ```python + has_positive_number = ConditionMismatch._to_raise_if_not_actual( + Query('number', lambda entity: entity.number), + Query('is positive', lambda number: number > 0), + ) + + has_negative_number_or_zero = ConditionMismatch._to_raise_if_not_actual( + Query('number', lambda entity: entity.number), + Query('is negative or zero', lambda number: number <= 0), ) - # 💬 this does not make sense cause we can't get descriptions - has_positive_decrement_by = lambda amount: Condition( - ConditionMismatch.to_raise_if_not( - Query('has positive', lambda res: res > 0), - Query(f'decrement by {amount}', lambda x: x - amount), - ), - _inverted=False # == DEFAULT + ``` + + to: + + ```python + has_positive_number = Condition( + 'has positive number' + ConditionMismatch._to_raise_if_not_actual( + lambda entity: entity.number, + lambda number: number > 0, + ) ) - # 💬 Here we have both description and _inverted for further inversion and customized rendering - # but we can can't customly render the actual value in case of inverted condition afterwards - has_positive_decrement_by = lambda amount: Condition( - f'has positive decrement by {amount}', - test=ConditionMismatch.to_raise_if_not_actual(lambda x: x - amount, lambda res: res > 0), - _inverted=False # == DEFAULT + + has_negative_number_or_zero = has_positive_number.not_ + ``` + + !!! note + + If you see + [ConditionMismatch._to_raise_if_not_actual](selene.core.exceptions.ConditionMismatch._to_raise_if_not_actual) + for the first time, + it's similar to + [ConditionMismatch._to_raise_if_not](selene.core.exceptions.ConditionMismatch._to_raise_if_not), + but with inverted order of params: + `ConditionMismatch._to_raise_if_not_actual(number, is_positive)` + == + `ConditionMismatch._to_raise_if_not(is_positive, number)` + + Notice the following ⬇️ + + ### Specifics of the Condition-object-based definition + + - It is simply a wrapping functional condition (PASS|FAIL-function-based) + into a Condition object with custom description. + Thus, we can use all variations of defining functional conditions + to define object-oriented ones. + - Because we provided a custom description + (`'has positive number'` in the case above), it's not mandatory + to wrap lambdas into Query objects to achieve readable error messages, + unlike we had to do for functional conditions. + + ### Customizing description of inverted conditions + + The description of the `has_negative_number_or_zero` will be automatically + constructed as `'has not (positive number)'`. In case you want custom: + + ```python + has_positive_number = Condition( + 'has positive number' + actual=lambda entity: entity.number, + by=lambda number: number > 0, ) - # 💬 but we could do this if to_raise_if_not_actual can accept _inverted too... - # but then we have to accept actual and predicate fns separately... - # like here: - has_positive_decrement_by = lambda amount: Condition( - f'has positive decrement by {amount}', - actual=lambda x: x - amount, - test_by=lambda res: res > 0, - _inverted=False # == DEFAULT + + has_negative_number_or_zero = Condition.as_not( # ⬅️ + 'has negative number or zero', # 💡 + has_positive_number ) - # or: - has_positive_decrement_by = lambda amount: Condition( - actual=Query('has positive', lambda res: res > 0), - test_by=Query(f'decrement by {amount}', lambda x: x - amount), - _inverted=False # == DEFAULT + ``` + + ### Re-composing methods summary + + Thus: + + - to invert condition you use `condition.not_` property + - to compose conditions by logical `and` you use `condition.and_(another_condition)` + - to compose conditions by logical `or` you use `condition.or_(another_condition)` + + ### Alternative signatures for Condition class initializer + + Condition class initializer has more than two params (description and functional condition) + and different variations of valid signatures to use... + + Recall the initial example: + + ```python + has_positive_number = Condition( + 'has positive number' + ConditionMismatch._to_raise_if_not_actual( + lambda entity: entity.number, + lambda number: number > 0, + ) ) - # what about this? - has_positive_decrement_by = lambda amount: Condition( - f'has positive decrement by {amount}', - test=(lambda actual: actual - amount, lambda res: res > 0), - _inverted=False # == DEFAULT + ``` + + #### The core parameter: **test** + + Let's rewrite it utilizing the named arguments python feature: + + ```python + has_positive_number = Condition( + description='has positive number' + test=ConditionMismatch._to_raise_if_not_actual( + lambda entity: entity.number, + lambda number: number > 0, + ) ) ``` + + #### Parameters: actual and by VS test + + Thus, the functional condition that is the core of object-oriented condition + – is passed as the `test` argument. In order to remove a bit of boilerplate + on object-oriented condition definition, there two other + alternative parameters to the Condition class initializer: + `actual` and `by` – similar to the parameters of the + [`Condition._to_raise_if_not_actual`](selene.core.exceptions.ConditionMismatch._to_raise_if_not_actual) + helper that we use to define functional True|False-predicate-based conditions. + + Compare: + + ```python + has_positive_number = Condition( + description='has positive number' + test=ConditionMismatch._to_raise_if_not_actual( + lambda entity: entity.number, + lambda number: number > 0, + ) + ) + ``` + + to: + + ```python + has_positive_number = Condition( + 'has positive number' + actual=lambda entity: entity.number, + by=lambda number: number > 0, + ) + ``` + + `actual` is optional by the way, so the following is also valid: + + ```python + has_positive_number = Condition( + 'has positive number' + by=lambda entity: entity.number > 0, + ) + ``` + + !!! tip + + Remember, that it's not mandatory to wrap lambdas into Query objects + here to achieve readable error messages, + because we already provided a custom description. + + #### Relation to Match subclass + + If you find passing optional `actual` and mandatory `by` better + than passing mandatory `test`, + and the `Condition` term is too low level for your case, consider using the + [`Match`][selene.core.condition.Match] subclass of the `Condition` class + that accepts only `actual` and `by` with optional `description` parameters, + and fits better with a `should` method of Selene entities – compare: + `entity.should(Match(...))` to `entity.should(Condition(...))`😉. """ @classmethod def by_and(cls, *conditions): + # TODO: consider refactoring to be predicate-based or both-based + # and ensure inverted works def func(entity): for condition in conditions: condition.__call__(entity) - return cls(' and '.join(map(str, conditions)), func) + return cls(' and '.join(map(str, conditions)), test=func) @classmethod def by_or(cls, *conditions): - def fn(entity): + # TODO: consider refactoring to be predicate-based or both-based + # and ensure inverted works + def func(entity): errors: List[Exception] = [] for condition in conditions: try: @@ -150,11 +696,13 @@ def fn(entity): errors.append(e) raise AssertionError('; '.join(map(str, errors))) - return cls(' or '.join(map(str, conditions)), fn) + return cls(' or '.join(map(str, conditions)), test=func) @classmethod def for_each(cls, condition) -> Condition[Iterable[E]]: - def fn(entity): + # TODO consider refactoring to be predicate-based or both-based + # and ensure inverted works + def func(entity): items_with_error: List[Tuple[str, str]] = [] index = None for index, item in enumerate(entity): @@ -170,7 +718,7 @@ def fn(entity): ) ) - return typing.cast(Condition[Iterable[E]], cls(f' each {condition}', fn)) + return typing.cast(Condition[Iterable[E]], cls(f' each {condition}', test=func)) @classmethod def as_not( # TODO: ENSURE ALL USAGES ARE NOW CORRECT @@ -179,52 +727,137 @@ def as_not( # TODO: ENSURE ALL USAGES ARE NOW CORRECT # TODO: how will it work composed conditions? if description: - return cls( - # now we provide the new description that counts inversion - description, - # specifying already an inverted fn - condition.__fn_inverted, - # thus, no need to mark condition for further inversion: - _inverted=False, + return ( + cls( + # now we provide the new description that counts inversion + description, + # specifying already an inverted fn + test=condition.__test_inverted, + # thus, no need to mark condition for further inversion: + _inverted=False, + ) + if not condition.__by + else cls( + description, + actual=condition.__actual, + by=Query._inverted(condition.__by), + _inverted=False, + ) ) else: return condition.not_ - # function throwIfNot(predicate: (entity: E) => Promise): Lambda { - # return async (entity: E) => { - # if (!await predicate(entity)) { - # throw new ConditionNotMatchedError(); - # } - # }; - # } - @classmethod def raise_if_not(cls, description: str, predicate: Predicate[E]) -> Condition[E]: - return cls(description, ConditionMismatch._to_raise_if_not(predicate)) + return cls(description, by=predicate) @classmethod def raise_if_not_actual( cls, description: str, query: Lambda[E, R], predicate: Predicate[R] ) -> Condition[E]: - return cls(description, ConditionMismatch._to_raise_if_not(predicate, query)) + return cls(description, actual=query, by=predicate) + + @overload + def __init__( + self, + description: str | Callable[[], str], + test: Lambda[E, None], + *, + _inverted=False, + ): ... + + # @overload + # def __init__( + # self, + # description: str | Callable[[], str], + # *, + # by: Tuple[Lambda[E, R], Predicate[R]], + # _inverted=False, + # ): ... + + @overload + def __init__( + self, + description: str | Callable[[], str], + *, + actual: Lambda[E, R] | None = None, + by: Predicate[R], + _inverted=False, + ): ... # TODO: should we make the description type as Callable[[Condition], str] - # instead of Callable[[], str]... - # to be able to pass condition itself... - # when we pass in child classes we pass self.__str__ - # that doesn't need to receive self, it already has it - # but what if we want to pass some crazy lambda for description from outside - # to kind of providing a "description self-based strategy" for condition? - # maybe at least we can define it as varagrs? like Callable[..., str] + # instead of Callable[[], str]... + # to be able to pass condition itself... + # when we pass in child classes we pass self.__str__ + # that doesn't need to receive self, it already has it + # but what if we want to pass some crazy lambda for description from outside + # to kind of providing a "description self-based strategy" for condition? + # maybe at least we can define it as varagrs? like Callable[..., str] + # TODO: consider accepting actual and by as Tuples + # where first is name for query and second is query fn def __init__( self, description: str | Callable[[], str], - fn: Lambda[E, None], + test: Lambda[E, None] | None = None, + *, + actual: Lambda[E, R] | None = None, + by: ( + Predicate[R] | None + ) = None, # TODO: consider renaming to by (same for ConditionMismatch) _inverted=False, ): + # can be already stored self.__description = description - self.__fn = fn self.__inverted = _inverted + self.__by: Predicate[R] | None = None + + if by: # i.e. condition is based on predicate (fn returning True/False) + if test: + raise ValueError( + 'either test or by with optional actual should be provided, ' + 'not both' + ) + self.__actual = actual + self.__by = by + self.__test = ( + ConditionMismatch._to_raise_if_not(self.__by, self.__actual) + if self.__actual + else ConditionMismatch._to_raise_if_not(self.__by) + ) + self.__test_inverted = ( + ConditionMismatch._to_raise_if_actual( + self.__actual, + self.__by, + ) + if self.__actual + else ConditionMismatch._to_raise_if(self.__by) + ) + return + + if test: # i.e. condition based on fn passing with None or raising Error + if actual: + raise ValueError( + 'either test or by with optional actual should be provided, ' + 'not both' + ) + + self.__test = test + + def as_inverted(entity: E) -> None: + try: + test( + entity + ) # called via test, not self.__test to make mypy happy :) + except Exception: # TODO: should we check only AssertionError here? + return + raise ConditionMismatch() + + self.__test_inverted = as_inverted + return + + raise ValueError( + 'either test or by with optional actual should be provided, ' 'not nothing' + ) # TODO: rethink not_ naming... # if condition is builder-like, for example: @@ -237,47 +870,39 @@ def __init__( # – is it enough? @property def not_(self) -> Condition[E]: - return self.__class__( - self.__description, - self.__fn, - _inverted=not self.__inverted, + return ( + self.__class__( + self.__description, + test=self.__test, + _inverted=not self.__inverted, + ) + if not self.__by + else self.__class__( + self.__description, + actual=self.__actual, + by=self.__by, + _inverted=not self.__inverted, + ) ) - def __describe_inverted(self) -> str: - condition_words = str( + def __describe(self) -> str: + return ( self.__description if not callable(self.__description) else self.__description() - ).split(' ') + ) + + def __describe_inverted(self) -> str: + condition_words = self.__describe().split(' ') is_or_have = condition_words[0] name = ' '.join(condition_words[1:]) no_or_not = 'not' if is_or_have == 'is' else 'no' return f'{is_or_have} {no_or_not} ({name})' - @property - def __fn_inverted(self) -> Lambda[E, None]: - - def inverted_fn(entity: E) -> None: - try: - self.__fn(entity) - except Exception: # TODO: should we check only AssertionError here? - return - raise ConditionMismatch() - - return inverted_fn - # TODO: consider changing has to have on the fly for CollectionConditions # TODO: or changing in collection locator rendering `all` to `collection` def __str__(self): - return ( - ( - self.__description() - if callable(self.__description) - else self.__description - ) - if not self.__inverted - else self.__describe_inverted() - ) + return self.__describe() if not self.__inverted else self.__describe_inverted() # TODO: we already have entity.matching for Callable[[E], bool] # is it a good idea to use same term for Callable[[E], None] raising error? @@ -291,14 +916,16 @@ def __str__(self): # kind of test term relates to testing in context of assertions... # though naturally it does not feel like "assertion"... # more like "predicate" returning bool (True/False), not raising exception - def _match(self, entity: E) -> None: - return self.__fn(entity) if not self.__inverted else self.__fn_inverted(entity) + def _test(self, entity: E) -> None: + return ( + self.__test(entity) if not self.__inverted else self.__test_inverted(entity) + ) def _matching(self, entity: E) -> bool: return self.predicate(entity) def __call__(self, entity: E) -> None: - return self._match(entity) + return self._test(entity) def call(self, entity: E) -> None: warnings.warn( @@ -309,12 +936,14 @@ def call(self, entity: E) -> None: self.__call__(entity) @property - def predicate(self) -> Lambda[E, bool]: # TODO: should we count inverted here too? + def predicate(self) -> Lambda[E, bool]: + # counts inversion... def fn(entity): try: - self.__call__(entity) + self.__call__(entity) # <- HERE return True - except Exception: # TODO: should we check only for AssertionError here? + # TODO: should we check only for AssertionError here? or broader? + except AssertionError: return False return fn @@ -332,6 +961,247 @@ def each(self) -> Condition[Iterable[E]]: return Condition.for_each(self) +# TODO: should Match be not just alias but a subclass overriding __init__ +# to accept only description (maybe optional) + predicates with optional actual +# i.e. not accepting test param at all... +# as, finally, the test param is more unhandy in straightforward inline usage +# TODO: just a crazy idea... why then import both Match and/or match.* +# why not to make match be a class over module – +# a class with static methods and attributes as predefined conditions +# and __init__ overriding Condition to accept just predicate & co? +# TODO: check how autocomplete will work, +# will it autocomplete after ma... – just match or match()? +class Match(Condition): + """A subclass-based alias to [Condition][selene.core.condition.Condition] + class for better readability on straightforward usage of conditions + built inline with optional custom description... + + ### Demo examples + + Example of full inline definition: + + ```python + from selene import browser + from selene.core.condition import Match + + ... + browser.should(Match('title «Expected»', by=lambda its: its.title == 'Expected')) + ``` + + Example of inline definition with reusing existing queries and predicates + and autogenerated description: + + ```python + from selene import browser, query + from selene.core.condition import Match + from selene.common import predicate + + ... + browser.should(Match(query.title, predicate.equals('Expected')) + ``` + + !!! warning + + Remember that in most cases you don't need to build condition + from scratch. You can reuse the one from predefined conditions + at `match.*` or among corresponding aliases at `be.*` and `have.*`. + For example, instead of + `Match(query.title, predicate.equals('Expected')` + you can simply reuse `have.title('Expected')` with import + `from selene import have`. + + Now, let's go in details through different scenarios of constructing + a Match condition-object. + + ### Differences from Condition initializer + + Unlike its base class (Condition), + the `Match` subclass has a bit less of params variations to set + on constructing new condition. + The `Match` initializer: + + - does not accept `test` param, + that is actually the core of its superclass `Condition` logic, and is used to store + the Pass|Fail-function (aka functional condition) to test the entity + against the actual condition criteria, implemented in that function. + - accepts only the alternative to `test` params: + the `by` predicate and the optional `actual` query + to transform an entity before passing to the predicate for match. + - accepts description as the first positional param, but can be skipped + if you are OK with automatically generated description based on + `by` and `actual` arguments names or descriptions. + + ### Better fit for straightforward inline usage + + Such combination of params is especially handy + when you build conditions inline without the need + to store and reuse them later, like in: + + ```python + from selene import browser + from selene.core.condition import Match + input = browser.element('#field') + input.should(Match( + 'normalized value', + by=lambda it: not re.find_all(r'(\s)\1+', it.locate().get_attribute(value)), + )) + ``` + + !!! note + + In the example above, it is especially important + to pass the `'normalized value'` description explicitly, + because we pass the lambda function in place + of the `by` predicate argument, and Selene can't autogenerate + the description for condition based on "anonymous" lambda function. + The description can be autogenerated only from: regular named function, + a callable object with custom `__str__` implementation + (like `Query(description, fn)` object). + + ### Reusing Selene's predefined queries + + To simplify the code a bit, you can reuse the predefined Selene query + inside the predicate definition: + + ```python + from selene import browser, query + from selene.core.condition import Match + input = browser.element('#field') + input.should(Match( + 'normalized value', + by=lambda element: not re.find_all(r'(\s)\1+', query.value(element)), + )) + ``` + + Or reusing query outside of predicate definition: + + ```python + from selene import browser, query + from selene.core.condition import Match + input = browser.element('#field') + input.should(Match( + 'normalized value', + actual=query.value, + by=lambda value: not re.find_all(r'(\s)\1+', value), + )) + ``` + + ### Optionality of description + + Or with default description, autogenerated based on passed query + description: + + ```python + from selene import browser, query + from selene.core.condition import Match + input = browser.element('#field') + input.should(Match( + actual=query.value, + by=Query('is normalized', lambda value: not re.find_all(r'(\s)\1+', value)), + )) + ``` + + ### Reusing custom queries and predicates + + So, if we already have somewhere a helper: + + ```python + is_normalized = Query('is normalized', lambda value: not re.find_all(r'(\s)\1+', value)) + + # or even... + # (in case of regular named function the 'is normalized' description + # will be generated from function name): + + def is_normalized(value): + return not re.find_all(r'(\s)\1+', value) + ``` + + – then we can build a condition on the fly with reusable query-blocks like: + + ```python + from selene import browser, query + from selene.core.condition import Match + input = browser.element('#field') + input.should(Match(actual=query.value, by=is_normalized)) + ``` + + Or without named arguments for even more concise code: + + ```python + from selene import browser, query + from selene.core.condition import Match + + browser.element('#field').should(Match(query.value, is_normalized)) + ``` + + ### Parametrized predicates + + Or maybe you need something "more parametrized": + + ```python + from selene import browser, query + from selene.core.condition import Match + + browser.element('#field').should(Match(query.value, has_no_pattern(r'(\s)\1+'))) + ``` + + – where: + + ```python + import re + + def has_no_pattern(regex): + return lambda actual: not re.find_all(regex, actual) + ``` + + ### When custom description over autogenerated + + But now the autogenerated description + (that you will see in error messages on fail) – may be too low level. + Thus, you might want to provide more high level custom description: + + ```python + from selene import browser, query + from selene.core.condition import Match + + browser.element('#field').should( + Match('normalized value', query.value, has_no_pattern(r'(\s)\1+')) + ) + ``` + + Everything for your freedom of creativity;) + + ### Refactoring conditions for reusability via extending + + At some point of time, you may actually become interested in reusing + such custom conditions. + Then you may refactor the last example to something like: + + ```python + from selene import browser + from my_project_tests.extensions.selene import have + + browser.element('#field').should(have.normalized_value) + ``` + + – where: + + ```python + # my_project_tests/extensions/selene/have.py + + from selene.core.condition import Match + # To reuse all existing have.* conditions, thus, extending them: + from selene.support.conditions.have import * + + normalized_value = Match('normalized value', query.value, has_no_pattern(r'(\s)\1+')) + ``` + + Have fun! ;) + """ + + # TODO: provide examples of error messages + + # TODO: Should we merge this class with Condition? # see `x_test_not_match__of_constructed_via_factory__raise_if_not_actual` test class _ConditionRaisingIfNotActual(Condition[E]): @@ -388,7 +1258,7 @@ def match(entity: E) -> None: super().__init__( description, - match, + test=match, ) @override diff --git a/selene/core/exceptions.py b/selene/core/exceptions.py index 12b15286..9463965a 100644 --- a/selene/core/exceptions.py +++ b/selene/core/exceptions.py @@ -23,16 +23,10 @@ import functools import warnings -from typing import Union, Callable -from typing_extensions import Any, override, overload, TypeVar +from typing_extensions import overload, Union, Callable, Optional -from selene.common._typing_functions import Query - -R = TypeVar('R') -E = TypeVar('E') - -# from selene.core.wait import E, R, Query +from selene.common._typing_functions import Query, E, R class TimeoutException(AssertionError): @@ -48,8 +42,13 @@ def __str__(self): # probably not just from SeleneError, # cause not all SeleneErrors are assertion errors class ConditionMismatch(AssertionError): - """ - Examples of application + """An error to through during assertion if the asserting condition is not matched. + + Contains a bunch of factory methods to transform regular predicates + (functions that returns True/False) into condition functions + that raise this error (ConditionMismatch) where predicate would return false. + + Examples of usage of factory methods: ```python # GIVEN @@ -64,51 +63,111 @@ def is_positive(x) -> bool: def decremented(x) -> int: return x - 1 - # THEN - ConditionMismatch.to_raise_if_not_actual(predicate.is_positive)(1) - ConditionMismatch.to_raise_if_not_actual(is_positive)(1) - ConditionMismatch.to_raise_if_not(predicate.is_positive)(1) # ❤️ - ConditionMismatch.to_raise_if_not(is_positive)(1) # ❤️ - - ConditionMismatch.to_raise_if_not_actual(Query('is positive', lambda x: x > 0))(1) - ConditionMismatch.to_raise_if_not(Query('is positive', lambda x: x > 0))(1) # ❤️ - - ConditionMismatch.to_raise_if_not(Query('is positive', lambda x: x > 0), decremented)(1) # ❤️ - ConditionMismatch.to_raise_if_not(is_positive, decremented)(1) # ❤️ - ConditionMismatch.to_raise_if_not(decremented, is_positive)(1) - ConditionMismatch.to_raise_if_not_actual(decremented, is_positive)(1) # ❤ + # THEN (all will pass without error) + ConditionMismatch.to_raise_if_not(predicate.is_positive)(1) + ConditionMismatch.to_raise_if_not(is_positive)(1) + ConditionMismatch.to_raise_if(predicate.is_positive)(0) + ConditionMismatch.to_raise_if(is_positive)(0) + + ConditionMismatch.to_raise_if_not(Query('is positive', lambda x: x > 0))(1) + ConditionMismatch.to_raise_if(Query('is positive', lambda x: x > 0))(0) + + ConditionMismatch.to_raise_if_not( + Query('is positive', lambda x: x > 0), + Query('decremented', lambda x: x - 1), + )(2) + ConditionMismatch.to_raise_if_not( + Query('is positive', lambda x: x > 0), + decremented, + )(2) + ConditionMismatch.to_raise_if_not(is_positive, decremented)(2) + ConditionMismatch.to_raise_if(is_positive, decremented)(1) + ConditionMismatch.to_raise_if_not(actual=decremented, by=is_positive)(2) + ConditionMismatch.to_raise_if(actual=decremented, by=is_positive)(1) + + ConditionMismatch.to_raise_if_not_actual(decremented, predicate.is_positive)(2) + ConditionMismatch.to_raise_if_actual(decremented, predicate.is_positive)(1) + ConditionMismatch.to_raise_if_not_actual(decremented, predicate.is_positive)(2) + ConditionMismatch.to_raise_if_actual(decremented, is_positive)(1) + + ConditionMismatch.to_raise_if_not_actual( + Query('decremented', lambda x: x - 1), + Query('is positive', lambda x: x > 0) + )(2) + # ... ``` """ + def __init__(self, message='condition not matched'): + super().__init__(message) + @classmethod @overload - def _to_raise_if_not(cls, test: Callable[[E], bool]): ... + def _to_raise_if_not( + cls, + by: Callable[[E], bool], + *, + _inverted: bool = False, + ): ... @classmethod @overload - def _to_raise_if_not(cls, test: Callable[[R], bool], actual: Callable[[E], R]): ... + def _to_raise_if_not( + cls, + by: Callable[[R], bool], + actual: Callable[[E], R] | None = None, + *, + _inverted: bool = False, + ): ... # TODO: should we name test param as predicate? @classmethod def _to_raise_if_not( cls, - # TODO: test may sound like assertion, not predicate... rename? - test: Callable[[E | R], bool], - actual: Callable[[E], E | R] | None = None, - # TODO: should we add inverted here? + by: Callable[[E | R], bool], + actual: Optional[Callable[[E], E | R]] = None, + *, + _inverted: Optional[bool] = False, ): - @functools.wraps(test) + @functools.wraps(by) def wrapped(entity: E) -> None: actual_description = ( - f' {name}' if (name := Query.full_name_for(actual)) else '' + f' {name}' if (name := Query.full_description_for(actual)) else '' ) actual_to_test = actual(entity) if actual else entity - if not test(actual_to_test): + answer = None + try: + answer = by(actual_to_test) + # TODO: should we move Exception processing out of this helper? + # should it be somewhere in Condition? + # cause now it's not a Mismatch anymore, it's a failure + # – no, we should not, we should keep it here, + # because this is needed for the inverted case + except Exception as reason: + # answer is still None + if not _inverted: + raise reason + pass + + if answer if _inverted else not answer: # TODO: should we render expected too? (based on predicate name) + # we want need it for our conditions, + # cause wait.py logs it in the message + # but ... ? raise ( cls(f'actual{actual_description}: {actual_to_test}') if actual - else cls(f'{Query.full_name_for(test) or "condition"} not matched') + else cls( + ( + ( + (f'not ({name})' if _inverted else name) + if (name := Query.full_description_for(by)) + else '' + ) + or "condition" + ) + + ' not matched' + ) # TODO: decide on # cls(f'{Query.full_name_for(predicate) or "condition"} not matched') # vs @@ -117,35 +176,29 @@ def wrapped(entity: E) -> None: return wrapped + @classmethod + def _to_raise_if( + cls, + by: Callable[[E | R], bool], + actual: Callable[[E], R] | None = None, + ): + return cls._to_raise_if_not(by, actual, _inverted=True) + @classmethod def _to_raise_if_not_actual( cls, query: Callable[[E], R], - test: Callable[[R], bool], + by: Callable[[R], bool], ): - return cls._to_raise_if_not(test, query) - - # @classmethod - # def to_raise_if_not( - # cls, - # predicate: Callable[[Any], bool], - # _named: str | None = None, - # _message: str | None = None, - # ): - # @functools.wraps(predicate) - # def wrapped(x): - # nonlocal predicate - # if not predicate(x): - # raise cls( - # _message - # if _message - # else f"{_named if _named else predicate} not matched" - # ) - # - # return wrapped + return cls._to_raise_if_not(by, query) - def __init__(self, message='condition not matched'): - super().__init__(message) + @classmethod + def _to_raise_if_actual( + cls, + query: Callable[[E], R], + by: Callable[[R], bool], + ): + return cls._to_raise_if(by, query) class ConditionNotMatchedError(ConditionMismatch): diff --git a/selene/core/match.py b/selene/core/match.py index 18ee8520..907171a6 100644 --- a/selene/core/match.py +++ b/selene/core/match.py @@ -76,7 +76,7 @@ element_is_clickable: Condition[Element] = element_is_visible.and_(element_is_enabled) element_is_present: Condition[Element] = ElementCondition.raise_if_not( - 'is present in DOM', lambda element: element() is not None + 'is present in DOM', lambda element: element.locate() is not None ) element_is_absent: Condition[Element] = ElementCondition.as_not(element_is_present) @@ -268,7 +268,7 @@ def values_containing( ) return ConditionWithValues( - str(raw_property_condition), raw_property_condition.__call__ + str(raw_property_condition), test=raw_property_condition.__call__ ) @@ -319,7 +319,7 @@ def values_containing( ) return ConditionWithValues( - str(raw_property_condition), raw_property_condition.__call__ + str(raw_property_condition), test=raw_property_condition.__call__ ) @@ -387,7 +387,7 @@ def values_containing( ) return ConditionWithValues( - str(raw_attribute_condition), raw_attribute_condition.__call__ + str(raw_attribute_condition), test=raw_attribute_condition.__call__ ) @@ -652,7 +652,7 @@ def __init__( ): # noqa if self._MATCHING_SEPARATOR.__len__() != 1: raise ValueError('MATCHING_SEPARATOR should be a one character string') - super().__init__(self.__str__, self.__call__) + super().__init__(self.__str__, test=self.__call__) self._expected = expected self._inverted = _inverted self._globs = _globs if _globs else _exact_texts_like._DEFAULT_GLOBS @@ -824,7 +824,7 @@ def __call__(self, entity: Collection): answer = None regex_invalid_error: re.error | None = None try: - answer = self._match(expected_pattern, actual_to_match) + answer = self._test(expected_pattern, actual_to_match) except re.error as error: # going to re-raise it below as AssertionError on `not answer` regex_invalid_error = error @@ -879,7 +879,7 @@ def __str__(self): ) ) - def _match(self, pattern, actual): + def _test(self, pattern, actual): answer = re.match(pattern, actual, self._flags) return not answer if self._inverted else answer diff --git a/selene/core/wait.py b/selene/core/wait.py index c0e4c590..8acee203 100644 --- a/selene/core/wait.py +++ b/selene/core/wait.py @@ -91,6 +91,7 @@ def hook_failure( return self._hook_failure # TODO: consider renaming to `def to(...)`, though will sound awkward when wait.to(condition) + # TODO: do we need a second description/named param? def for_(self, fn: Callable[[E], R]) -> R: def logic(fn: Callable[[E], R]) -> R: finish_time = time.time() + self._timeout @@ -110,6 +111,8 @@ def logic(fn: Callable[[E], R]) -> R: timeout = self._timeout entity = self.entity + # TODO: consider using Query.full_description_for(fn) + # TODO: consider customizing what to use on __init__ fn_name = Query.full_name_for(fn) or str(fn) failure = TimeoutException( diff --git a/tests/integration/condition__elements__have_attribute_and_co_test.py b/tests/integration/condition__elements__have_attribute_and_co_test.py index 683eb6bb..9686e16b 100644 --- a/tests/integration/condition__elements__have_attribute_and_co_test.py +++ b/tests/integration/condition__elements__have_attribute_and_co_test.py @@ -64,7 +64,8 @@ def test_have_attribute__condition_variations(session_browser): "browser.all(('css selector', '.name')).has no (attribute 'value' with values " "containing '(20, 2)')\n" '\n' - 'Reason: ConditionMismatch: condition not matched\n' + "Reason: ConditionMismatch: actual attribute values: ['John 20th', 'Doe " + "2nd']\n" ) in str(error) names.should(have.attribute('id').value('name').each.not_) diff --git a/tests/integration/condition__elements__have_property_and_co_test.py b/tests/integration/condition__elements__have_property_and_co_test.py index dabd6dc3..cad24133 100644 --- a/tests/integration/condition__elements__have_property_and_co_test.py +++ b/tests/integration/condition__elements__have_property_and_co_test.py @@ -61,11 +61,10 @@ def test_have_property__condition_variations(session_browser): pytest.fail('should fail on values mismatch') except AssertionError as error: assert ( - 'Timed out after 0.1s, while waiting for:\n' - "browser.all(('css selector', '.name')).has no (js property 'value' with values " - "containing '(20, 2)')\n" + "browser.all(('css selector', '.name')).has no (js property 'value' with " + "values containing '(20, 2)')\n" '\n' - 'Reason: ConditionMismatch: condition not matched\n' + "Reason: ConditionMismatch: actual property values: ['John 20th', 'Doe 2nd']\n" ) in str(error) exercises.first.should(have.js_property('value').value(20)) diff --git a/tests/unit/common/fp__threads_test.py b/tests/unit/common/fp__threads_test.py new file mode 100644 index 00000000..bfe3696f --- /dev/null +++ b/tests/unit/common/fp__threads_test.py @@ -0,0 +1,96 @@ +def test_thread_last__demo_with_re_sub_only(): + from selene.common.fp import thread_last + import re + + result = thread_last( + '_have_.special.NumberLike._attr_', + (re.sub, r'([a-z0-9])([A-Z])', r'\1 \2'), + (re.sub, r'(\w)\.(\w)', r'\1 \2'), + (re.sub, r'(^_+|_+$)', ''), + (re.sub, r'_+', ' '), + (re.sub, r'(\s)+', r'\1'), + str.lower, + ) + + assert result == 'have special number like attr' + + +def test_thread_last__demo_with_common_str_fns(): + from selene.common.fp import thread_last, map_with + import re + + result = thread_last( + ['_have.special_number_', 10], + map_with(str), + ''.join, + (re.sub, r'(^_+|_+$)', ''), + (re.sub, r'_+', ' '), + (re.sub, r'(\w)\.(\w)', r'\1 \2'), + str.split, + ) + + assert result == ['have', 'special', 'number', '10'] + + +def test_thread_last__threads_in_first_to_last_order(): + from selene.common.fp import thread_last + + append = lambda the_item, to_acc: to_acc + the_item + + # WHEN + result = thread_last( + '', + (append, 'a'), + (append, 'b'), + (append, 'c'), + ) + + assert 'abc' == result + + +def test_thread_first__threads_in_first_to_last_order(): + from selene.common.fp import thread_first + + append = lambda to_acc, the_item: to_acc + the_item + + # WHEN + result = thread_first( + '', + (append, 'a'), + (append, 'b'), + (append, 'c'), + ) + + assert 'abc' == result + + +def test_thread__is_more_bulky__when_signatures_are_similar(): + from selene.common.fp import thread + + append = lambda to_acc, the_item: to_acc + the_item + + # WHEN + result = thread( + '', + lambda acc: append(acc, 'a'), + lambda acc: append(acc, 'b'), + lambda acc: append(acc, 'c'), + ) + + assert 'abc' == result + + +def test_thread__is_handy__when_signatures_are_too_different(): + from selene.common.fp import thread + + suffix = lambda to_acc, the_item: to_acc + the_item + prefix = lambda the_item, to_acc: the_item + to_acc + + # WHEN + result = thread( + 'b', + lambda acc: suffix(acc, 'c'), + lambda acc: prefix('a', acc), + ) + + assert 'abc' == result diff --git a/tests/unit/core/condition_test.py b/tests/unit/core/condition_test.py index 73621be9..a5cd22af 100644 --- a/tests/unit/core/condition_test.py +++ b/tests/unit/core/condition_test.py @@ -1,39 +1,138 @@ from __future__ import annotations -from selene.core.condition import Condition +from selene.core.condition import Condition, Match import pytest from selene.core.exceptions import ConditionMismatch from selene.common._typing_functions import E, Query -def test_match__of_constructed_via__init(): - positive = Condition( - 'match positive', +def test_match__of_constructed_via__init__with_test_fn(): + positive = Match( + 'positive', ConditionMismatch._to_raise_if_not(Query('is positive', lambda x: x > 0)), - _inverted=False, ) - positive._match(1) - assert 'match positive' == str(positive) + positive._test(1) + assert 'positive' == str(positive) + + try: + positive._test(0) + pytest.fail('on mismatch') + except AssertionError as error: + assert 'is positive not matched' == str(error) + + +def test_match__of_constructed_via__init__with_test_by_predicate_as_lambda(): + positive = Match( + 'positive', + by=lambda x: x > 0, + ) + + positive._test(1) + assert 'positive' == str(positive) + + try: + positive._test(0) + pytest.fail('on mismatch') + except AssertionError as error: + assert 'condition not matched' == str(error) + + +def test_match__of_constructed_via__init__with_test_by_predicate_as_query(): + positive = Match( + 'positive', + by=Query('is positive', lambda x: x > 0), + ) + + positive._test(1) + assert 'positive' == str(positive) try: - positive._match(0) + positive._test(0) pytest.fail('on mismatch') except AssertionError as error: assert 'is positive not matched' == str(error) +def test_match__of_constructed_via__init__with_test_by_query_and_actual_as_lambda(): + positive_decrement = Match( + 'positive decrement', + actual=lambda x: x - 1, + by=Query('is positive', lambda x: x > 0), + ) + + positive_decrement._test(2) + assert 'positive decrement' == str(positive_decrement) + + try: + positive_decrement._test(1) + pytest.fail('on mismatch') + except AssertionError as error: + assert 'actual: 0' == str(error) + + +def test_match__of_constructed_via__init__with_test_by_query_and_actual_as_query(): + positive_decrement = Match( + 'positive decrement', + actual=Query('decrement', lambda x: x - 1), + by=Query('is positive', lambda x: x > 0), + ) + + positive_decrement._test(2) + assert 'positive decrement' == str(positive_decrement) + + try: + positive_decrement._test(1) + pytest.fail('on mismatch') + except AssertionError as error: + assert 'actual decrement: 0' == str(error) + + +# TODO: consider implenting +def x_test_match__of_constructed_via__init__with_test_by_and_actual_but_no_desc(): + positive_decrement = Match( + by=Query('has positive', lambda x: x > 0), + actual=Query('decrement', lambda x: x - 1), + ) + + positive_decrement._test(2) + assert 'has positive decrement' == str(positive_decrement) + + try: + positive_decrement._test(1) + pytest.fail('on mismatch') + except AssertionError as error: + assert 'actual decrement: 0' == str(error) + + +# TODO: consider implenting +def x_test_match__of_constructed_via__init__with_test_by_n_actual_as_tuples_n_no_desc(): + positive_decrement = Match( + actual=('decrement', lambda x: x - 1), + by=('is positive', lambda x: x > 0), + ) + + positive_decrement._test(2) + assert 'is positive decrement' == str(positive_decrement) + + try: + positive_decrement._test(1) + pytest.fail('on mismatch') + except AssertionError as error: + assert 'actual decrement: 0' == str(error) + + def test_match__of_constructed_via_factory__raise_if_not_actual(): positive = Condition.raise_if_not_actual( 'is positive', Query('self', lambda it: it), lambda actual: actual > 0 ) - positive._match(1) + positive._test(1) assert 'is positive' == str(positive) try: - positive._match(0) + positive._test(0) pytest.fail('on mismatch') except AssertionError as error: assert 'actual self: 0' == str(error) @@ -42,11 +141,11 @@ def test_match__of_constructed_via_factory__raise_if_not_actual(): def test_match__of_constructed_via_factory__raise_if_not(): positive = Condition.raise_if_not('is positive', lambda actual: actual > 0) - positive._match(1) + positive._test(1) assert 'is positive' == str(positive) try: - positive._match(0) + positive._test(0) pytest.fail('on mismatch') except AssertionError as error: assert 'condition not matched' == str(error) @@ -62,10 +161,10 @@ def test_predicate__of_constructed_via_factory__raise_if_not(): def test_not_match__of_constructed_via_factory__raise_if_not(): positive = Condition.raise_if_not('is positive', lambda actual: actual > 0) - positive.not_._match(0) + positive.not_._test(0) try: - positive.not_._match(1) + positive.not_._test(1) pytest.fail('on mismatch') except AssertionError as error: assert 'condition not matched' == str(error) @@ -76,14 +175,14 @@ def test_not_match__of_constructed_via_factory__raise_if_not_actual(): 'is positive', Query('self', lambda it: it), lambda actual: actual > 0 ) - positive.not_._match(0) + positive.not_._test(0) assert 'is not (positive)' == str(positive.not_) try: - positive.not_._match(1) + positive.not_._test(1) pytest.fail('on mismatch') except AssertionError as error: - assert 'condition not matched' == str(error) + assert 'actual self: 1' == str(error) # TODO: should the feature be implemented? @@ -94,11 +193,11 @@ def x_test_not_match__of_constructed_via_factory__raise_if_not_actual(): _query=Query('self', lambda it: it), # 💡 ) - positive.not_._match(0) + positive.not_._test(0) assert 'is not (positive)' == str(positive.not_) try: - positive.not_._match(1) + positive.not_._test(1) pytest.fail('on mismatch') except AssertionError as error: assert 'actual self: 0' == str(error) # 💡 @@ -109,11 +208,11 @@ def test_not_not_match__of_constructed_via_factory__raise_if_not_actual(): 'is positive', Query('self', lambda it: it), lambda actual: actual > 0 ) - positive.not_.not_._match(1) + positive.not_.not_._test(1) assert 'is positive' == str(positive.not_.not_) try: - positive.not_.not_._match(0) + positive.not_.not_._test(0) pytest.fail('on mismatch') except AssertionError as error: assert 'actual self: 0' == str(error) @@ -138,14 +237,12 @@ def test_as_not_match__of_constructed_via_factory__raise_if_not_actual(): # THEN assert 'negative or zero' == str(negative_or_zero) - negative_or_zero._match(0) + negative_or_zero._test(0) # WHEN try: - negative_or_zero._match(1) + negative_or_zero._test(1) # THEN pytest.fail('on mismatch') except AssertionError as error: - assert 'condition not matched' == str(error) - # # TODO: do we need the following behavior? - # assert 'actual self: 1' == str(error) + assert 'actual self: 1' == str(error) diff --git a/tests/unit/core/test_wait.py b/tests/unit/core/test_wait.py index 5bcae22a..19cc958e 100644 --- a/tests/unit/core/test_wait.py +++ b/tests/unit/core/test_wait.py @@ -1,4 +1,7 @@ from __future__ import annotations + +import pytest + from selene.core.wait import Wait @@ -38,14 +41,12 @@ def attribute(entity: Entity): # THEN except AssertionError as error: - assert 'Timed out after 0.5s, while waiting for:' in str(error) assert ( - 'Entity(None)' - f'.{test_simple_waiting_entity_lifecycle__when_fn_is_static_fn.__name__}' - '.' - '.have.attribute' + 'Timed out after 0.5s, while waiting for:\n' + 'Entity(None).have.attribute\n' + '\n' + 'Reason: AssertionError: attribute is None\n' ) in str(error) - assert 'Reason: AssertionError: attribute is None' in str(error) # WHEN have.attribute.__qualname__ = 'has attribute defined' @@ -67,10 +68,12 @@ def attribute(entity: Entity): # THEN __qualname__ still overrides __str__ in error message except AssertionError as error: - assert 'Timed out after 0.5s, while waiting for:' in str(error) - assert 'Entity(None).has defined attribute' not in str(error) # <- THEN - assert 'Entity(None).has attribute defined' in str(error) # <- THEN - assert 'Reason: AssertionError: attribute is None' in str(error) + assert ( + 'Timed out after 0.5s, while waiting for:\n' + 'Entity(None).has defined attribute\n' # <- THEN + '\n' + 'Reason: AssertionError: attribute is None\n' + ) in str(error) def test_simple_waiting_entity_lifecycle__when_fn_is_callable(): @@ -109,18 +112,17 @@ def __call__(self, entity: Entity): # THEN except AssertionError as error: - assert 'Timed out after 0.5s, while waiting for:' in str(error) assert ( - 'Entity(None)' - f'.' - '.HaveAttribute object at' + 'Message: \n' + '\n' + 'Timed out after 0.5s, while waiting for:\n' + 'Entity(None).HaveAttribute\n' + '\n' + 'Reason: AssertionError: attribute is None\n' ) in str(error) - assert 'Reason: AssertionError: attribute is None' in str(error) # WHEN - have_attribute.__str__ = lambda: 'has defined attribute' + have_attribute.__class__.__str__ = lambda self: 'has defined attribute' # AND try: changed_result.wait.for_(have_attribute) @@ -132,13 +134,27 @@ def __call__(self, entity: Entity): assert 'Reason: AssertionError: attribute is None' in str(error) # WHEN - have_attribute.__qualname__ = 'has attribute defined' + have_attribute.__class__.__str__ = object.__str__ + have_attribute.__str__ = lambda: 'has DEFINED attribute' # AND try: changed_result.wait.for_(have_attribute) - # THEN new __qualname__ overrides __str__() + # THEN __str__() in error message except AssertionError as error: assert 'Timed out after 0.5s, while waiting for:' in str(error) - assert 'Entity(None).has attribute defined' in str(error) # <- THEN + assert 'Entity(None).has DEFINED attribute' in str(error) # <- THEN assert 'Reason: AssertionError: attribute is None' in str(error) + + # # Seems like not relevant anymore ↙️ + # # WHEN + # have_attribute.__qualname__ = 'has attribute defined' + # # AND + # try: + # changed_result.wait.for_(have_attribute) + # + # # THEN new __qualname__ overrides __str__() + # except AssertionError as error: + # assert 'Timed out after 0.5s, while waiting for:' in str(error) + # assert 'Entity(None).has attribute defined' in str(error) # <- THEN + # assert 'Reason: AssertionError: attribute is None' in str(error)