Skip to content

Commit 9bb4967

Browse files
authored
Merge pull request #658 from pytest-dev/ab/fix-typing
Fix typing
2 parents 6cdd340 + f4413e5 commit 9bb4967

14 files changed

+333
-160
lines changed

CHANGES.rst

+7-1
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,15 @@ Deprecated
2020

2121
Removed
2222
+++++++
23+
* The following private attributes are not available anymore (`#658 <https://github.com/pytest-dev/pytest-bdd/pull/658>`_):
24+
* ``_pytest.reports.TestReport.scenario``; replaced by ``pytest_bdd.reporting.test_report_context`` WeakKeyDictionary (internal use)
25+
* ``__scenario__`` attribute of test functions generated by the ``@scenario`` (and ``@scenarios``) decorator; replaced by ``pytest_bdd.scenario.scenario_wrapper_template_registry`` WeakKeyDictionary (internal use)
26+
* ``_pytest.nodes.Item.__scenario_report__``; replaced by ``pytest_bdd.reporting.scenario_reports_registry`` WeakKeyDictionary (internal use)
27+
* ``_pytest_bdd_step_context`` attribute of internal test function markers; replaced by ``pytest_bdd.steps.step_function_context_registry`` WeakKeyDictionary (internal use)
2328

2429
Fixed
2530
+++++
31+
* Made type annotations stronger and removed most of the ``typing.Any`` usages and ``# type: ignore`` annotations. `#658 <https://github.com/pytest-dev/pytest-bdd/pull/658>`_
2632

2733
Security
2834
++++++++
@@ -137,7 +143,7 @@ Fixed
137143

138144
7.0.1
139145
-----
140-
- Fix errors occurring if `pytest_unconfigure` is called before `pytest_configure`. `#362 <https://github.com/pytest-dev/pytest-bdd/issues/362>`_ `#641 <https://github.com/pytest-dev/pytest-bdd/pull/641>`_
146+
- Fix errors occurring if ``pytest_unconfigure`` is called before `pytest_configure`. `#362 <https://github.com/pytest-dev/pytest-bdd/issues/362>`_ `#641 <https://github.com/pytest-dev/pytest-bdd/pull/641>`_
141147

142148
7.0.0
143149
----------

src/pytest_bdd/compat.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from collections.abc import Sequence
44
from importlib.metadata import version
5-
from typing import Any
65

76
from _pytest.fixtures import FixtureDef, FixtureManager, FixtureRequest
87
from _pytest.nodes import Node
@@ -14,10 +13,12 @@
1413

1514
if pytest_version.release >= (8, 1):
1615

17-
def getfixturedefs(fixturemanager: FixtureManager, fixturename: str, node: Node) -> Sequence[FixtureDef] | None:
16+
def getfixturedefs(
17+
fixturemanager: FixtureManager, fixturename: str, node: Node
18+
) -> Sequence[FixtureDef[object]] | None:
1819
return fixturemanager.getfixturedefs(fixturename, node)
1920

20-
def inject_fixture(request: FixtureRequest, arg: str, value: Any) -> None:
21+
def inject_fixture(request: FixtureRequest, arg: str, value: object) -> None:
2122
"""Inject fixture into pytest fixture request.
2223
2324
:param request: pytest fixture request
@@ -38,10 +39,12 @@ def inject_fixture(request: FixtureRequest, arg: str, value: Any) -> None:
3839

3940
else:
4041

41-
def getfixturedefs(fixturemanager: FixtureManager, fixturename: str, node: Node) -> Sequence[FixtureDef] | None:
42+
def getfixturedefs(
43+
fixturemanager: FixtureManager, fixturename: str, node: Node
44+
) -> Sequence[FixtureDef[object]] | None:
4245
return fixturemanager.getfixturedefs(fixturename, node.nodeid) # type: ignore
4346

44-
def inject_fixture(request: FixtureRequest, arg: str, value: Any) -> None:
47+
def inject_fixture(request: FixtureRequest, arg: str, value: object) -> None:
4548
"""Inject fixture into pytest fixture request.
4649
4750
:param request: pytest fixture request

src/pytest_bdd/cucumber_json.py

+78-20
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,69 @@
66
import math
77
import os
88
import time
9-
import typing
9+
from typing import TYPE_CHECKING, Literal, TypedDict
1010

11-
if typing.TYPE_CHECKING:
12-
from typing import Any
11+
from typing_extensions import NotRequired
1312

13+
from .reporting import FeatureDict, ScenarioReportDict, StepReportDict, test_report_context_registry
14+
15+
if TYPE_CHECKING:
1416
from _pytest.config import Config
1517
from _pytest.config.argparsing import Parser
1618
from _pytest.reports import TestReport
1719
from _pytest.terminal import TerminalReporter
1820

1921

22+
class ResultElementDict(TypedDict):
23+
status: Literal["passed", "failed", "skipped"]
24+
duration: int # in nanoseconds
25+
error_message: NotRequired[str]
26+
27+
28+
class TagElementDict(TypedDict):
29+
name: str
30+
line: int
31+
32+
33+
class MatchElementDict(TypedDict):
34+
location: str
35+
36+
37+
class StepElementDict(TypedDict):
38+
keyword: str
39+
name: str
40+
line: int
41+
match: MatchElementDict
42+
result: ResultElementDict
43+
44+
45+
class ScenarioElementDict(TypedDict):
46+
keyword: str
47+
id: str
48+
name: str
49+
line: int
50+
description: str
51+
tags: list[TagElementDict]
52+
type: Literal["scenario"]
53+
steps: list[StepElementDict]
54+
55+
56+
class FeatureElementDict(TypedDict):
57+
keyword: str
58+
uri: str
59+
name: str
60+
id: str
61+
line: int
62+
description: str
63+
language: str
64+
tags: list[TagElementDict]
65+
elements: list[ScenarioElementDict]
66+
67+
68+
class FeaturesDict(TypedDict):
69+
features: dict[str, FeatureElementDict]
70+
71+
2072
def add_options(parser: Parser) -> None:
2173
"""Add pytest-bdd options."""
2274
group = parser.getgroup("bdd", "Cucumber JSON")
@@ -52,26 +104,32 @@ class LogBDDCucumberJSON:
52104
def __init__(self, logfile: str) -> None:
53105
logfile = os.path.expanduser(os.path.expandvars(logfile))
54106
self.logfile = os.path.normpath(os.path.abspath(logfile))
55-
self.features: dict[str, dict] = {}
107+
self.features: dict[str, FeatureElementDict] = {}
56108

57-
def _get_result(self, step: dict[str, Any], report: TestReport, error_message: bool = False) -> dict[str, Any]:
109+
def _get_result(self, step: StepReportDict, report: TestReport, error_message: bool = False) -> ResultElementDict:
58110
"""Get scenario test run result.
59111
60112
:param step: `Step` step we get result for
61113
:param report: pytest `Report` object
62114
:return: `dict` in form {"status": "<passed|failed|skipped>", ["error_message": "<error_message>"]}
63115
"""
64-
result: dict[str, Any] = {}
65-
if report.passed or not step["failed"]: # ignore setup/teardown
66-
result = {"status": "passed"}
67-
elif report.failed:
68-
result = {"status": "failed", "error_message": str(report.longrepr) if error_message else ""}
69-
elif report.skipped:
70-
result = {"status": "skipped"}
71-
result["duration"] = int(math.floor((10**9) * step["duration"])) # nanosec
72-
return result
73-
74-
def _serialize_tags(self, item: dict[str, Any]) -> list[dict[str, Any]]:
116+
status: Literal["passed", "failed", "skipped"]
117+
res_message = None
118+
if report.outcome == "passed" or not step["failed"]: # ignore setup/teardown
119+
status = "passed"
120+
elif report.outcome == "failed":
121+
status = "failed"
122+
res_message = str(report.longrepr) if error_message else ""
123+
elif report.outcome == "skipped":
124+
status = "skipped"
125+
else:
126+
raise ValueError(f"Unknown test outcome {report.outcome}")
127+
res: ResultElementDict = {"status": status, "duration": int(math.floor((10**9) * step["duration"]))} # nanosec
128+
if res_message is not None:
129+
res["error_message"] = res_message
130+
return res
131+
132+
def _serialize_tags(self, item: FeatureDict | ScenarioReportDict) -> list[TagElementDict]:
75133
"""Serialize item's tags.
76134
77135
:param item: json-serialized `Scenario` or `Feature`.
@@ -87,16 +145,16 @@ def _serialize_tags(self, item: dict[str, Any]) -> list[dict[str, Any]]:
87145

88146
def pytest_runtest_logreport(self, report: TestReport) -> None:
89147
try:
90-
scenario = report.scenario
91-
except AttributeError:
148+
scenario = test_report_context_registry[report].scenario
149+
except KeyError:
92150
# skip reporting for non-bdd tests
93151
return
94152

95153
if not scenario["steps"] or report.when != "call":
96154
# skip if there isn't a result or scenario has no steps
97155
return
98156

99-
def stepmap(step: dict[str, Any]) -> dict[str, Any]:
157+
def stepmap(step: StepReportDict) -> StepElementDict:
100158
error_message = False
101159
if step["failed"] and not scenario.setdefault("failed", False):
102160
scenario["failed"] = True
@@ -128,7 +186,7 @@ def stepmap(step: dict[str, Any]) -> dict[str, Any]:
128186
self.features[scenario["feature"]["filename"]]["elements"].append(
129187
{
130188
"keyword": scenario["keyword"],
131-
"id": report.item["name"],
189+
"id": test_report_context_registry[report].name,
132190
"name": scenario["name"],
133191
"line": scenario["line_number"],
134192
"description": scenario["description"],

src/pytest_bdd/feature.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
import glob
3030
import os.path
31+
from collections.abc import Iterable
3132

3233
from .parser import Feature, FeatureParser
3334

@@ -57,7 +58,7 @@ def get_feature(base_path: str, filename: str, encoding: str = "utf-8") -> Featu
5758
return feature
5859

5960

60-
def get_features(paths: list[str], encoding: str = "utf-8") -> list[Feature]:
61+
def get_features(paths: Iterable[str], encoding: str = "utf-8") -> list[Feature]:
6162
"""Get features for given paths.
6263
6364
:param list paths: `list` of paths (file or dirs)

src/pytest_bdd/generation.py

+18-8
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,30 @@
77
from typing import TYPE_CHECKING, cast
88

99
from _pytest._io import TerminalWriter
10+
from _pytest.python import Function
1011
from mako.lookup import TemplateLookup # type: ignore
1112

1213
from .compat import getfixturedefs
1314
from .feature import get_features
1415
from .parser import Feature, ScenarioTemplate, Step
15-
from .scenario import inject_fixturedefs_for_step, make_python_docstring, make_python_name, make_string_literal
16+
from .scenario import (
17+
inject_fixturedefs_for_step,
18+
make_python_docstring,
19+
make_python_name,
20+
make_string_literal,
21+
scenario_wrapper_template_registry,
22+
)
1623
from .steps import get_step_fixture_name
1724
from .types import STEP_TYPES
1825

1926
if TYPE_CHECKING:
2027
from collections.abc import Sequence
21-
from typing import Any
2228

2329
from _pytest.config import Config
2430
from _pytest.config.argparsing import Parser
2531
from _pytest.fixtures import FixtureDef, FixtureManager
2632
from _pytest.main import Session
27-
from _pytest.python import Function
33+
from _pytest.nodes import Node
2834

2935

3036
template_lookup = TemplateLookup(directories=[os.path.join(os.path.dirname(__file__), "templates")])
@@ -127,23 +133,25 @@ def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) ->
127133

128134

129135
def _find_step_fixturedef(
130-
fixturemanager: FixtureManager, item: Function, step: Step
131-
) -> Sequence[FixtureDef[Any]] | None:
136+
fixturemanager: FixtureManager, item: Node, step: Step
137+
) -> Sequence[FixtureDef[object]] | None:
132138
"""Find step fixturedef."""
133139
with inject_fixturedefs_for_step(step=step, fixturemanager=fixturemanager, node=item):
134140
bdd_name = get_step_fixture_name(step=step)
135141
return getfixturedefs(fixturemanager, bdd_name, item)
136142

137143

138-
def parse_feature_files(paths: list[str], **kwargs: Any) -> tuple[list[Feature], list[ScenarioTemplate], list[Step]]:
144+
def parse_feature_files(
145+
paths: list[str], encoding: str = "utf-8"
146+
) -> tuple[list[Feature], list[ScenarioTemplate], list[Step]]:
139147
"""Parse feature files of given paths.
140148
141149
:param paths: `list` of paths (file or dirs)
142150
143151
:return: `list` of `tuple` in form:
144152
(`list` of `Feature` objects, `list` of `Scenario` objects, `list` of `Step` objects).
145153
"""
146-
features = get_features(paths, **kwargs)
154+
features = get_features(paths, encoding=encoding)
147155
scenarios = sorted(
148156
itertools.chain.from_iterable(feature.scenarios.values() for feature in features),
149157
key=lambda scenario: (scenario.feature.name or scenario.feature.filename, scenario.name),
@@ -182,7 +190,9 @@ def _show_missing_code_main(config: Config, session: Session) -> None:
182190
features, scenarios, steps = parse_feature_files(config.option.features)
183191

184192
for item in session.items:
185-
if scenario := getattr(item.obj, "__scenario__", None): # type: ignore
193+
if not isinstance(item, Function):
194+
continue
195+
if (scenario := scenario_wrapper_template_registry.get(item.obj)) is not None:
186196
if scenario in scenarios:
187197
scenarios.remove(scenario)
188198
for step in scenario.steps:

src/pytest_bdd/gherkin_terminal_reporter.py

+21-17
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
from _pytest.terminal import TerminalReporter
66

7-
if typing.TYPE_CHECKING:
8-
from typing import Any
7+
from .reporting import test_report_context_registry
98

9+
if typing.TYPE_CHECKING:
1010
from _pytest.config import Config
1111
from _pytest.config.argparsing import Parser
1212
from _pytest.reports import TestReport
@@ -43,12 +43,12 @@ def configure(config: Config) -> None:
4343
raise Exception("gherkin-terminal-reporter is not compatible with 'xdist' plugin.")
4444

4545

46-
class GherkinTerminalReporter(TerminalReporter): # type: ignore
46+
class GherkinTerminalReporter(TerminalReporter): # type: ignore[misc]
4747
def __init__(self, config: Config) -> None:
4848
super().__init__(config)
49-
self.current_rule = None
49+
self.current_rule: str | None = None
5050

51-
def pytest_runtest_logreport(self, report: TestReport) -> Any:
51+
def pytest_runtest_logreport(self, report: TestReport) -> None:
5252
rep = report
5353
res = self.config.hook.pytest_report_teststatus(report=rep, config=self.config)
5454
cat, letter, word = res
@@ -69,16 +69,21 @@ def pytest_runtest_logreport(self, report: TestReport) -> Any:
6969
scenario_markup = word_markup
7070
rule_markup = {"purple": True}
7171

72-
if self.verbosity <= 0 or not hasattr(report, "scenario"):
72+
try:
73+
scenario = test_report_context_registry[report].scenario
74+
except KeyError:
75+
scenario = None
76+
77+
if self.verbosity <= 0 or scenario is None:
7378
return super().pytest_runtest_logreport(rep)
7479

75-
rule = report.scenario.get("rule")
80+
rule = scenario.get("rule")
7681
indent = " " if rule else ""
7782

7883
if self.verbosity == 1:
7984
self.ensure_newline()
80-
self._tw.write(f"{report.scenario['feature']['keyword']}: ", **feature_markup)
81-
self._tw.write(report.scenario["feature"]["name"], **feature_markup)
85+
self._tw.write(f"{scenario['feature']['keyword']}: ", **feature_markup)
86+
self._tw.write(scenario["feature"]["name"], **feature_markup)
8287
self._tw.write("\n")
8388

8489
if rule and rule["name"] != self.current_rule:
@@ -87,15 +92,15 @@ def pytest_runtest_logreport(self, report: TestReport) -> Any:
8792
self._tw.write("\n")
8893
self.current_rule = rule["name"]
8994

90-
self._tw.write(f"{indent} {report.scenario['keyword']}: ", **scenario_markup)
91-
self._tw.write(report.scenario["name"], **scenario_markup)
95+
self._tw.write(f"{indent} {scenario['keyword']}: ", **scenario_markup)
96+
self._tw.write(scenario["name"], **scenario_markup)
9297
self._tw.write(" ")
9398
self._tw.write(word, **word_markup)
9499
self._tw.write("\n")
95100
elif self.verbosity > 1:
96101
self.ensure_newline()
97-
self._tw.write(f"{report.scenario['feature']['keyword']}: ", **feature_markup)
98-
self._tw.write(report.scenario["feature"]["name"], **feature_markup)
102+
self._tw.write(f"{scenario['feature']['keyword']}: ", **feature_markup)
103+
self._tw.write(scenario["feature"]["name"], **feature_markup)
99104
self._tw.write("\n")
100105

101106
if rule and rule["name"] != self.current_rule:
@@ -104,13 +109,12 @@ def pytest_runtest_logreport(self, report: TestReport) -> Any:
104109
self._tw.write("\n")
105110
self.current_rule = rule["name"]
106111

107-
self._tw.write(f"{indent} {report.scenario['keyword']}: ", **scenario_markup)
108-
self._tw.write(report.scenario["name"], **scenario_markup)
112+
self._tw.write(f"{indent} {scenario['keyword']}: ", **scenario_markup)
113+
self._tw.write(scenario["name"], **scenario_markup)
109114
self._tw.write("\n")
110-
for step in report.scenario["steps"]:
115+
for step in scenario["steps"]:
111116
self._tw.write(f"{indent} {step['keyword']} {step['name']}\n", **scenario_markup)
112117
self._tw.write(f"{indent} {word}", **word_markup)
113118
self._tw.write("\n\n")
114119

115120
self.stats.setdefault(cat, []).append(rep)
116-
return None

0 commit comments

Comments
 (0)