diff --git a/poetry.lock b/poetry.lock index b72242e53..2d77af476 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,18 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} [[package]] name = "cachetools" @@ -155,6 +169,17 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1 testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] typing = ["typing-extensions (>=4.8)"] +[[package]] +name = "gherkin-official" +version = "29.0.0" +description = "Gherkin parser (official, by Cucumber team)" +optional = false +python-versions = "*" +files = [ + {file = "gherkin_official-29.0.0-py3-none-any.whl", hash = "sha256:26967b0d537a302119066742669e0e8b663e632769330be675457ae993e1d1bc"}, + {file = "gherkin_official-29.0.0.tar.gz", hash = "sha256:dbea32561158f02280d7579d179b019160d072ce083197625e2f80a6776bb9eb"}, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -384,6 +409,130 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pydantic" +version = "2.9.0" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.9.0-py3-none-any.whl", hash = "sha256:f66a7073abd93214a20c5f7b32d56843137a7a2e70d02111f3be287035c45370"}, + {file = "pydantic-2.9.0.tar.gz", hash = "sha256:c7a8a9fdf7d100afa49647eae340e2d23efa382466a8d177efcd1381e9be5598"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.23.2" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] +tzdata = {version = "*", markers = "python_version >= \"3.9\""} + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.23.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.23.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7d0324a35ab436c9d768753cbc3c47a865a2cbc0757066cb864747baa61f6ece"}, + {file = "pydantic_core-2.23.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:276ae78153a94b664e700ac362587c73b84399bd1145e135287513442e7dfbc7"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:964c7aa318da542cdcc60d4a648377ffe1a2ef0eb1e996026c7f74507b720a78"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1cf842265a3a820ebc6388b963ead065f5ce8f2068ac4e1c713ef77a67b71f7c"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae90b9e50fe1bd115b24785e962b51130340408156d34d67b5f8f3fa6540938e"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ae65fdfb8a841556b52935dfd4c3f79132dc5253b12c0061b96415208f4d622"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c8aa40f6ca803f95b1c1c5aeaee6237b9e879e4dfb46ad713229a63651a95fb"}, + {file = "pydantic_core-2.23.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c53100c8ee5a1e102766abde2158077d8c374bee0639201f11d3032e3555dfbc"}, + {file = "pydantic_core-2.23.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d6b9dd6aa03c812017411734e496c44fef29b43dba1e3dd1fa7361bbacfc1354"}, + {file = "pydantic_core-2.23.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b18cf68255a476b927910c6873d9ed00da692bb293c5b10b282bd48a0afe3ae2"}, + {file = "pydantic_core-2.23.2-cp310-none-win32.whl", hash = "sha256:e460475719721d59cd54a350c1f71c797c763212c836bf48585478c5514d2854"}, + {file = "pydantic_core-2.23.2-cp310-none-win_amd64.whl", hash = "sha256:5f3cf3721eaf8741cffaf092487f1ca80831202ce91672776b02b875580e174a"}, + {file = "pydantic_core-2.23.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7ce8e26b86a91e305858e018afc7a6e932f17428b1eaa60154bd1f7ee888b5f8"}, + {file = "pydantic_core-2.23.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e9b24cca4037a561422bf5dc52b38d390fb61f7bfff64053ce1b72f6938e6b2"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753294d42fb072aa1775bfe1a2ba1012427376718fa4c72de52005a3d2a22178"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:257d6a410a0d8aeb50b4283dea39bb79b14303e0fab0f2b9d617701331ed1515"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8319e0bd6a7b45ad76166cc3d5d6a36c97d0c82a196f478c3ee5346566eebfd"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a05c0240f6c711eb381ac392de987ee974fa9336071fb697768dfdb151345ce"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d5b0ff3218858859910295df6953d7bafac3a48d5cd18f4e3ed9999efd2245f"}, + {file = "pydantic_core-2.23.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96ef39add33ff58cd4c112cbac076726b96b98bb8f1e7f7595288dcfb2f10b57"}, + {file = "pydantic_core-2.23.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0102e49ac7d2df3379ef8d658d3bc59d3d769b0bdb17da189b75efa861fc07b4"}, + {file = "pydantic_core-2.23.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a6612c2a844043e4d10a8324c54cdff0042c558eef30bd705770793d70b224aa"}, + {file = "pydantic_core-2.23.2-cp311-none-win32.whl", hash = "sha256:caffda619099cfd4f63d48462f6aadbecee3ad9603b4b88b60cb821c1b258576"}, + {file = "pydantic_core-2.23.2-cp311-none-win_amd64.whl", hash = "sha256:6f80fba4af0cb1d2344869d56430e304a51396b70d46b91a55ed4959993c0589"}, + {file = "pydantic_core-2.23.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4c83c64d05ffbbe12d4e8498ab72bdb05bcc1026340a4a597dc647a13c1605ec"}, + {file = "pydantic_core-2.23.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6294907eaaccf71c076abdd1c7954e272efa39bb043161b4b8aa1cd76a16ce43"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a801c5e1e13272e0909c520708122496647d1279d252c9e6e07dac216accc41"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc0c316fba3ce72ac3ab7902a888b9dc4979162d320823679da270c2d9ad0cad"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b06c5d4e8701ac2ba99a2ef835e4e1b187d41095a9c619c5b185c9068ed2a49"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82764c0bd697159fe9947ad59b6db6d7329e88505c8f98990eb07e84cc0a5d81"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b1a195efd347ede8bcf723e932300292eb13a9d2a3c1f84eb8f37cbbc905b7f"}, + {file = "pydantic_core-2.23.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7efb12e5071ad8d5b547487bdad489fbd4a5a35a0fc36a1941517a6ad7f23e0"}, + {file = "pydantic_core-2.23.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5dd0ec5f514ed40e49bf961d49cf1bc2c72e9b50f29a163b2cc9030c6742aa73"}, + {file = "pydantic_core-2.23.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:820f6ee5c06bc868335e3b6e42d7ef41f50dfb3ea32fbd523ab679d10d8741c0"}, + {file = "pydantic_core-2.23.2-cp312-none-win32.whl", hash = "sha256:3713dc093d5048bfaedbba7a8dbc53e74c44a140d45ede020dc347dda18daf3f"}, + {file = "pydantic_core-2.23.2-cp312-none-win_amd64.whl", hash = "sha256:e1895e949f8849bc2757c0dbac28422a04be031204df46a56ab34bcf98507342"}, + {file = "pydantic_core-2.23.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:da43cbe593e3c87d07108d0ebd73771dc414488f1f91ed2e204b0370b94b37ac"}, + {file = "pydantic_core-2.23.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:64d094ea1aa97c6ded4748d40886076a931a8bf6f61b6e43e4a1041769c39dd2"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:084414ffe9a85a52940b49631321d636dadf3576c30259607b75516d131fecd0"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043ef8469f72609c4c3a5e06a07a1f713d53df4d53112c6d49207c0bd3c3bd9b"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3649bd3ae6a8ebea7dc381afb7f3c6db237fc7cebd05c8ac36ca8a4187b03b30"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6db09153d8438425e98cdc9a289c5fade04a5d2128faff8f227c459da21b9703"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5668b3173bb0b2e65020b60d83f5910a7224027232c9f5dc05a71a1deac9f960"}, + {file = "pydantic_core-2.23.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c7b81beaf7c7ebde978377dc53679c6cba0e946426fc7ade54251dfe24a7604"}, + {file = "pydantic_core-2.23.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ae579143826c6f05a361d9546446c432a165ecf1c0b720bbfd81152645cb897d"}, + {file = "pydantic_core-2.23.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:19f1352fe4b248cae22a89268720fc74e83f008057a652894f08fa931e77dced"}, + {file = "pydantic_core-2.23.2-cp313-none-win32.whl", hash = "sha256:e1a79ad49f346aa1a2921f31e8dbbab4d64484823e813a002679eaa46cba39e1"}, + {file = "pydantic_core-2.23.2-cp313-none-win_amd64.whl", hash = "sha256:582871902e1902b3c8e9b2c347f32a792a07094110c1bca6c2ea89b90150caac"}, + {file = "pydantic_core-2.23.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:743e5811b0c377eb830150d675b0847a74a44d4ad5ab8845923d5b3a756d8100"}, + {file = "pydantic_core-2.23.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6650a7bbe17a2717167e3e23c186849bae5cef35d38949549f1c116031b2b3aa"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56e6a12ec8d7679f41b3750ffa426d22b44ef97be226a9bab00a03365f217b2b"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:810ca06cca91de9107718dc83d9ac4d2e86efd6c02cba49a190abcaf33fb0472"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:785e7f517ebb9890813d31cb5d328fa5eda825bb205065cde760b3150e4de1f7"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ef71ec876fcc4d3bbf2ae81961959e8d62f8d74a83d116668409c224012e3af"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d50ac34835c6a4a0d456b5db559b82047403c4317b3bc73b3455fefdbdc54b0a"}, + {file = "pydantic_core-2.23.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16b25a4a120a2bb7dab51b81e3d9f3cde4f9a4456566c403ed29ac81bf49744f"}, + {file = "pydantic_core-2.23.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:41ae8537ad371ec018e3c5da0eb3f3e40ee1011eb9be1da7f965357c4623c501"}, + {file = "pydantic_core-2.23.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07049ec9306ec64e955b2e7c40c8d77dd78ea89adb97a2013d0b6e055c5ee4c5"}, + {file = "pydantic_core-2.23.2-cp38-none-win32.whl", hash = "sha256:086c5db95157dc84c63ff9d96ebb8856f47ce113c86b61065a066f8efbe80acf"}, + {file = "pydantic_core-2.23.2-cp38-none-win_amd64.whl", hash = "sha256:67b6655311b00581914aba481729971b88bb8bc7996206590700a3ac85e457b8"}, + {file = "pydantic_core-2.23.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:358331e21a897151e54d58e08d0219acf98ebb14c567267a87e971f3d2a3be59"}, + {file = "pydantic_core-2.23.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c4d9f15ffe68bcd3898b0ad7233af01b15c57d91cd1667f8d868e0eacbfe3f87"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0123655fedacf035ab10c23450163c2f65a4174f2bb034b188240a6cf06bb123"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e6e3ccebdbd6e53474b0bb7ab8b88e83c0cfe91484b25e058e581348ee5a01a5"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc535cb898ef88333cf317777ecdfe0faac1c2a3187ef7eb061b6f7ecf7e6bae"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aab9e522efff3993a9e98ab14263d4e20211e62da088298089a03056980a3e69"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05b366fb8fe3d8683b11ac35fa08947d7b92be78ec64e3277d03bd7f9b7cda79"}, + {file = "pydantic_core-2.23.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7568f682c06f10f30ef643a1e8eec4afeecdafde5c4af1b574c6df079e96f96c"}, + {file = "pydantic_core-2.23.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cdd02a08205dc90238669f082747612cb3c82bd2c717adc60f9b9ecadb540f80"}, + {file = "pydantic_core-2.23.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a2ab4f410f4b886de53b6bddf5dd6f337915a29dd9f22f20f3099659536b2f6"}, + {file = "pydantic_core-2.23.2-cp39-none-win32.whl", hash = "sha256:0448b81c3dfcde439551bb04a9f41d7627f676b12701865c8a2574bcea034437"}, + {file = "pydantic_core-2.23.2-cp39-none-win_amd64.whl", hash = "sha256:4cebb9794f67266d65e7e4cbe5dcf063e29fc7b81c79dc9475bd476d9534150e"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e758d271ed0286d146cf7c04c539a5169a888dd0b57026be621547e756af55bc"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f477d26183e94eaafc60b983ab25af2a809a1b48ce4debb57b343f671b7a90b6"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da3131ef2b940b99106f29dfbc30d9505643f766704e14c5d5e504e6a480c35e"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329a721253c7e4cbd7aad4a377745fbcc0607f9d72a3cc2102dd40519be75ed2"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7706e15cdbf42f8fab1e6425247dfa98f4a6f8c63746c995d6a2017f78e619ae"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e64ffaf8f6e17ca15eb48344d86a7a741454526f3a3fa56bc493ad9d7ec63936"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dd59638025160056687d598b054b64a79183f8065eae0d3f5ca523cde9943940"}, + {file = "pydantic_core-2.23.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:12625e69b1199e94b0ae1c9a95d000484ce9f0182f9965a26572f054b1537e44"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5d813fd871b3d5c3005157622ee102e8908ad6011ec915a18bd8fde673c4360e"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1eb37f7d6a8001c0f86dc8ff2ee8d08291a536d76e49e78cda8587bb54d8b329"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ce7eaf9a98680b4312b7cebcdd9352531c43db00fca586115845df388f3c465"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f087879f1ffde024dd2788a30d55acd67959dcf6c431e9d3682d1c491a0eb474"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ce883906810b4c3bd90e0ada1f9e808d9ecf1c5f0b60c6b8831d6100bcc7dd6"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a8031074a397a5925d06b590121f8339d34a5a74cfe6970f8a1124eb8b83f4ac"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:23af245b8f2f4ee9e2c99cb3f93d0e22fb5c16df3f2f643f5a8da5caff12a653"}, + {file = "pydantic_core-2.23.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c57e493a0faea1e4c38f860d6862ba6832723396c884fbf938ff5e9b224200e2"}, + {file = "pydantic_core-2.23.2.tar.gz", hash = "sha256:95d6bf449a1ac81de562d65d180af5d8c19672793c81877a2eda8fde5d08f2fd"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pygments" version = "2.17.2" @@ -531,6 +680,28 @@ files = [ {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + [[package]] name = "virtualenv" version = "20.25.1" @@ -554,4 +725,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.8" -content-hash = "b40d47067f444deec4964404014795593f1b602f8a2f6376279bb5a27d5e18be" +content-hash = "14509c113eb897776c0adc8e930775188765786b3dd9022359ba19733833b363" diff --git a/pyproject.toml b/pyproject.toml index f8464ec77..a5d9a15c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,8 @@ parse-type = "*" pytest = ">=6.2.0" typing-extensions = "*" packaging = "*" +gherkin-official = "^29.0.0" +pydantic = "^2.9.0" [tool.poetry.group.dev.dependencies] tox = ">=4.11.3" diff --git a/src/pytest_bdd/compat.py b/src/pytest_bdd/compat.py index 079f7de01..37fc75091 100644 --- a/src/pytest_bdd/compat.py +++ b/src/pytest_bdd/compat.py @@ -24,7 +24,6 @@ def inject_fixture(request: FixtureRequest, arg: str, value: Any) -> None: :param arg: argument name :param value: argument value """ - request._fixturemanager._register_fixture( name=arg, func=lambda: value, diff --git a/src/pytest_bdd/feature.py b/src/pytest_bdd/feature.py index 54a15e3af..bf91345f7 100644 --- a/src/pytest_bdd/feature.py +++ b/src/pytest_bdd/feature.py @@ -28,8 +28,9 @@ import glob import os.path +from typing import Iterator -from .parser import Feature, parse_feature +from .parser import Feature, get_gherkin_document # Global features dictionary features: dict[str, Feature] = {} @@ -49,11 +50,13 @@ def get_feature(base_path: str, filename: str, encoding: str = "utf-8") -> Featu when multiple scenarios are referencing the same file. """ __tracebackhide__ = True - full_name = os.path.abspath(os.path.join(base_path, filename)) - feature = features.get(full_name) + full_filename = os.path.abspath(os.path.join(base_path, filename)) + rel_filename = os.path.join(os.path.basename(base_path), filename) + feature = features.get(full_filename) if not feature: - feature = parse_feature(base_path, filename, encoding=encoding) - features[full_name] = feature + gherkin_document = get_gherkin_document(full_filename, rel_filename, encoding) + feature = gherkin_document.feature + features[full_filename] = feature return feature @@ -65,17 +68,23 @@ def get_features(paths: list[str], **kwargs) -> list[Feature]: :return: `list` of `Feature` objects. """ seen_names = set() - features = [] + _features = [] for path in paths: if path not in seen_names: seen_names.add(path) if os.path.isdir(path): - features.extend( - get_features(glob.iglob(os.path.join(path, "**", "*.feature"), recursive=True), **kwargs) - ) + for feature_file in _find_feature_files(path): + base, name = os.path.split(feature_file) + feature = get_feature(base, name, **kwargs) + _features.append(feature) else: base, name = os.path.split(path) feature = get_feature(base, name, **kwargs) - features.append(feature) - features.sort(key=lambda feature: feature.name or feature.filename) - return features + _features.append(feature) + _features.sort(key=lambda _feature: _feature.name or _feature.abs_filename) + return _features + + +def _find_feature_files(path: str) -> Iterator[str]: + """Recursively find all `.feature` files in a given directory.""" + return glob.iglob(os.path.join(path, "**", "*.feature"), recursive=True) diff --git a/src/pytest_bdd/generation.py b/src/pytest_bdd/generation.py index bfde4a9b7..faa8f0b84 100644 --- a/src/pytest_bdd/generation.py +++ b/src/pytest_bdd/generation.py @@ -24,7 +24,8 @@ from _pytest.main import Session from _pytest.python import Function - from .parser import Feature, ScenarioTemplate, Step + from .parser import Feature, Scenario, Step + template_lookup = TemplateLookup(directories=[os.path.join(os.path.dirname(__file__), "templates")]) @@ -57,7 +58,7 @@ def cmdline_main(config: Config) -> int | None: return None # Make mypy happy -def generate_code(features: list[Feature], scenarios: list[ScenarioTemplate], steps: list[Step]) -> str: +def generate_code(features: list[Feature], scenarios: list[Scenario], steps: list[Step]) -> str: """Generate test code for the given filenames.""" grouped_steps = group_steps(steps) template = template_lookup.get_template("test.py.mak") @@ -79,7 +80,7 @@ def show_missing_code(config: Config) -> int: return wrap_session(config, _show_missing_code_main) -def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) -> None: +def print_missing_code(scenarios: list[Scenario], steps: list[Step]) -> None: """Print missing code with TerminalWriter.""" tw = TerminalWriter() scenario = step = None @@ -87,8 +88,8 @@ def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) -> for scenario in scenarios: tw.line() tw.line( - 'Scenario "{scenario.name}" is not bound to any test in the feature "{scenario.feature.name}"' - " in the file {scenario.feature.filename}:{scenario.line_number}".format(scenario=scenario), + 'Scenario "{scenario.name}" is not bound to any test in the feature "{scenario.parent.name}"' + " in the file {scenario.parent}:{scenario.location.line}".format(scenario=scenario), red=True, ) @@ -99,16 +100,16 @@ def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) -> tw.line() if step.scenario is not None: tw.line( - """Step {step} is not defined in the scenario "{step.scenario.name}" in the feature""" - """ "{step.scenario.feature.name}" in the file""" - """ {step.scenario.feature.filename}:{step.line_number}""".format(step=step), + """Step {step} is not defined in the scenario "{step.parent.name}" in the feature""" + """ "{step.parent.parent.name}" in the file""" + """ {step.parent.parent.abs_filename}:{step.location.line}""".format(step=step), red=True, ) elif step.background is not None: tw.line( """Step {step} is not defined in the background of the feature""" - """ "{step.background.feature.name}" in the file""" - """ {step.background.feature.filename}:{step.line_number}""".format(step=step), + """ "{step.background.parent.name}" in the file""" + """ {step.background.parent.abs_filename}:{step.location.line}""".format(step=step), red=True, ) @@ -119,7 +120,7 @@ def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) -> tw.line() features = sorted( - (scenario.feature for scenario in scenarios), key=lambda feature: feature.name or feature.filename + (scenario.feature for scenario in scenarios), key=lambda feature: feature.name or feature.abs_filename ) code = generate_code(features, scenarios, steps) tw.write(code) @@ -134,7 +135,7 @@ def _find_step_fixturedef( return getfixturedefs(fixturemanager, bdd_name, item) -def parse_feature_files(paths: list[str], **kwargs: Any) -> tuple[list[Feature], list[ScenarioTemplate], list[Step]]: +def parse_feature_files(paths: list[str], **kwargs: Any) -> tuple[list[Feature], list[Scenario], list[Step]]: """Parse feature files of given paths. :param paths: `list` of paths (file or dirs) @@ -144,25 +145,26 @@ def parse_feature_files(paths: list[str], **kwargs: Any) -> tuple[list[Feature], """ features = get_features(paths, **kwargs) scenarios = sorted( - itertools.chain.from_iterable(feature.scenarios.values() for feature in features), - key=lambda scenario: (scenario.feature.name or scenario.feature.filename, scenario.name), + itertools.chain.from_iterable(feature.scenarios for feature in features), + key=lambda scenario: (scenario.feature.name or scenario.feature.abs_filename, scenario.name), ) - steps = sorted((step for scenario in scenarios for step in scenario.steps), key=lambda step: step.name) + steps = sorted((step for scenario in scenarios for step in scenario.all_steps), key=lambda step: step.name) return features, scenarios, steps def group_steps(steps: list[Step]) -> list[Step]: """Group steps by type.""" - steps = sorted(steps, key=lambda step: step.type) + steps = sorted(steps, key=lambda step: step.given_when_then) seen_steps = set() grouped_steps = [] for step in itertools.chain.from_iterable( - sorted(group, key=lambda step: step.name) for _, group in itertools.groupby(steps, lambda step: step.type) + sorted(group, key=lambda step: step.name) + for _, group in itertools.groupby(steps, lambda step: step.given_when_then) ): if step.name not in seen_steps: grouped_steps.append(step) seen_steps.add(step.name) - grouped_steps.sort(key=lambda step: STEP_TYPES.index(step.type)) + grouped_steps.sort(key=lambda step: STEP_TYPES.index(step.given_when_then)) return grouped_steps @@ -190,10 +192,6 @@ def _show_missing_code_main(config: Config, session: Session) -> None: steps.remove(step) except ValueError: pass - for scenario in scenarios: - for step in scenario.steps: - if step.background is None: - steps.remove(step) grouped_steps = group_steps(steps) print_missing_code(scenarios, grouped_steps) diff --git a/src/pytest_bdd/gherkin_terminal_reporter.py b/src/pytest_bdd/gherkin_terminal_reporter.py index b26a8a7db..5c07130d3 100644 --- a/src/pytest_bdd/gherkin_terminal_reporter.py +++ b/src/pytest_bdd/gherkin_terminal_reporter.py @@ -31,10 +31,8 @@ def configure(config: Config) -> None: raise Exception( "gherkin-terminal-reporter is not compatible with any other terminal reporter." "You can use only one terminal reporter." - "Currently '{0}' is used." - "Please decide to use one by deactivating {0} or gherkin-terminal-reporter.".format( - current_reporter.__class__ - ) + "Currently '{current_reporter.__class__}' is used." + "Please decide to use one by deactivating {current_reporter.__class__} or gherkin-terminal-reporter." ) gherkin_reporter = GherkinTerminalReporter(config) config.pluginmanager.unregister(current_reporter) @@ -70,23 +68,20 @@ def pytest_runtest_logreport(self, report: TestReport) -> Any: if self.verbosity <= 0 or not hasattr(report, "scenario"): return super().pytest_runtest_logreport(rep) + # Common logic for verbosity 1 and greater + self.ensure_newline() + self._tw.write("Feature: ", **feature_markup) + self._tw.write(report.scenario["feature"]["name"], **feature_markup) + self._tw.write("\n") + self._tw.write(" Scenario: ", **scenario_markup) + self._tw.write(report.scenario["name"], **scenario_markup) + if self.verbosity == 1: - self.ensure_newline() - self._tw.write("Feature: ", **feature_markup) - self._tw.write(report.scenario["feature"]["name"], **feature_markup) - self._tw.write("\n") - self._tw.write(" Scenario: ", **scenario_markup) - self._tw.write(report.scenario["name"], **scenario_markup) self._tw.write(" ") self._tw.write(word, **word_markup) self._tw.write("\n") elif self.verbosity > 1: self.ensure_newline() - self._tw.write("Feature: ", **feature_markup) - self._tw.write(report.scenario["feature"]["name"], **feature_markup) - self._tw.write("\n") - self._tw.write(" Scenario: ", **scenario_markup) - self._tw.write(report.scenario["name"], **scenario_markup) self._tw.write("\n") for step in report.scenario["steps"]: self._tw.write(f" {step['keyword']} {step['name']}\n", **scenario_markup) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 533bb4ff1..4aac8b218 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -1,419 +1,501 @@ -from __future__ import annotations - -import os.path +import linecache import re import textwrap -import typing -from collections import OrderedDict from dataclasses import dataclass, field from functools import cached_property -from typing import cast +from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Union + +from gherkin.errors import CompositeParserException +from gherkin.parser import Parser +from gherkin.token_scanner import TokenScanner -from . import exceptions, types +from . import exceptions +from .types import STEP_TYPES -SPLIT_LINE_RE = re.compile(r"(?") COMMENT_RE = re.compile(r"(^|(?<=\s))#") -STEP_PREFIXES = [ - ("Feature: ", types.FEATURE), - ("Scenario Outline: ", types.SCENARIO_OUTLINE), - ("Examples:", types.EXAMPLES), - ("Scenario: ", types.SCENARIO), - ("Background:", types.BACKGROUND), - ("Given ", types.GIVEN), - ("When ", types.WHEN), - ("Then ", types.THEN), - ("@", types.TAG), - # Continuation of the previously mentioned step type - ("And ", None), - ("But ", None), -] -TYPES_WITH_DESCRIPTIONS = [types.FEATURE, types.SCENARIO, types.SCENARIO_OUTLINE] -if typing.TYPE_CHECKING: - from typing import Any, Iterable, Mapping, Match, Sequence +@dataclass(frozen=True) +class Location: + column: int + line: int + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Location": + return cls(column=data["column"], line=data["line"]) -def split_line(line: str) -> list[str]: - """Split the given Examples line. - :param str|unicode line: Feature file Examples line. +@dataclass(frozen=True) +class Comment: + location: Location + text: str - :return: List of strings. - """ - return [cell.replace("\\|", "|").strip() for cell in SPLIT_LINE_RE.split(line)[1:-1]] + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Comment": + return cls(location=Location.from_dict(data["location"]), text=data["text"]) -def parse_line(line: str) -> tuple[str, str]: - """Parse step line to get the step prefix (Scenario, Given, When, Then or And) and the actual step name. +@dataclass(frozen=True) +class Cell: + location: Location + value: str - :param line: Line of the Feature file. + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Cell": + return cls(location=Location.from_dict(data["location"]), value=_convert_to_raw_string(data["value"])) - :return: `tuple` in form ("", ""). - """ - for prefix, _ in STEP_PREFIXES: - if line.startswith(prefix): - return prefix.strip(), line[len(prefix) :].strip() - return "", line +@dataclass(frozen=True) +class Row: + id: str + location: Location + cells: List[Cell] -def strip_comments(line: str) -> str: - """Remove comments. + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Row": + return cls( + id=data["id"], + location=Location.from_dict(data["location"]), + cells=[Cell.from_dict(cell) for cell in data["cells"]], + ) - :param str line: Line of the Feature file. - :return: Stripped line. - """ - if res := COMMENT_RE.search(line): - line = line[: res.start()] - return line.strip() +@dataclass(frozen=True) +class DataTable: + location: Location + name: Optional[str] = None + tableHeader: Optional[Row] = None + tableBody: Optional[List[Row]] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "DataTable": + return cls( + location=Location.from_dict(data["location"]), + name=data.get("name"), + tableHeader=Row.from_dict(data["tableHeader"]) if data.get("tableHeader") else None, + tableBody=[Row.from_dict(row) for row in data.get("tableBody", [])], + ) + def as_contexts(self) -> Iterable[Dict[str, Any]]: + """ + Generate contexts for the examples. -def get_step_type(line: str) -> str | None: - """Detect step type by the beginning of the line. + Yields: + Dict[str, Any]: A dictionary mapping parameter names to their values for each example row. + """ + if not self.tableHeader or not self.tableBody: + return # If header or body is missing, there's nothing to yield + + # Extract parameter names from the tableHeader (row with headers) + example_params = [cell.value for cell in self.tableHeader.cells] + + for row in self.tableBody: + assert len(example_params) == len(row.cells), "Row length does not match header length" + # Map parameter names (from header) to values (from the row) + yield dict(zip(example_params, [cell.value for cell in row.cells])) + + +@dataclass(frozen=True) +class DocString: + content: str + delimiter: str + location: Location + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "DocString": + return cls( + content=textwrap.dedent(data["content"]), + delimiter=data["delimiter"], + location=Location.from_dict(data["location"]), + ) - :param str line: Line of the Feature file. - :return: SCENARIO, GIVEN, WHEN, THEN, or `None` if can't be detected. - """ - for prefix, _type in STEP_PREFIXES: - if line.startswith(prefix): - return _type - return None +@dataclass +class Step: + id: str + keyword: str + keywordType: str + location: Location + text: str + name: Optional[str] = None + raw_name: Optional[str] = None + dataTable: Optional[DataTable] = None + docString: Optional[DocString] = None + parent: Optional[Union["Background", "Scenario"]] = None + failed: bool = False + duration: Optional[float] = None + + def __post_init__(self): + def generate_initial_name(): + """Generate an initial name based on the step's text and optional docString.""" + self.name = _strip_comments(self.text) + if self.docString: + self.name = f"{self.name}\n{self.docString.content}" + # Populate a frozen copy of the name untouched by params later + self.raw_name = self.name + + generate_initial_name() + self.params = tuple(frozenset(STEP_PARAM_RE.findall(self.raw_name))) + + def get_parent_of_type(self, parent_type) -> Optional[Any]: + """Return the parent if it's of the specified type.""" + return self.parent if isinstance(self.parent, parent_type) else None + @property + def scenario(self) -> Optional["Scenario"]: + return self.get_parent_of_type(Scenario) -def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> Feature: - """Parse the feature file. + @property + def background(self) -> Optional["Background"]: + return self.get_parent_of_type(Background) - :param str basedir: Feature files base directory. - :param str filename: Relative path to the feature file. - :param str encoding: Feature file encoding (utf-8 by default). - """ - __tracebackhide__ = True - abs_filename = os.path.abspath(os.path.join(basedir, filename)) - rel_filename = os.path.join(os.path.basename(basedir), filename) - feature = Feature( - scenarios=OrderedDict(), - filename=abs_filename, - rel_filename=rel_filename, - line_number=1, - name=None, - tags=set(), - background=None, - description="", - ) - scenario: ScenarioTemplate | None = None - mode: str | None = None - prev_mode = None - description: list[str] = [] - step = None - multiline_step = False - prev_line = None + @property + def given_when_then(self) -> str: + return getattr(self, "_gwt", "") - with open(abs_filename, encoding=encoding) as f: - content = f.read() - - for line_number, line in enumerate(content.splitlines(), start=1): - unindented_line = line.lstrip() - line_indent = len(line) - len(unindented_line) - if step and (step.indent < line_indent or ((not unindented_line) and multiline_step)): - multiline_step = True - # multiline step, so just add line and continue - step.add_line(line) - continue - else: - step = None - multiline_step = False - stripped_line = line.strip() - clean_line = strip_comments(line) - if not clean_line and (not prev_mode or prev_mode not in TYPES_WITH_DESCRIPTIONS): - # Blank lines are included in feature and scenario descriptions - continue - mode = get_step_type(clean_line) or mode - - allowed_prev_mode = (types.BACKGROUND, types.GIVEN, types.WHEN) - - if not scenario and prev_mode not in allowed_prev_mode and mode in types.STEP_TYPES: - raise exceptions.FeatureError( - "Step definition outside of a Scenario or a Background", line_number, clean_line, filename - ) - - if mode == types.FEATURE: - if prev_mode is None or prev_mode == types.TAG: - _, feature.name = parse_line(clean_line) - feature.line_number = line_number - feature.tags = get_tags(prev_line) - elif prev_mode == types.FEATURE: - # Do not include comments in descriptions - if not stripped_line.startswith("#"): - description.append(clean_line) - else: - raise exceptions.FeatureError( - "Multiple features are not allowed in a single feature file", - line_number, - clean_line, - filename, - ) - - prev_mode = mode - - # Remove Feature, Given, When, Then, And - keyword, parsed_line = parse_line(clean_line) - - if mode in [types.SCENARIO, types.SCENARIO_OUTLINE]: - # Lines between the scenario declaration - # and the scenario's first step line - # are considered part of the scenario description. - if scenario and not keyword: - # Do not include comments in descriptions - if not stripped_line.startswith("#"): - scenario.add_description_line(clean_line) - continue - tags = get_tags(prev_line) - scenario = ScenarioTemplate( - feature=feature, - name=parsed_line, - line_number=line_number, - tags=tags, - templated=mode == types.SCENARIO_OUTLINE, - ) - feature.scenarios[parsed_line] = scenario - elif mode == types.BACKGROUND: - feature.background = Background(feature=feature, line_number=line_number) - elif mode == types.EXAMPLES: - mode = types.EXAMPLES_HEADERS - scenario.examples.line_number = line_number - elif mode == types.EXAMPLES_HEADERS: - scenario.examples.set_param_names([l for l in split_line(parsed_line) if l]) - mode = types.EXAMPLE_LINE - elif mode == types.EXAMPLE_LINE: - scenario.examples.add_example(list(split_line(stripped_line))) - elif mode and mode not in (types.FEATURE, types.TAG): - step = Step(name=parsed_line, type=mode, indent=line_indent, line_number=line_number, keyword=keyword) - if feature.background and not scenario: - feature.background.add_step(step) - else: - scenario = cast(ScenarioTemplate, scenario) - scenario.add_step(step) - prev_line = clean_line - - feature.description = "\n".join(description).strip() - return feature - - -@dataclass(eq=False) -class Feature: - scenarios: OrderedDict[str, ScenarioTemplate] - filename: str - rel_filename: str - name: str | None - tags: set[str] - background: Background | None - line_number: int - description: str + @given_when_then.setter + def given_when_then(self, gwt: str) -> None: + self._gwt = gwt + def __str__(self) -> str: + """Return a string representation of the step.""" + return f'{self.given_when_then.capitalize()} "{self.name}"' -@dataclass(eq=False) -class ScenarioTemplate: - """A scenario template. + def render(self, context: Mapping[str, Any]) -> None: + """Render the step name with the given context and update the instance. - Created when parsing the feature file, it will then be combined with the examples to create a Scenario. - """ + Args: + context (Mapping[str, Any]): The context for rendering the step name. + """ + _render_steps([self], context) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Step": + return cls( + id=data["id"], + keyword=str(data["keyword"]).capitalize().strip(), + keywordType=data["keywordType"], + location=Location.from_dict(data["location"]), + text=data["text"], + dataTable=DataTable.from_dict(data["dataTable"]) if data.get("dataTable") else None, + docString=DocString.from_dict(data["docString"]) if data.get("docString") else None, + ) - feature: Feature + +@dataclass(frozen=True) +class Tag: + id: str + location: Location name: str - line_number: int - templated: bool - tags: set[str] = field(default_factory=set) - examples: Examples | None = field(default_factory=lambda: Examples()) - _steps: list[Step] = field(init=False, default_factory=list) - _description_lines: list[str] = field(init=False, default_factory=list) - def add_step(self, step: Step) -> None: - step.scenario = self - self._steps.append(step) + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Tag": + return cls(id=data["id"], location=Location.from_dict(data["location"]), name=data["name"]) - @property - def steps(self) -> list[Step]: - background = self.feature.background - return (background.steps if background else []) + self._steps - - def render(self, context: Mapping[str, Any]) -> Scenario: - background_steps = self.feature.background.steps if self.feature.background else [] - if not self.templated: - scenario_steps = self._steps - else: - scenario_steps = [ - Step( - name=step.render(context), - type=step.type, - indent=step.indent, - line_number=step.line_number, - keyword=step.keyword, - ) - for step in self._steps - ] - steps = background_steps + scenario_steps - return Scenario( - feature=self.feature, - name=self.name, - line_number=self.line_number, - steps=steps, - tags=self.tags, - description=self._description_lines, - ) - def add_description_line(self, description_line): - """Add a description line to the scenario. - :param str description_line: +@dataclass +class Scenario: + id: str + keyword: str + location: Location + name: str + description: str + steps: List[Step] + tags: Set[Tag] + examples: Optional[List[DataTable]] = field(default_factory=list) + parent: Optional[Union["Feature", "Rule"]] = None + + def __post_init__(self): + self.steps = _compute_given_when_then(self.steps) + for step in self.steps: + step.parent = self + + @cached_property + def tag_names(self) -> Set[str]: + return _get_tag_names(self.tags) + + def render(self, context: Mapping[str, Any]) -> None: + """Render the scenario's steps with the given context. + + Args: + context (Mapping[str, Any]): The context for rendering steps. """ - self._description_lines.append(description_line) + _render_steps(self.steps, context) + + @cached_property + def feature(self): + return self.parent if _check_instance_by_name(self.parent, "Feature") else None + + @cached_property + def rule(self): + return self.parent if _check_instance_by_name(self.parent, "Rule") else None @property - def description(self): - """Get the scenario's description. - :return: The scenario description - """ - return "\n".join(self._description_lines) + def all_steps(self) -> List[Step]: + """Get all steps including background steps if present.""" + background_steps = self.feature.background_steps if self.feature else [] + return background_steps + self.steps + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Scenario": + if "id" not in data: + print("Hmm...") + return cls( + id=data["id"], + keyword=data["keyword"], + location=Location.from_dict(data["location"]), + name=data["name"], + description=textwrap.dedent(data["description"]), + steps=[Step.from_dict(step) for step in data["steps"]], + tags={Tag.from_dict(tag) for tag in data["tags"]}, + examples=[DataTable.from_dict(example) for example in data.get("examples", [])], + ) -@dataclass(eq=False) -class Scenario: - feature: Feature +@dataclass +class Rule: + id: str + keyword: str + location: Location name: str - line_number: int - steps: list[Step] - tags: set[str] = field(default_factory=set) - description: list[str] = field(default_factory=list) + description: str + tags: Set[Tag] + children: List["Child"] + parent: Optional["Feature"] = None + def __post_init__(self): + for scenario in self.children: + scenario.parent = self -@dataclass(eq=False) -class Step: - type: str - _name: str - line_number: int - indent: int + @cached_property + def tag_names(self) -> Set[str]: + return _get_tag_names(self.tags) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Rule": + return cls( + id=data["id"], + keyword=data["keyword"], + location=Location.from_dict(data["location"]), + name=data["name"], + description=textwrap.dedent(data["description"]), + tags={Tag.from_dict(tag) for tag in data["tags"]}, + children=[Child.from_dict(child) for child in data["children"]], + ) + + +@dataclass +class Background: + id: str keyword: str - failed: bool = field(init=False, default=False) - scenario: ScenarioTemplate | None = field(init=False, default=None) - background: Background | None = field(init=False, default=None) - lines: list[str] = field(init=False, default_factory=list) - - def __init__(self, name: str, type: str, indent: int, line_number: int, keyword: str) -> None: - self.name = name - self.type = type - self.indent = indent - self.line_number = line_number - self.keyword = keyword - - self.failed = False - self.scenario = None - self.background = None - self.lines = [] - - def add_line(self, line: str) -> None: - """Add line to the multiple step. - - :param str line: Line of text - the continuation of the step name. + location: Location + name: str + description: str + steps: List[Step] + parent: Optional["Feature"] = None + + def __post_init__(self): + self.steps = _compute_given_when_then(self.steps) + for step in self.steps: + step.parent = self + + def render(self, context: Mapping[str, Any]) -> None: + """Render the scenario's steps with the given context. + + Args: + context (Mapping[str, Any]): The context for rendering steps. """ - self.lines.append(line) - self._invalidate_full_name_cache() + _render_steps(self.steps, context) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Background": + return cls( + id=data["id"], + keyword=data["keyword"], + location=Location.from_dict(data["location"]), + name=data["name"], + description=textwrap.dedent(data["description"]), + steps=[Step.from_dict(step) for step in data["steps"]], + ) - @cached_property - def full_name(self) -> str: - multilines_content = textwrap.dedent("\n".join(self.lines)) if self.lines else "" - - # Remove the multiline quotes, if present. - multilines_content = re.sub( - pattern=r'^"""\n(?P.*)\n"""$', - repl=r"\g", - string=multilines_content, - flags=re.DOTALL, # Needed to make the "." match also new lines + +@dataclass +class Child: + background: Optional[Background] = None + rule: Optional[Rule] = None + scenario: Optional[Scenario] = None + parent: Optional[Union["Feature", "Rule"]] = None + + def __post_init__(self): + if self.scenario: + self.scenario.parent = self.parent + if self.background: + self.background.parent = self.parent + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Child": + return cls( + background=Background.from_dict(data["background"]) if data.get("background") else None, + rule=Rule.from_dict(data["rule"]) if data.get("rule") else None, + scenario=Scenario.from_dict(data["scenario"]) if data.get("scenario") else None, ) - lines = [self._name] + [multilines_content] - return "\n".join(lines).strip() - def _invalidate_full_name_cache(self) -> None: - """Invalidate the full_name cache.""" - if "full_name" in self.__dict__: - del self.full_name +@dataclass +class Feature: + keyword: str + location: Location + tags: Set[Tag] + name: str + description: str + children: List[Child] + abs_filename: Optional[str] = None + rel_filename: Optional[str] = None + + def __post_init__(self): + for child in self.children: + child.parent = self + if child.scenario: + child.scenario.parent = self + if child.background: + child.background.parent = self @property - def name(self) -> str: - return self.full_name - - @name.setter - def name(self, value: str) -> None: - self._name = value - self._invalidate_full_name_cache() + def scenarios(self) -> List[Scenario]: + return [child.scenario for child in self.children if child.scenario] - def __str__(self) -> str: - """Full step name including the type.""" - return f'{self.type.capitalize()} "{self.name}"' + @property + def backgrounds(self) -> List[Background]: + return [child.background for child in self.children if child.background] @property - def params(self) -> tuple[str, ...]: - return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) + def background_steps(self) -> List[Step]: + _steps = [] + backgrounds = self.backgrounds + for background in backgrounds: + _steps.extend(background.steps) + return _steps + + @cached_property + def rules(self) -> List[Rule]: + return [child.rule for child in self.children if child.rule] - def render(self, context: Mapping[str, Any]) -> str: - def replacer(m: Match): - varname = m.group(1) - return str(context[varname]) + def get_child_by_name(self, name: str) -> Optional[Union[Scenario, Background]]: + """ + Returns the child (Scenario or Background) that has the given name. + """ + for scenario in self.scenarios: + if scenario.name == name: + return scenario + for background in self.backgrounds: + if background.name == name: + return background + return None - return STEP_PARAM_RE.sub(replacer, self.name) + @cached_property + def tag_names(self) -> Set[str]: + return _get_tag_names(self.tags) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Feature": + return cls( + keyword=data["keyword"], + location=Location.from_dict(data["location"]), + tags={Tag.from_dict(tag) for tag in data["tags"]}, + name=data["name"], + description=textwrap.dedent(data["description"]), + children=[Child.from_dict(child) for child in data["children"]], + ) -@dataclass(eq=False) -class Background: +@dataclass +class GherkinDocument: feature: Feature - line_number: int - steps: list[Step] = field(init=False, default_factory=list) + comments: List[Comment] - def add_step(self, step: Step) -> None: - """Add step to the background.""" - step.background = self - self.steps.append(step) + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GherkinDocument": + return cls( + feature=Feature.from_dict(data["feature"]), + comments=[Comment.from_dict(comment) for comment in data["comments"]], + ) -@dataclass(eq=False) -class Examples: - """Example table.""" +def _compute_given_when_then(steps: List[Step]) -> List[Step]: + last_gwt = None + for step in steps: + lower_keyword = step.keyword.lower() + if lower_keyword in STEP_TYPES: + last_gwt = lower_keyword + step.given_when_then = last_gwt + return steps - line_number: int | None = field(default=None) - name: str | None = field(default=None) - example_params: list[str] = field(init=False, default_factory=list) - examples: list[Sequence[str]] = field(init=False, default_factory=list) +def get_gherkin_document(abs_filename: str, rel_filename: str, encoding: str = "utf-8") -> GherkinDocument: + with open(abs_filename, encoding=encoding) as f: + feature_file_text = f.read() - def set_param_names(self, keys: Iterable[str]) -> None: - self.example_params = [str(key) for key in keys] + try: + gherkin_data = Parser().parse(TokenScanner(feature_file_text)) + except CompositeParserException as e: + raise exceptions.FeatureError( + e.args[0], + e.errors[0].location["line"], + linecache.getline(abs_filename, e.errors[0].location["line"]).rstrip("\n"), + abs_filename, + ) from e - def add_example(self, values: Sequence[str]) -> None: - self.examples.append(values) + gherkin_doc = GherkinDocument.from_dict(gherkin_data) + gherkin_doc.feature.abs_filename = abs_filename + gherkin_doc.feature.rel_filename = rel_filename + return gherkin_doc - def as_contexts(self) -> Iterable[dict[str, Any]]: - if not self.examples: - return - header, rows = self.example_params, self.examples +def _check_instance_by_name(obj: Any, class_name: str) -> bool: + return obj.__class__.__name__ == class_name - for row in rows: - assert len(header) == len(row) - yield dict(zip(header, row)) - def __bool__(self) -> bool: - return bool(self.examples) +def _strip_comments(line: str) -> str: + """Remove comments from a line of text. + Args: + line (str): The line of text from which to remove comments. + + Returns: + str: The line of text without comments, with leading and trailing whitespace removed. + """ + if "#" not in line: + return line + if res := COMMENT_RE.search(line): + line = line[: res.start()] + return line.strip() + + +def _get_tag_names(tags: Set[Tag]): + return {tag.name.lstrip("@") for tag in tags} -def get_tags(line: str | None) -> set[str]: - """Get tags out of the given line. - :param str line: Feature file text line. +def _convert_to_raw_string(normal_string: str) -> str: + return normal_string.replace("\\", "\\\\") - :return: List of tags. + +def _render_steps(steps: List[Step], context: Mapping[str, Any]) -> None: + """ + Render multiple steps in batch by applying the context to each step's text. + + Args: + steps (List[Step]): The list of steps to render. + context (Mapping[str, Any]): The context to apply to the step names. """ - if not line or not line.strip().startswith("@"): - return set() - return {tag.lstrip("@") for tag in line.strip().split(" @") if len(tag) > 1} + # Create a map of parameter replacements for all steps at once + # This will store {param: replacement} for each variable found in steps + replacements = {param: context.get(param, f"<{param}>") for step in steps for param in step.params} + + # Precompute replacement function + def replacer(text: str) -> str: + return STEP_PARAM_RE.sub(lambda m: replacements.get(m.group(1), m.group(0)), text) + + # Apply the replacement in batch + for step in steps: + step.name = replacer(step.raw_name) diff --git a/src/pytest_bdd/reporting.py b/src/pytest_bdd/reporting.py index f0a3d0145..023858552 100644 --- a/src/pytest_bdd/reporting.py +++ b/src/pytest_bdd/reporting.py @@ -42,9 +42,9 @@ def serialize(self) -> dict[str, Any]: """ return { "name": self.step.name, - "type": self.step.type, + "type": self.step.given_when_then, "keyword": self.step.keyword, - "line_number": self.step.line_number, + "line_number": self.step.location.line, "failed": self.failed, "duration": self.duration, } @@ -73,12 +73,13 @@ def duration(self) -> float: class ScenarioReport: """Scenario execution report.""" - def __init__(self, scenario: Scenario) -> None: + def __init__(self, feature: Feature, scenario: Scenario) -> None: """Scenario report constructor. + :param pytest_bdd.parser.Feature feature: Feature. :param pytest_bdd.parser.Scenario scenario: Scenario. - :param node: pytest test node object """ + self.feature: Feature = feature self.scenario: Scenario = scenario self.step_reports: list[StepReport] = [] @@ -105,28 +106,28 @@ def serialize(self) -> dict[str, Any]: :return: Serialized report. :rtype: dict """ + feature = self.feature scenario = self.scenario - feature = scenario.feature - return { "steps": [step_report.serialize() for step_report in self.step_reports], "name": scenario.name, - "line_number": scenario.line_number, - "tags": sorted(scenario.tags), + "line_number": scenario.location.line, + "tags": sorted(scenario.tag_names), "feature": { "name": feature.name, - "filename": feature.filename, + "filename": feature.abs_filename, "rel_filename": feature.rel_filename, - "line_number": feature.line_number, + "line_number": feature.location.line, "description": feature.description, - "tags": sorted(feature.tags), + "tags": sorted(feature.tag_names), }, } def fail(self) -> None: """Stop collecting information and finalize the report as failed.""" self.current_step_report.finalize(failed=True) - remaining_steps = self.scenario.steps[len(self.step_reports) :] + steps = self.scenario.all_steps + remaining_steps = steps[len(self.step_reports) :] # Fail the rest of the steps and make reports. for step in remaining_steps: @@ -148,7 +149,7 @@ def runtest_makereport(item: Item, call: CallInfo, rep: TestReport) -> None: def before_scenario(request: FixtureRequest, feature: Feature, scenario: Scenario) -> None: """Create scenario report for the item.""" - request.node.__scenario_report__ = ScenarioReport(scenario=scenario) + request.node.__scenario_report__ = ScenarioReport(feature=feature, scenario=scenario) def step_error( diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 709288139..da6f91102 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -33,7 +33,7 @@ from _pytest.mark.structures import ParameterSet from _pytest.nodes import Node - from .parser import Feature, Scenario, ScenarioTemplate, Step + from .parser import Feature, Scenario, Step P = ParamSpec("P") T = TypeVar("T") @@ -55,7 +55,7 @@ def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, node: if step_func_context is None: continue - if step_func_context.type is not None and step_func_context.type != step.type: + if step_func_context.type is not None and step_func_context.type != step.given_when_then: continue match = step_func_context.parser.is_matching(step.name) @@ -90,7 +90,7 @@ def iterparentnodeids(nodeid: str) -> Iterator[str]: """ SEP = "/" pos = 0 - first_colons: Optional[int] = nodeid.find("::") + first_colons: int | None = nodeid.find("::") if first_colons == -1: first_colons = None # The root Session node - always present. @@ -159,27 +159,26 @@ def get_step_function(request, step: Step) -> StepFunctionContext | None: Then we let `patch_argumented_step_functions` find out what step definition fixtures can parse the current step, and it will inject them for the step fixture name. - Finally we let request.getfixturevalue(...) fetch the step definition fixture. + Finally, we let request.getfixturevalue(...) fetch the step definition fixture. """ __tracebackhide__ = True bdd_name = get_step_fixture_name(step=step) with inject_fixturedefs_for_step(step=step, fixturemanager=request._fixturemanager, node=request.node): - try: + with contextlib.suppress(pytest.FixtureLookupError): return cast(StepFunctionContext, request.getfixturevalue(bdd_name)) - except pytest.FixtureLookupError: - return None + return None def _execute_step_function( - request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext + request: FixtureRequest, gherkin_scenario: Scenario, step: Step, context: StepFunctionContext ) -> None: """Execute step function.""" __tracebackhide__ = True kw = { "request": request, - "feature": scenario.feature, - "scenario": scenario, + "feature": gherkin_scenario.feature, + "scenario": gherkin_scenario, "step": step, "step_func": context.step_func, "step_func_args": {}, @@ -218,37 +217,37 @@ def _execute_step_function( request.config.hook.pytest_bdd_after_step(**kw) -def _execute_scenario(feature: Feature, scenario: Scenario, request: FixtureRequest) -> None: +def _execute_scenario(feature: Feature, gherkin_scenario: Scenario, request: FixtureRequest) -> None: """Execute the scenario. - :param feature: Feature. - :param scenario: Scenario. + :param gherkin_scenario: Scenario. :param request: request. - :param encoding: Encoding. """ __tracebackhide__ = True - request.config.hook.pytest_bdd_before_scenario(request=request, feature=feature, scenario=scenario) + request.config.hook.pytest_bdd_before_scenario(request=request, feature=feature, scenario=gherkin_scenario) try: - for step in scenario.steps: + for step in gherkin_scenario.all_steps: step_func_context = get_step_function(request=request, step=step) if step_func_context is None: exc = exceptions.StepDefinitionNotFoundError( f"Step definition is not found: {step}. " - f'Line {step.line_number} in scenario "{scenario.name}" in the feature "{scenario.feature.filename}"' + f'Line {step.location.line} in scenario "{gherkin_scenario.name}" in the feature "{gherkin_scenario.parent.abs_filename}"' ) request.config.hook.pytest_bdd_step_func_lookup_error( - request=request, feature=feature, scenario=scenario, step=step, exception=exc + request=request, + feature=feature, + scenario=gherkin_scenario, + step=step, + exception=exc, ) raise exc - _execute_step_function(request, scenario, step, step_func_context) + _execute_step_function(request, gherkin_scenario, step, step_func_context) finally: - request.config.hook.pytest_bdd_after_scenario(request=request, feature=feature, scenario=scenario) + request.config.hook.pytest_bdd_after_scenario(request=request, feature=feature, scenario=gherkin_scenario) -def _get_scenario_decorator( - feature: Feature, feature_name: str, templated_scenario: ScenarioTemplate, scenario_name: str -) -> Callable[[Callable[P, T]], Callable[P, T]]: +def _get_scenario_decorator(feature: Feature, gherkin_scenario: Scenario) -> Callable[[Callable[P, T]], Callable[P, T]]: # HACK: Ideally we would use `def decorator(fn)`, but we want to return a custom exception # when the decorator is misused. # Pytest inspect the signature to determine the required fixtures, and in that case it would look @@ -268,12 +267,12 @@ def decorator(*args: Callable[P, T]) -> Callable[P, T]: @pytest.mark.usefixtures(*func_args) def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any: __tracebackhide__ = True - scenario = templated_scenario.render(_pytest_bdd_example) - _execute_scenario(feature, scenario, request) + gherkin_scenario.render(_pytest_bdd_example) + _execute_scenario(feature, gherkin_scenario, request) fixture_values = [request.getfixturevalue(arg) for arg in func_args] return fn(*fixture_values) - example_parametrizations = collect_example_parametrizations(templated_scenario) + example_parametrizations = collect_example_parametrizations(gherkin_scenario) if example_parametrizations is not None: # Parametrize the scenario outlines scenario_wrapper = pytest.mark.parametrize( @@ -281,21 +280,24 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str example_parametrizations, )(scenario_wrapper) - for tag in templated_scenario.tags.union(feature.tags): + for tag in gherkin_scenario.tag_names | feature.tag_names: config = CONFIG_STACK[-1] config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper) - scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}" - scenario_wrapper.__scenario__ = templated_scenario + scenario_wrapper.__doc__ = f"{feature.name}: {gherkin_scenario.name}" + scenario_wrapper.__scenario__ = gherkin_scenario return cast(Callable[P, T], scenario_wrapper) return decorator def collect_example_parametrizations( - templated_scenario: ScenarioTemplate, + gherkin_scenario: Scenario, ) -> list[ParameterSet] | None: - if contexts := list(templated_scenario.examples.as_contexts()): + examples = gherkin_scenario.examples + if not examples: + return None + if contexts := list(examples[0].as_contexts()): return [pytest.param(context, id="-".join(context.values())) for context in contexts] else: return None @@ -312,9 +314,9 @@ def scenario( :param str feature_name: Feature file name. Absolute or relative to the configured feature base path. :param str scenario_name: Scenario name. :param str encoding: Feature file encoding. + :param features_base_dir: Optional base dir location for locating feature files. If not set, it will try and resolve using property set in .ini file, then the caller_module_path. """ __tracebackhide__ = True - scenario_name = scenario_name caller_module_path = get_caller_module_path() # Get the feature @@ -323,17 +325,14 @@ def scenario( feature = get_feature(features_base_dir, feature_name, encoding=encoding) # Get the scenario - try: - scenario = feature.scenarios[scenario_name] - except KeyError: + gherkin_scenario = feature.get_child_by_name(scenario_name) + if gherkin_scenario is None: feature_name = feature.name or "[Empty]" raise exceptions.ScenarioNotFound( - f'Scenario "{scenario_name}" in feature "{feature_name}" in {feature.filename} is not found.' + f'Scenario "{scenario_name}" in feature "{feature_name}" in {feature.abs_filename} is not found.' ) - return _get_scenario_decorator( - feature=feature, feature_name=feature_name, templated_scenario=scenario, scenario_name=scenario_name - ) + return _get_scenario_decorator(feature=feature, gherkin_scenario=gherkin_scenario) def get_features_base_dir(caller_module_path: str) -> str: @@ -347,7 +346,7 @@ def get_features_base_dir(caller_module_path: str) -> str: def get_from_ini(key: str, default: str) -> str: """Get value from ini config. Return default if value has not been set. - Use if the default value is dynamic. Otherwise set default on addini call. + Use if the default value is dynamic. Otherwise, set default on addini call. """ config = CONFIG_STACK[-1] value = config.getini(key) @@ -407,23 +406,23 @@ def scenarios(*feature_paths: str, **kwargs: Any) -> None: found = False module_scenarios = frozenset( - (attr.__scenario__.feature.filename, attr.__scenario__.name) + (attr.__scenario__.feature.abs_filename, attr.__scenario__.name) for name, attr in caller_locals.items() if hasattr(attr, "__scenario__") ) for feature in get_features(abs_feature_paths): - for scenario_name, scenario_object in feature.scenarios.items(): + for gherkin_scenario in feature.scenarios: # skip already bound scenarios - if (scenario_object.feature.filename, scenario_name) not in module_scenarios: + if (feature.abs_filename, gherkin_scenario.name) not in module_scenarios: - @scenario(feature.filename, scenario_name, **kwargs) + @scenario(feature.abs_filename, gherkin_scenario.name, **kwargs) def _scenario() -> None: pass # pragma: no cover - for test_name in get_python_name_generator(scenario_name): + for test_name in get_python_name_generator(gherkin_scenario.name): if test_name not in caller_locals: - # found an unique test name + # found a unique test name caller_locals[test_name] = _scenario break found = True diff --git a/src/pytest_bdd/steps.py b/src/pytest_bdd/steps.py index 7642a6e84..a887744dc 100644 --- a/src/pytest_bdd/steps.py +++ b/src/pytest_bdd/steps.py @@ -43,10 +43,8 @@ def _(article): from typing import Any, Callable, Iterable, Literal, TypeVar import pytest -from _pytest.fixtures import FixtureRequest from typing_extensions import ParamSpec -from . import compat from .parser import Step from .parsers import StepParser, get_parser from .types import GIVEN, THEN, WHEN @@ -73,7 +71,7 @@ class StepFunctionContext: def get_step_fixture_name(step: Step) -> str: """Get step fixture name""" - return f"{StepNamePrefix.step_impl.value}_{step.type}_{step.name}" + return f"{StepNamePrefix.step_impl.value}_{step.given_when_then}_{step.name}" def given( @@ -151,7 +149,7 @@ def step( :return: Decorator function for the step. Example: - >>> @step("there is an wallet", target_fixture="wallet") + >>> @step("there is a wallet", target_fixture="wallet") >>> def _() -> dict[str, int]: >>> return {"eur": 0, "usd": 0} diff --git a/src/pytest_bdd/templates/test.py.mak b/src/pytest_bdd/templates/test.py.mak index 9f7901539..106f16988 100644 --- a/src/pytest_bdd/templates/test.py.mak +++ b/src/pytest_bdd/templates/test.py.mak @@ -18,7 +18,7 @@ def test_${ make_python_name(scenario.name)}(): % endfor % for step in steps: -@${step.type}(${ make_string_literal(step.name)}) +@${step.given_when_then}(${ make_string_literal(step.name)}) def _(): ${make_python_docstring(step.name)} raise NotImplementedError diff --git a/src/pytest_bdd/types.py b/src/pytest_bdd/types.py index 8faf940a4..66f20df68 100644 --- a/src/pytest_bdd/types.py +++ b/src/pytest_bdd/types.py @@ -2,16 +2,8 @@ from __future__ import annotations -FEATURE = "feature" -SCENARIO_OUTLINE = "scenario outline" -EXAMPLES = "examples" -EXAMPLES_HEADERS = "example headers" -EXAMPLE_LINE = "example line" -SCENARIO = "scenario" -BACKGROUND = "background" GIVEN = "given" WHEN = "when" THEN = "then" -TAG = "tag" STEP_TYPES = (GIVEN, WHEN, THEN) diff --git a/tests/feature/test_background.py b/tests/feature/test_background.py index be0490e83..4f7fc0c86 100644 --- a/tests/feature/test_background.py +++ b/tests/feature/test_background.py @@ -2,14 +2,16 @@ import textwrap -FEATURE = """\ +FEATURE = '''\ Feature: Background support Background: Given foo has a value "bar" And a background step with multiple lines: + """ one two + """ Scenario: Basic usage @@ -21,7 +23,7 @@ Then foo should have value "dummy" And foo should not have value "bar" -""" +''' STEPS = r"""\ import re diff --git a/tests/feature/test_multiline.py b/tests/feature/test_multiline.py index 2d531b5d5..ff407e487 100644 --- a/tests/feature/test_multiline.py +++ b/tests/feature/test_multiline.py @@ -24,52 +24,7 @@ ''' ), "Some\n\nExtra\nLines", - ), - ( - textwrap.dedent( - """\ - Feature: Multiline - Scenario: Multiline step using sub indentation - Given I have a step with: - Some - - Extra - Lines - Then the text should be parsed with correct indentation - """ - ), - "Some\n\nExtra\nLines", - ), - ( - textwrap.dedent( - """\ - Feature: Multiline - Scenario: Multiline step using sub indentation - Given I have a step with: - Some - - Extra - Lines - - Then the text should be parsed with correct indentation - """ - ), - " Some\n\n Extra\nLines", - ), - ( - textwrap.dedent( - """\ - Feature: Multiline - Scenario: Multiline step using sub indentation - Given I have a step with: - Some - Extra - Lines - - """ - ), - "Some\nExtra\nLines", - ), + ) ], ) def test_multiline(pytester, feature_text, expected_text): @@ -104,52 +59,3 @@ def _(text): ) result = pytester.runpytest() result.assert_outcomes(passed=1) - - -def test_multiline_wrong_indent(pytester): - """Multiline step using sub indentation wrong indent.""" - - pytester.makefile( - ".feature", - multiline=textwrap.dedent( - """\ - - Feature: Multiline - Scenario: Multiline step using sub indentation wrong indent - Given I have a step with: - Some - - Extra - Lines - Then the text should be parsed with correct indentation - - """ - ), - ) - - pytester.makepyfile( - textwrap.dedent( - """\ - from pytest_bdd import parsers, given, then, scenario - - - @scenario("multiline.feature", "Multiline step using sub indentation wrong indent") - def test_multiline(request): - pass - - - @given(parsers.parse("I have a step with:\\n{{text}}"), target_fixture="text") - def _(text): - return text - - - @then("the text should be parsed with correct indentation") - def _(text): - assert text == expected_text - - """ - ) - ) - result = pytester.runpytest() - result.assert_outcomes(failed=1) - result.stdout.fnmatch_lines("*StepDefinitionNotFoundError: Step definition is not found:*") diff --git a/tests/feature/test_no_scenario.py b/tests/feature/test_no_scenario.py index f3bcd7d3c..5eb68e11c 100644 --- a/tests/feature/test_no_scenario.py +++ b/tests/feature/test_no_scenario.py @@ -27,4 +27,4 @@ def test_no_scenarios(pytester): ) ) result = pytester.runpytest() - result.stdout.fnmatch_lines(["*FeatureError: Step definition outside of a Scenario or a Background.*"]) + result.stdout.fnmatch_lines(["*FeatureError*"]) diff --git a/tests/feature/test_no_sctrict_gherkin.py b/tests/feature/test_no_strict_gherkin.py similarity index 100% rename from tests/feature/test_no_sctrict_gherkin.py rename to tests/feature/test_no_strict_gherkin.py diff --git a/tests/feature/test_outline.py b/tests/feature/test_outline.py index c8bfe9c48..db591266f 100644 --- a/tests/feature/test_outline.py +++ b/tests/feature/test_outline.py @@ -171,7 +171,7 @@ def test_outline_with_escaped_pipes(pytester): pytester.makefile( ".feature", outline=textwrap.dedent( - r"""\ + r""" Feature: Outline With Special characters Scenario Outline: Outline with escaped pipe character diff --git a/tests/feature/test_scenario.py b/tests/feature/test_scenario.py index f494d8cef..5bc7bb061 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -28,7 +28,7 @@ def test_not_found(): """ ) ) - result = pytester.runpytest_subprocess(*pytest_params) + result = pytester.runpytest_inprocess(*pytest_params) result.assert_outcomes(errors=1) result.stdout.fnmatch_lines('*Scenario "NOT FOUND" in feature "Scenario is not found" in*') @@ -111,7 +111,7 @@ def test_scenario_not_decorator(pytester, pytest_params): """ ) - result = pytester.runpytest_subprocess(*pytest_params) + result = pytester.runpytest_inprocess(*pytest_params) result.assert_outcomes(failed=1) result.stdout.fnmatch_lines("*ScenarioIsDecoratorOnly: scenario function can only be used as a decorator*") @@ -144,11 +144,11 @@ def _(): pass """ ) - result = pytester.runpytest_subprocess(*pytest_params) + result = pytester.runpytest_inprocess(*pytest_params) result.assert_outcomes(passed=1) -def test_angular_brakets_are_not_parsed(pytester): +def test_angular_brackets_are_not_parsed(pytester): """Test that angular brackets are not parsed for "Scenario"s. (They should be parsed only when used in "Scenario Outline") diff --git a/tests/feature/test_scenarios.py b/tests/feature/test_scenarios.py index ccfcf14a2..ff7d66b23 100644 --- a/tests/feature/test_scenarios.py +++ b/tests/feature/test_scenarios.py @@ -26,6 +26,7 @@ def _(): features.joinpath("test.feature").write_text( textwrap.dedent( """ +Feature: Test scenarios Scenario: Test scenario Given I have a bar """ @@ -37,6 +38,7 @@ def _(): subfolder.joinpath("test.feature").write_text( textwrap.dedent( """ +Feature: Test scenarios Scenario: Test subfolder scenario Given I have a bar @@ -64,7 +66,7 @@ def test_already_bound(): scenarios('features') """ ) - result = pytester.runpytest_subprocess("-v", "-s", *pytest_params) + result = pytester.runpytest_inprocess("-v", "-s", *pytest_params) result.assert_outcomes(passed=4, failed=1) result.stdout.fnmatch_lines(["*collected 5 items"]) result.stdout.fnmatch_lines(["*test_test_subfolder_scenario *bar!", "PASSED"]) @@ -84,6 +86,6 @@ def test_scenarios_none_found(pytester, pytest_params): scenarios('.') """ ) - result = pytester.runpytest_subprocess(testpath, *pytest_params) + result = pytester.runpytest_inprocess(testpath, *pytest_params) result.assert_outcomes(errors=1) result.stdout.fnmatch_lines(["*NoScenariosFound*"]) diff --git a/tests/feature/test_steps.py b/tests/feature/test_steps.py index 30b731c0a..56af6b154 100644 --- a/tests/feature/test_steps.py +++ b/tests/feature/test_steps.py @@ -354,13 +354,14 @@ def test_step_hooks(pytester): pytester.makefile( ".feature", test=""" +Feature: StepHandler hooks Scenario: When step has hook on failure Given I have a bar When it fails Scenario: When step's dependency a has failure Given I have a bar - When it's dependency fails + When its dependency fails Scenario: When step is not found Given not found @@ -391,7 +392,7 @@ def _(): def dependency(): raise Exception('dependency fails') - @when("it's dependency fails") + @when("its dependency fails") def _(dependency): pass @@ -471,16 +472,21 @@ def test_step_trace(pytester): pytester.makefile( ".feature", test=""" - Scenario: When step has failure - Given I have a bar - When it fails + Feature: StepHandler hooks + Scenario: When step has hook on failure + Given I have a bar + When it fails - Scenario: When step is not found - Given not found + Scenario: When step's dependency a has failure + Given I have a bar + When its dependency fails - Scenario: When step validation error happens - Given foo - And foo + Scenario: When step is not found + Given not found + + Scenario: When step validation error happens + Given foo + And foo """, ) pytester.makepyfile( @@ -496,12 +502,20 @@ def _(): def _(): raise Exception('when fails') - @scenario('test.feature', 'When step has failure') - def test_when_fails_inline(): + @pytest.fixture + def dependency(): + raise Exception('dependency fails') + + @when("its dependency fails") + def when_dependency_fails(dependency): pass - @scenario('test.feature', 'When step has failure') - def test_when_fails_decorated(): + @scenario('test.feature', "When step's dependency a has failure") + def test_when_dependency_fails(): + pass + + @scenario('test.feature', 'When step has hook on failure') + def test_when_fails(): pass @scenario('test.feature', 'When step is not found') @@ -517,25 +531,47 @@ def test_when_step_validation_error(): pass """ ) - result = pytester.runpytest("-k test_when_fails_inline", "-vv") - result.assert_outcomes(failed=1) - result.stdout.fnmatch_lines(["*test_when_fails_inline*FAILED"]) - assert "INTERNALERROR" not in result.stdout.str() - - result = pytester.runpytest("-k test_when_fails_decorated", "-vv") - result.assert_outcomes(failed=1) - result.stdout.fnmatch_lines(["*test_when_fails_decorated*FAILED"]) - assert "INTERNALERROR" not in result.stdout.str() - - result = pytester.runpytest("-k test_when_not_found", "-vv") - result.assert_outcomes(failed=1) - result.stdout.fnmatch_lines(["*test_when_not_found*FAILED"]) - assert "INTERNALERROR" not in result.stdout.str() - - result = pytester.runpytest("-k test_when_step_validation_error", "-vv") - result.assert_outcomes(failed=1) - result.stdout.fnmatch_lines(["*test_when_step_validation_error*FAILED"]) - assert "INTERNALERROR" not in result.stdout.str() + reprec = pytester.inline_run("-k test_when_fails") + reprec.assertoutcome(failed=1) + + calls = reprec.getcalls("pytest_bdd_before_scenario") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_after_scenario") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_before_step") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_before_step_call") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_after_step") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_step_error") + assert calls[0].request + + reprec = pytester.inline_run("-k test_when_not_found") + reprec.assertoutcome(failed=1) + + calls = reprec.getcalls("pytest_bdd_step_func_lookup_error") + assert calls[0].request + + reprec = pytester.inline_run("-k test_when_step_validation_error") + reprec.assertoutcome(failed=1) + + reprec = pytester.inline_run("-k test_when_dependency_fails", "-vv") + reprec.assertoutcome(failed=1) + + calls = reprec.getcalls("pytest_bdd_before_step") + assert len(calls) == 2 + + calls = reprec.getcalls("pytest_bdd_before_step_call") + assert len(calls) == 1 + + calls = reprec.getcalls("pytest_bdd_step_error") + assert calls[0].request def test_steps_with_yield(pytester): diff --git a/tests/feature/test_tags.py b/tests/feature/test_tags.py index f1dea8035..1ec56d79c 100644 --- a/tests/feature/test_tags.py +++ b/tests/feature/test_tags.py @@ -4,8 +4,6 @@ import pytest -from pytest_bdd.parser import get_tags - def test_tags_selector(pytester): """Test tests selection by tags.""" @@ -162,51 +160,6 @@ def _(): result.stdout.fnmatch_lines(["*= 1 skipped, 1 xpassed * =*"]) -def test_tag_with_spaces(pytester): - pytester.makefile( - ".ini", - pytest=textwrap.dedent( - """ - [pytest] - markers = - test with spaces - """ - ), - ) - pytester.makeconftest( - """ - import pytest - - @pytest.hookimpl(tryfirst=True) - def pytest_bdd_apply_tag(tag, function): - assert tag == 'test with spaces' - """ - ) - pytester.makefile( - ".feature", - test=""" - Feature: Tag with spaces - - @test with spaces - Scenario: Tags - Given I have a bar - """, - ) - pytester.makepyfile( - """ - from pytest_bdd import given, scenarios - - @given('I have a bar') - def _(): - return 'bar' - - scenarios('test.feature') - """ - ) - result = pytester.runpytest_subprocess() - result.stdout.fnmatch_lines(["*= 1 passed * =*"]) - - def test_at_in_scenario(pytester): pytester.makefile( ".feature", @@ -236,21 +189,5 @@ def _(): """ ) strict_option = "--strict-markers" - result = pytester.runpytest_subprocess(strict_option) + result = pytester.runpytest_inprocess(strict_option) result.stdout.fnmatch_lines(["*= 2 passed * =*"]) - - -@pytest.mark.parametrize( - "line, expected", - [ - ("@foo @bar", {"foo", "bar"}), - ("@with spaces @bar", {"with spaces", "bar"}), - ("@double @double", {"double"}), - (" @indented", {"indented"}), - (None, set()), - ("foobar", set()), - ("", set()), - ], -) -def test_get_tags(line, expected): - assert get_tags(line) == expected diff --git a/tests/feature/test_wrong.py b/tests/feature/test_wrong.py index f8c405439..002cd671c 100644 --- a/tests/feature/test_wrong.py +++ b/tests/feature/test_wrong.py @@ -50,4 +50,4 @@ def test_wrong(): ) result = pytester.runpytest() result.assert_outcomes(errors=1) - result.stdout.fnmatch_lines("*FeatureError: Multiple features are not allowed in a single feature file.*") + result.stdout.fnmatch_lines("*FeatureError: *") diff --git a/tests/generation/test_generate_missing.py b/tests/generation/test_generate_missing.py index d6be9be6f..4d02e0f4c 100644 --- a/tests/generation/test_generate_missing.py +++ b/tests/generation/test_generate_missing.py @@ -29,11 +29,9 @@ def test_generate_missing(pytester): Scenario: Scenario tests which are already bound to the tests stay as is Given I have a bar - Scenario: Code is generated for scenarios which are not bound to any tests Given I have a bar - Scenario: Code is generated for scenario steps which are not yet defined(implemented) Given I have a custom bar """ @@ -80,10 +78,6 @@ def test_missing_steps(): ] ) - result.stdout.fnmatch_lines( - ['Step Given "I have a foobar" is not defined in the background of the feature "Missing code generation" *'] - ) - result.stdout.fnmatch_lines(["Please place the code above to the test file(s):"]) diff --git a/tests/steps/test_common.py b/tests/steps/test_common.py index 7108aaab5..535f785aa 100644 --- a/tests/steps/test_common.py +++ b/tests/steps/test_common.py @@ -4,7 +4,7 @@ import pytest -from pytest_bdd import given, parser, parsers, then, when +from pytest_bdd import given, parsers, then, when from pytest_bdd.utils import collect_dumped_objects @@ -316,25 +316,3 @@ def _(n): objects = collect_dumped_objects(result) assert objects == ["foo", ("foo parametrized", 1), "foo", ("foo parametrized", 2), "foo", ("foo parametrized", 3)] - - -def test_step_name_is_cached(): - """Test that the step name is cached and not re-computed eache time.""" - step = parser.Step(name="step name", type="given", indent=8, line_number=3, keyword="Given") - assert step.name == "step name" - - # manipulate the step name directly and validate the cache value is still returned - step._name = "incorrect step name" - assert step.name == "step name" - - # change the step name using the property and validate the cache has been invalidated - step.name = "new step name" - assert step.name == "new step name" - - # manipulate the step lines and validate the cache value is still returned - step.lines.append("step line 1") - assert step.name == "new step name" - - # add a step line and validate the cache has been invalidated - step.add_line("step line 2") - assert step.name == "new step name\nstep line 1\nstep line 2"