From 9efc7817b690366a3590b6f7a785bd2a362209e4 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Fri, 3 Jul 2026 07:53:46 +0200 Subject: [PATCH 1/5] Use a match statement to dispatch on gherkin Child fields A gherkin Child has exactly one of background/rule/scenario set; class patterns make that dispatch explicit and replace the truthiness checks with type matching. --- src/pytest_bdd/parser.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index fdc86c9a..78af534e 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -10,7 +10,7 @@ from .exceptions import StepError from .gherkin_parser import Background as GherkinBackground -from .gherkin_parser import DataTable, GherkinDocument, get_gherkin_document +from .gherkin_parser import Child, DataTable, GherkinDocument, get_gherkin_document from .gherkin_parser import Feature as GherkinFeature from .gherkin_parser import Rule as GherkinRule from .gherkin_parser import Scenario as GherkinScenario @@ -492,12 +492,13 @@ def parse(self) -> Feature: ) for child in feature_data.children: - if child.background: - feature.background = self.parse_background(child.background) - elif child.rule: - self._parse_and_add_rule(child.rule, feature) - elif child.scenario: - self._parse_and_add_scenario(child.scenario, feature) + match child: + case Child(background=GherkinBackground() as background): + feature.background = self.parse_background(background) + case Child(rule=GherkinRule() as rule): + self._parse_and_add_rule(rule, feature) + case Child(scenario=GherkinScenario() as scenario): + self._parse_and_add_scenario(scenario, feature) return feature From 697ca4b3387d57fa2b3128a798598bb82d5a34b5 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Fri, 3 Jul 2026 09:05:36 +0200 Subject: [PATCH 2/5] Cover the no-match arm of the gherkin Child dispatch A Child with none of background/rule/scenario set (a child kind a future gherkin version could introduce) must be skipped by FeatureParser.parse. No real feature file can produce one today, so the test appends a synthetic empty Child to the parsed document. --- tests/parser/test_parser.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/parser/test_parser.py b/tests/parser/test_parser.py index 09be7c85..5d51a25b 100644 --- a/tests/parser/test_parser.py +++ b/tests/parser/test_parser.py @@ -1,7 +1,9 @@ from __future__ import annotations +import textwrap from pathlib import Path +from src.pytest_bdd import parser as parser_module from src.pytest_bdd.gherkin_parser import ( Background, Cell, @@ -851,3 +853,35 @@ def test_parser(): ) assert gherkin_doc == expected_document + + +def test_feature_parser_skips_unknown_child_kinds(tmp_path, monkeypatch): + """A Child with none of background/rule/scenario set is skipped. + + The gherkin AST reserves the right to grow new child kinds; ``Child.from_dict`` + would map such a child to an all-``None`` dataclass, and ``FeatureParser.parse`` + must ignore it instead of crashing. + """ + (tmp_path / "minimal.feature").write_text( + textwrap.dedent( + """\ + Feature: Minimal + Scenario: A scenario + Given a step + """ + ) + ) + + real_get_gherkin_document = parser_module.get_gherkin_document + + def get_gherkin_document_with_unknown_child(abs_filename: str, encoding: str = "utf-8") -> GherkinDocument: + document = real_get_gherkin_document(abs_filename, encoding) + document.feature.children.append(Child()) + return document + + monkeypatch.setattr(parser_module, "get_gherkin_document", get_gherkin_document_with_unknown_child) + + feature = parser_module.FeatureParser(str(tmp_path), "minimal.feature").parse() + + assert list(feature.scenarios) == ["A scenario"] + assert feature.background is None From 064621b63c69425b4ed19053c416bf3a9d0dafba Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Sun, 5 Jul 2026 23:00:25 +0200 Subject: [PATCH 3/5] Enforce Child dispatch exhaustiveness with assert_never, drop the mock test assert_never on the Child dataclass itself does not typecheck: mypy cannot prove exhaustiveness from attribute patterns on a class with three optional fields, so the fall-through arm keeps the type "Child" instead of "Never". Matching on `child.background or child.rule or child.scenario` gives mypy a proper union (Background | Rule | Scenario | None) to exhaust; removing any arm is now a mypy error. Both defensive arms (empty Child, assert_never) are unreachable with the current gherkin version, so they are excluded from coverage instead of being exercised through a monkeypatched document, and the mock-based test is removed. "pragma: no cover" is re-added to coverage's exclude_lines: setting exclude_lines in the config replaces coverage.py's defaults, so the pragma had silently stopped working. --- pyproject.toml | 1 + src/pytest_bdd/parser.py | 18 +++++++++++++----- tests/parser/test_parser.py | 34 ---------------------------------- 3 files changed, 14 insertions(+), 39 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dcd1a009..c52cec0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,7 @@ lint.isort.required-imports = [ [tool.coverage.report] exclude_lines = [ + "pragma: no cover", "if TYPE_CHECKING:", "if typing\\.TYPE_CHECKING:", ] diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 78af534e..2afe51e6 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -8,9 +8,11 @@ from collections.abc import Generator, Iterable, Mapping, Sequence from dataclasses import dataclass, field +from typing_extensions import assert_never + from .exceptions import StepError from .gherkin_parser import Background as GherkinBackground -from .gherkin_parser import Child, DataTable, GherkinDocument, get_gherkin_document +from .gherkin_parser import DataTable, GherkinDocument, get_gherkin_document from .gherkin_parser import Feature as GherkinFeature from .gherkin_parser import Rule as GherkinRule from .gherkin_parser import Scenario as GherkinScenario @@ -492,13 +494,19 @@ def parse(self) -> Feature: ) for child in feature_data.children: - match child: - case Child(background=GherkinBackground() as background): + match child.background or child.rule or child.scenario: + case GherkinBackground() as background: feature.background = self.parse_background(background) - case Child(rule=GherkinRule() as rule): + case GherkinRule() as rule: self._parse_and_add_rule(rule, feature) - case Child(scenario=GherkinScenario() as scenario): + case GherkinScenario() as scenario: self._parse_and_add_scenario(scenario, feature) + case None: # pragma: no cover + # An empty Child (a child kind newer than this gherkin version); + # skip it, like unknown children were skipped before the match. + pass + case unreachable: # pragma: no cover + assert_never(unreachable) return feature diff --git a/tests/parser/test_parser.py b/tests/parser/test_parser.py index 5d51a25b..09be7c85 100644 --- a/tests/parser/test_parser.py +++ b/tests/parser/test_parser.py @@ -1,9 +1,7 @@ from __future__ import annotations -import textwrap from pathlib import Path -from src.pytest_bdd import parser as parser_module from src.pytest_bdd.gherkin_parser import ( Background, Cell, @@ -853,35 +851,3 @@ def test_parser(): ) assert gherkin_doc == expected_document - - -def test_feature_parser_skips_unknown_child_kinds(tmp_path, monkeypatch): - """A Child with none of background/rule/scenario set is skipped. - - The gherkin AST reserves the right to grow new child kinds; ``Child.from_dict`` - would map such a child to an all-``None`` dataclass, and ``FeatureParser.parse`` - must ignore it instead of crashing. - """ - (tmp_path / "minimal.feature").write_text( - textwrap.dedent( - """\ - Feature: Minimal - Scenario: A scenario - Given a step - """ - ) - ) - - real_get_gherkin_document = parser_module.get_gherkin_document - - def get_gherkin_document_with_unknown_child(abs_filename: str, encoding: str = "utf-8") -> GherkinDocument: - document = real_get_gherkin_document(abs_filename, encoding) - document.feature.children.append(Child()) - return document - - monkeypatch.setattr(parser_module, "get_gherkin_document", get_gherkin_document_with_unknown_child) - - feature = parser_module.FeatureParser(str(tmp_path), "minimal.feature").parse() - - assert list(feature.scenarios) == ["A scenario"] - assert feature.background is None From b837fc9be7e82c3c16be3d79c113a7516a75f529 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Mon, 6 Jul 2026 09:40:16 +0200 Subject: [PATCH 4/5] Warn and skip unknown gherkin child kinds instead of asserting Per review feedback, replace the assert_never fall-through (which would crash on a gherkin child kind newer than this version) with a catch-all case that emits a UserWarning and skips the child. pytest-bdd keeps working against future gherkin releases; the warning surfaces the gap without breaking CI. The arm is now reachable and exercised by a test, so the pragma: no cover markers are dropped. --- src/pytest_bdd/parser.py | 17 +++++++++-------- tests/parser/test_parser.py | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index 2afe51e6..9f269e51 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -4,12 +4,11 @@ import os.path import re import textwrap +import warnings from collections import OrderedDict from collections.abc import Generator, Iterable, Mapping, Sequence from dataclasses import dataclass, field -from typing_extensions import assert_never - from .exceptions import StepError from .gherkin_parser import Background as GherkinBackground from .gherkin_parser import DataTable, GherkinDocument, get_gherkin_document @@ -501,12 +500,14 @@ def parse(self) -> Feature: self._parse_and_add_rule(rule, feature) case GherkinScenario() as scenario: self._parse_and_add_scenario(scenario, feature) - case None: # pragma: no cover - # An empty Child (a child kind newer than this gherkin version); - # skip it, like unknown children were skipped before the match. - pass - case unreachable: # pragma: no cover - assert_never(unreachable) + case _: + # An empty Child, or a child kind newer than this gherkin version; + # skip it and warn instead of crashing, to keep working against + # future gherkin releases that add new child types. + warnings.warn( + f"Unknown gherkin child skipped in {self.rel_filename!r}; consider upgrading pytest-bdd.", + stacklevel=2, + ) return feature diff --git a/tests/parser/test_parser.py b/tests/parser/test_parser.py index 09be7c85..e848b5d0 100644 --- a/tests/parser/test_parser.py +++ b/tests/parser/test_parser.py @@ -1,7 +1,11 @@ from __future__ import annotations +import textwrap from pathlib import Path +import pytest + +from src.pytest_bdd import parser as parser_module from src.pytest_bdd.gherkin_parser import ( Background, Cell, @@ -851,3 +855,37 @@ def test_parser(): ) assert gherkin_doc == expected_document + + +def test_feature_parser_warns_on_unknown_child_kinds(tmp_path, monkeypatch): + """A Child with none of background/rule/scenario set is skipped with a warning. + + The gherkin AST reserves the right to grow new child kinds; ``Child.from_dict`` + would map such a child to an all-``None`` dataclass, and ``FeatureParser.parse`` + must warn and skip it instead of crashing, so pytest-bdd keeps working against + future gherkin releases. + """ + (tmp_path / "minimal.feature").write_text( + textwrap.dedent( + """\ + Feature: Minimal + Scenario: A scenario + Given a step + """ + ) + ) + + real_get_gherkin_document = parser_module.get_gherkin_document + + def get_gherkin_document_with_unknown_child(abs_filename: str, encoding: str = "utf-8") -> GherkinDocument: + document = real_get_gherkin_document(abs_filename, encoding) + document.feature.children.append(Child()) + return document + + monkeypatch.setattr(parser_module, "get_gherkin_document", get_gherkin_document_with_unknown_child) + + with pytest.warns(UserWarning, match="Unknown gherkin child"): + feature = parser_module.FeatureParser(str(tmp_path), "minimal.feature").parse() + + assert list(feature.scenarios) == ["A scenario"] + assert feature.background is None From f85e5518d41f8eb1954be91650a5353980a70a2b Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Mon, 6 Jul 2026 18:08:07 +0200 Subject: [PATCH 5/5] Use patch.object context manager instead of monkeypatch in warn test --- tests/parser/test_parser.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/parser/test_parser.py b/tests/parser/test_parser.py index e848b5d0..0de24430 100644 --- a/tests/parser/test_parser.py +++ b/tests/parser/test_parser.py @@ -2,6 +2,7 @@ import textwrap from pathlib import Path +from unittest.mock import patch import pytest @@ -857,7 +858,7 @@ def test_parser(): assert gherkin_doc == expected_document -def test_feature_parser_warns_on_unknown_child_kinds(tmp_path, monkeypatch): +def test_feature_parser_warns_on_unknown_child_kinds(tmp_path): """A Child with none of background/rule/scenario set is skipped with a warning. The gherkin AST reserves the right to grow new child kinds; ``Child.from_dict`` @@ -882,10 +883,9 @@ def get_gherkin_document_with_unknown_child(abs_filename: str, encoding: str = " document.feature.children.append(Child()) return document - monkeypatch.setattr(parser_module, "get_gherkin_document", get_gherkin_document_with_unknown_child) - - with pytest.warns(UserWarning, match="Unknown gherkin child"): - feature = parser_module.FeatureParser(str(tmp_path), "minimal.feature").parse() + with patch.object(parser_module, "get_gherkin_document", get_gherkin_document_with_unknown_child): + with pytest.warns(UserWarning, match="Unknown gherkin child"): + feature = parser_module.FeatureParser(str(tmp_path), "minimal.feature").parse() assert list(feature.scenarios) == ["A scenario"] assert feature.background is None