Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix typing #658

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
9 changes: 8 additions & 1 deletion CHANGES.rst
Expand Up @@ -3,10 +3,17 @@ Changelog

Unreleased
----------
- Address many ``mypy`` warnings. The following private attributes are not available anymore:

* ``_pytest.reports.TestReport.scenario`` (replaced by ``pytest_bdd.reporting.test_report_context`` WeakKeyDictionary)
* ``__scenario__`` attribute of test functions generated by the ``@scenario`` (and ``@scenarios``) decorator (replaced by ``pytest_bdd.scenario.scenario_wrapper_template_registry`` WeakKeyDictionary)
* ``_pytest.nodes.Item.__scenario_report__`` (replaced by ``pytest_bdd.reporting.scenario_reports_registry`` WeakKeyDictionary)
* ``_pytest_bdd_step_context`` attribute of internal test function markers (replaced by ``pytest_bdd.steps.step_function_context_registry`` WeakKeyDictionary)
`#658 <https://github.com/pytest-dev/pytest-bdd/pull/658>`_

7.0.1
-----
- 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>`_
- 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>`_

7.0.0
----------
Expand Down
144 changes: 73 additions & 71 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Expand Up @@ -43,7 +43,7 @@ typing-extensions = "*"

[tool.poetry.group.dev.dependencies]
tox = ">=4.11.3"
mypy = ">=1.6.0"
mypy = "^1.8.0"
types-setuptools = ">=68.2.0.0"
pytest-xdist = ">=3.3.1"
coverage = {extras = ["toml"], version = ">=6.5.0"}
Expand Down
8 changes: 5 additions & 3 deletions src/pytest_bdd/cucumber_json.py
Expand Up @@ -7,6 +7,8 @@
import time
import typing

from .reporting import test_report_context

if typing.TYPE_CHECKING:
from typing import Any

Expand Down Expand Up @@ -87,8 +89,8 @@ def _serialize_tags(self, item: dict[str, Any]) -> list[dict[str, Any]]:

def pytest_runtest_logreport(self, report: TestReport) -> None:
try:
scenario = report.scenario
except AttributeError:
scenario = test_report_context[report].scenario
except KeyError:
# skip reporting for non-bdd tests
return

Expand Down Expand Up @@ -127,7 +129,7 @@ def stepmap(step: dict[str, Any]) -> dict[str, Any]:
self.features[scenario["feature"]["filename"]]["elements"].append(
{
"keyword": "Scenario",
"id": report.item["name"],
"id": test_report_context[report].name,
"name": scenario["name"],
"line": scenario["line_number"],
"description": "",
Expand Down
3 changes: 2 additions & 1 deletion src/pytest_bdd/feature.py
Expand Up @@ -27,6 +27,7 @@

import glob
import os.path
from typing import Iterable

from .parser import Feature, parse_feature

Expand Down Expand Up @@ -56,7 +57,7 @@ def get_feature(base_path: str, filename: str, encoding: str = "utf-8") -> Featu
return feature


def get_features(paths: list[str], **kwargs) -> list[Feature]:
def get_features(paths: Iterable[str], **kwargs) -> list[Feature]:
"""Get features for given paths.

:param list paths: `list` of paths (file or dirs)
Expand Down
19 changes: 13 additions & 6 deletions src/pytest_bdd/generation.py
Expand Up @@ -6,10 +6,17 @@
from typing import TYPE_CHECKING, cast

from _pytest._io import TerminalWriter
from _pytest.python import Function
from mako.lookup import TemplateLookup

from .feature import get_features
from .scenario import inject_fixturedefs_for_step, make_python_docstring, make_python_name, make_string_literal
from .scenario import (
inject_fixturedefs_for_step,
make_python_docstring,
make_python_name,
make_string_literal,
scenario_wrapper_template_registry,
)
from .steps import get_step_fixture_name
from .types import STEP_TYPES

Expand All @@ -20,7 +27,7 @@
from _pytest.config.argparsing import Parser
from _pytest.fixtures import FixtureDef, FixtureManager
from _pytest.main import Session
from _pytest.python import Function
from _pytest.nodes import Node

from .parser import Feature, ScenarioTemplate, Step

Expand Down Expand Up @@ -123,9 +130,7 @@
tw.write(code)


def _find_step_fixturedef(
fixturemanager: FixtureManager, item: Function, step: Step
) -> Sequence[FixtureDef[Any]] | None:
def _find_step_fixturedef(fixturemanager: FixtureManager, item: Node, step: Step) -> Sequence[FixtureDef[Any]] | None:
"""Find step fixturedef."""
with inject_fixturedefs_for_step(step=step, fixturemanager=fixturemanager, nodeid=item.nodeid):
bdd_name = get_step_fixture_name(step=step)
Expand Down Expand Up @@ -179,7 +184,9 @@
features, scenarios, steps = parse_feature_files(config.option.features)

for item in session.items:
if scenario := getattr(item.obj, "__scenario__", None):
if not isinstance(item, Function):
continue

Check warning on line 188 in src/pytest_bdd/generation.py

View check run for this annotation

Codecov / codecov/patch

src/pytest_bdd/generation.py#L188

Added line #L188 was not covered by tests
if (scenario := scenario_wrapper_template_registry.get(item.obj)) is not None:
if scenario in scenarios:
scenarios.remove(scenario)
for step in scenario.steps:
Expand Down
19 changes: 13 additions & 6 deletions src/pytest_bdd/gherkin_terminal_reporter.py
Expand Up @@ -4,6 +4,8 @@

from _pytest.terminal import TerminalReporter

from .reporting import test_report_context

if typing.TYPE_CHECKING:
from typing import Any

Expand Down Expand Up @@ -67,28 +69,33 @@ def pytest_runtest_logreport(self, report: TestReport) -> Any:
feature_markup = {"blue": True}
scenario_markup = word_markup

if self.verbosity <= 0 or not hasattr(report, "scenario"):
try:
scenario = test_report_context[report].scenario
except KeyError:
scenario = None

if self.verbosity <= 0 or scenario is None:
return super().pytest_runtest_logreport(rep)

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(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(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(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(scenario["name"], **scenario_markup)
self._tw.write("\n")
for step in report.scenario["steps"]:
for step in scenario["steps"]:
self._tw.write(f" {step['keyword']} {step['name']}\n", **scenario_markup)
self._tw.write(f" {word}", **word_markup)
self._tw.write("\n\n")
Expand Down
2 changes: 1 addition & 1 deletion src/pytest_bdd/parser.py
Expand Up @@ -226,7 +226,7 @@ class ScenarioTemplate:
line_number: int
templated: bool
tags: set[str] = field(default_factory=set)
examples: Examples | None = field(default_factory=lambda: Examples())
examples: Examples = field(default_factory=lambda: Examples())
_steps: list[Step] = field(init=False, default_factory=list)
_description_lines: list[str] = field(init=False, default_factory=list)

Expand Down
30 changes: 20 additions & 10 deletions src/pytest_bdd/reporting.py
Expand Up @@ -6,7 +6,9 @@
from __future__ import annotations

import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
from weakref import WeakKeyDictionary

if TYPE_CHECKING:
from typing import Any, Callable
Expand All @@ -18,6 +20,9 @@

from .parser import Feature, Scenario, Step

scenario_reports_registry: WeakKeyDictionary[Item, ScenarioReport] = WeakKeyDictionary()
test_report_context: WeakKeyDictionary[TestReport, ReportContext] = WeakKeyDictionary()


class StepReport:
"""Step execution report."""
Expand Down Expand Up @@ -134,20 +139,25 @@ def fail(self) -> None:
self.add_step_report(report)


@dataclass
class ReportContext:
scenario: dict[str, Any]
name: str


def runtest_makereport(item: Item, call: CallInfo, rep: TestReport) -> None:
"""Store item in the report object."""
try:
scenario_report: ScenarioReport = item.__scenario_report__
except AttributeError:
pass
else:
rep.scenario = scenario_report.serialize()
rep.item = {"name": item.name}
scenario_report: ScenarioReport = scenario_reports_registry[item]
except KeyError:
return

test_report_context[rep] = ReportContext(scenario=scenario_report.serialize(), name=item.name)


def before_scenario(request: FixtureRequest, feature: Feature, scenario: Scenario) -> None:
"""Create scenario report for the item."""
request.node.__scenario_report__ = ScenarioReport(scenario=scenario)
scenario_reports_registry[request.node] = ScenarioReport(scenario=scenario)


def step_error(
Expand All @@ -160,7 +170,7 @@ def step_error(
exception: Exception,
) -> None:
"""Finalize the step report as failed."""
request.node.__scenario_report__.fail()
scenario_reports_registry[request.node].fail()


def before_step(
Expand All @@ -171,7 +181,7 @@ def before_step(
step_func: Callable[..., Any],
) -> None:
"""Store step start time."""
request.node.__scenario_report__.add_step_report(StepReport(step=step))
scenario_reports_registry[request.node].add_step_report(StepReport(step=step))


def after_step(
Expand All @@ -183,4 +193,4 @@ def after_step(
step_func_args: dict,
) -> None:
"""Finalize the step report as successful."""
request.node.__scenario_report__.current_step_report.finalize(failed=False)
scenario_reports_registry[request.node].current_step_report.finalize(failed=False)
29 changes: 16 additions & 13 deletions src/pytest_bdd/scenario.py
Expand Up @@ -17,6 +17,7 @@
import os
import re
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, TypeVar, cast
from weakref import WeakKeyDictionary

import pytest
from _pytest.fixtures import FixtureDef, FixtureManager, FixtureRequest, call_fixture_func
Expand All @@ -25,15 +26,14 @@

from . import exceptions
from .feature import get_feature, get_features
from .steps import StepFunctionContext, get_step_fixture_name, inject_fixture
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path
from .steps import StepFunctionContext, get_step_fixture_name, inject_fixture, step_function_context_registry
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path, registry_get_safe

if TYPE_CHECKING:
from _pytest.mark.structures import ParameterSet

from .parser import Feature, Scenario, ScenarioTemplate, Step

P = ParamSpec("P")
T = TypeVar("T")

logger = logging.getLogger(__name__)
Expand All @@ -42,14 +42,16 @@
PYTHON_REPLACE_REGEX = re.compile(r"\W")
ALPHA_REGEX = re.compile(r"^\d+_*")

scenario_wrapper_template_registry: WeakKeyDictionary[Callable[..., Any], ScenarioTemplate] = WeakKeyDictionary()


def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, nodeid: str) -> Iterable[FixtureDef[Any]]:
"""Find the fixture defs that can parse a step."""
# happens to be that _arg2fixturedefs is changed during the iteration so we use a copy
fixture_def_by_name = list(fixturemanager._arg2fixturedefs.items())
for fixturename, fixturedefs in fixture_def_by_name:
for pos, fixturedef in enumerate(fixturedefs):
step_func_context = getattr(fixturedef.func, "_pytest_bdd_step_context", None)
step_func_context = step_function_context_registry.get(fixturedef.func)
if step_func_context is None:
continue

Expand Down Expand Up @@ -198,14 +200,14 @@ def _execute_scenario(feature: Feature, scenario: Scenario, request: FixtureRequ

def _get_scenario_decorator(
feature: Feature, feature_name: str, templated_scenario: ScenarioTemplate, scenario_name: str
) -> Callable[[Callable[P, T]], Callable[P, T]]:
) -> Callable[[Callable[..., T]], Callable[..., 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
# for a fixture called "fn" that doesn't exist (if it exists then it's even worse).
# It will error with a "fixture 'fn' not found" message instead.
# We can avoid this hack by using a pytest hook and check for misuse instead.
def decorator(*args: Callable[P, T]) -> Callable[P, T]:
def decorator(*args: Callable[..., T]) -> Callable[..., T]:
if not args:
raise exceptions.ScenarioIsDecoratorOnly(
"scenario function can only be used as a decorator. Refer to the documentation."
Expand All @@ -216,7 +218,7 @@ def decorator(*args: Callable[P, T]) -> Callable[P, T]:
# We need to tell pytest that the original function requires its fixtures,
# otherwise indirect fixtures would not work.
@pytest.mark.usefixtures(*func_args)
def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any:
def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> T:
__tracebackhide__ = True
scenario = templated_scenario.render(_pytest_bdd_example)
_execute_scenario(feature, scenario, request)
Expand All @@ -236,8 +238,9 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str
config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper)

scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}"
scenario_wrapper.__scenario__ = templated_scenario
return cast(Callable[P, T], scenario_wrapper)

scenario_wrapper_template_registry[scenario_wrapper] = templated_scenario
return scenario_wrapper

return decorator

Expand All @@ -256,7 +259,7 @@ def scenario(
scenario_name: str,
encoding: str = "utf-8",
features_base_dir: str | None = None,
) -> Callable[[Callable[P, T]], Callable[P, T]]:
) -> Callable[[Callable[..., T]], Callable[..., T]]:
"""Scenario decorator.

:param str feature_name: Feature file name. Absolute or relative to the configured feature base path.
Expand Down Expand Up @@ -294,7 +297,7 @@ def get_features_base_dir(caller_module_path: str) -> str:
return os.path.join(rootdir, d)


def get_from_ini(key: str, default: str) -> str:
def get_from_ini(key: str, default: T) -> str | T:
"""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.
Expand Down Expand Up @@ -357,9 +360,9 @@ def scenarios(*feature_paths: str, **kwargs: Any) -> None:
found = False

module_scenarios = frozenset(
(attr.__scenario__.feature.filename, attr.__scenario__.name)
(s.feature.filename, s.name)
for name, attr in caller_locals.items()
if hasattr(attr, "__scenario__")
if (s := registry_get_safe(scenario_wrapper_template_registry, attr)) is not None
)

for feature in get_features(abs_feature_paths):
Expand Down