Merge pull request #505 from pytest-dev/add-type-annotations

Add type annotations to the codebase.
This commit is contained in:
Alessio Bogon 2022-03-05 16:49:08 +01:00 committed by GitHub
commit 02bf62014f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 435 additions and 265 deletions

View File

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] python-version: ["3.7", "3.8", "3.9", "3.10"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@ -24,6 +24,10 @@ jobs:
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -U setuptools pip install -U setuptools
pip install tox tox-gh-actions codecov pip install tox tox-gh-actions codecov
- name: Type checking
continue-on-error: true
run: |
tox -e mypy
- name: Test with tox - name: Test with tox
run: | run: |
tox tox

View File

@ -22,4 +22,10 @@ repos:
rev: v2.31.0 rev: v2.31.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py37-plus] args: ["--py37-plus"]
# TODO: Enable mypy checker when the checks succeed
#- repo: https://github.com/pre-commit/mirrors-mypy
# rev: v0.931
# hooks:
# - id: mypy
# additional_dependencies: [types-setuptools]

View File

@ -13,6 +13,8 @@ This release introduces breaking changes in order to be more in line with the of
- Drop support of python 3.6, pytest 4 (elchupanebrej) - Drop support of python 3.6, pytest 4 (elchupanebrej)
- Step definitions can have "yield" statements again (4.0 release broke it). They will be executed as normal fixtures: code after the yield is executed during teardown of the test. (youtux) - Step definitions can have "yield" statements again (4.0 release broke it). They will be executed as normal fixtures: code after the yield is executed during teardown of the test. (youtux)
- Scenario outlines unused example parameter validation is removed (olegpidsadnyi) - Scenario outlines unused example parameter validation is removed (olegpidsadnyi)
- Add type decorations
- ``pytest_bdd.parsers.StepParser`` now is an Abstract Base Class. Subclasses must make sure to implement the abstract methods.

View File

@ -10,3 +10,13 @@ target-version = ["py37", "py38", "py39", "py310"]
profile = "black" profile = "black"
line_length = 120 line_length = 120
multi_line_output = 3 multi_line_output = 3
[tool.mypy]
python_version = "3.7"
warn_return_any = true
warn_unused_configs = true
files = "pytest_bdd/**/*.py"
[[tool.mypy.overrides]]
module = ["parse", "parse_type", "glob2"]
ignore_missing_imports = true

View File

@ -1,8 +1,9 @@
"""pytest-bdd public API.""" """pytest-bdd public API."""
from __future__ import annotations
from pytest_bdd.scenario import scenario, scenarios from pytest_bdd.scenario import scenario, scenarios
from pytest_bdd.steps import given, then, when from pytest_bdd.steps import given, then, when
__version__ = "6.0.0" __version__ = "6.0.0"
__all__ = [given.__name__, when.__name__, then.__name__, scenario.__name__, scenarios.__name__] __all__ = ["given", "when", "then", "scenario", "scenarios"]

View File

@ -1,12 +1,22 @@
"""Cucumber json output formatter.""" """Cucumber json output formatter."""
from __future__ import annotations
import json import json
import math import math
import os import os
import time import time
import typing
if typing.TYPE_CHECKING:
from typing import Any
from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.reports import TestReport
from _pytest.terminal import TerminalReporter
def add_options(parser): def add_options(parser: Parser) -> None:
"""Add pytest-bdd options.""" """Add pytest-bdd options."""
group = parser.getgroup("bdd", "Cucumber JSON") group = parser.getgroup("bdd", "Cucumber JSON")
group.addoption( group.addoption(
@ -20,7 +30,7 @@ def add_options(parser):
) )
def configure(config): def configure(config: Config) -> None:
cucumber_json_path = config.option.cucumber_json_path cucumber_json_path = config.option.cucumber_json_path
# prevent opening json log on worker nodes (xdist) # prevent opening json log on worker nodes (xdist)
if cucumber_json_path and not hasattr(config, "workerinput"): if cucumber_json_path and not hasattr(config, "workerinput"):
@ -28,7 +38,7 @@ def configure(config):
config.pluginmanager.register(config._bddcucumberjson) config.pluginmanager.register(config._bddcucumberjson)
def unconfigure(config): def unconfigure(config: Config) -> None:
xml = getattr(config, "_bddcucumberjson", None) xml = getattr(config, "_bddcucumberjson", None)
if xml is not None: if xml is not None:
del config._bddcucumberjson del config._bddcucumberjson
@ -39,22 +49,19 @@ class LogBDDCucumberJSON:
"""Logging plugin for cucumber like json output.""" """Logging plugin for cucumber like json output."""
def __init__(self, logfile): def __init__(self, logfile: str) -> None:
logfile = os.path.expanduser(os.path.expandvars(logfile)) logfile = os.path.expanduser(os.path.expandvars(logfile))
self.logfile = os.path.normpath(os.path.abspath(logfile)) self.logfile = os.path.normpath(os.path.abspath(logfile))
self.features = {} self.features: dict[str, dict] = {}
def append(self, obj): def _get_result(self, step: dict[str, Any], report: TestReport, error_message: bool = False) -> dict[str, Any]:
self.features[-1].append(obj)
def _get_result(self, step, report, error_message=False):
"""Get scenario test run result. """Get scenario test run result.
:param step: `Step` step we get result for :param step: `Step` step we get result for
:param report: pytest `Report` object :param report: pytest `Report` object
:return: `dict` in form {"status": "<passed|failed|skipped>", ["error_message": "<error_message>"]} :return: `dict` in form {"status": "<passed|failed|skipped>", ["error_message": "<error_message>"]}
""" """
result = {} result: dict[str, Any] = {}
if report.passed or not step["failed"]: # ignore setup/teardown if report.passed or not step["failed"]: # ignore setup/teardown
result = {"status": "passed"} result = {"status": "passed"}
elif report.failed and step["failed"]: elif report.failed and step["failed"]:
@ -64,7 +71,7 @@ class LogBDDCucumberJSON:
result["duration"] = int(math.floor((10**9) * step["duration"])) # nanosec result["duration"] = int(math.floor((10**9) * step["duration"])) # nanosec
return result return result
def _serialize_tags(self, item): def _serialize_tags(self, item: dict[str, Any]) -> list[dict[str, Any]]:
"""Serialize item's tags. """Serialize item's tags.
:param item: json-serialized `Scenario` or `Feature`. :param item: json-serialized `Scenario` or `Feature`.
@ -78,7 +85,7 @@ class LogBDDCucumberJSON:
""" """
return [{"name": tag, "line": item["line_number"] - 1} for tag in item["tags"]] return [{"name": tag, "line": item["line_number"] - 1} for tag in item["tags"]]
def pytest_runtest_logreport(self, report): def pytest_runtest_logreport(self, report: TestReport) -> None:
try: try:
scenario = report.scenario scenario = report.scenario
except AttributeError: except AttributeError:
@ -89,7 +96,7 @@ class LogBDDCucumberJSON:
# skip if there isn't a result or scenario has no steps # skip if there isn't a result or scenario has no steps
return return
def stepmap(step): def stepmap(step: dict[str, Any]) -> dict[str, Any]:
error_message = False error_message = False
if step["failed"] and not scenario.setdefault("failed", False): if step["failed"] and not scenario.setdefault("failed", False):
scenario["failed"] = True scenario["failed"] = True
@ -130,12 +137,12 @@ class LogBDDCucumberJSON:
} }
) )
def pytest_sessionstart(self): def pytest_sessionstart(self) -> None:
self.suite_start_time = time.time() self.suite_start_time = time.time()
def pytest_sessionfinish(self): def pytest_sessionfinish(self) -> None:
with open(self.logfile, "w", encoding="utf-8") as logfile: with open(self.logfile, "w", encoding="utf-8") as logfile:
logfile.write(json.dumps(list(self.features.values()))) logfile.write(json.dumps(list(self.features.values())))
def pytest_terminal_summary(self, terminalreporter): def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None:
terminalreporter.write_sep("-", "generated json file: %s" % (self.logfile)) terminalreporter.write_sep("-", f"generated json file: {self.logfile}")

View File

@ -1,4 +1,5 @@
"""pytest-bdd Exceptions.""" """pytest-bdd Exceptions."""
from __future__ import annotations
class ScenarioIsDecoratorOnly(Exception): class ScenarioIsDecoratorOnly(Exception):
@ -30,6 +31,6 @@ class FeatureError(Exception):
message = "{0}.\nLine number: {1}.\nLine: {2}.\nFile: {3}" message = "{0}.\nLine number: {1}.\nLine: {2}.\nFile: {3}"
def __str__(self): def __str__(self) -> str:
"""String representation.""" """String representation."""
return self.message.format(*self.args) return self.message.format(*self.args)

View File

@ -23,15 +23,16 @@ Syntax example:
:note: There are no multiline steps, the description of the step must fit in :note: There are no multiline steps, the description of the step must fit in
one line. one line.
""" """
from __future__ import annotations
import os.path import os.path
import typing
import glob2 import glob2
from .parser import Feature, parse_feature from .parser import Feature, parse_feature
# Global features dictionary # Global features dictionary
features: typing.Dict[str, Feature] = {} features: dict[str, Feature] = {}
def get_feature(base_path: str, filename: str, encoding: str = "utf-8") -> Feature: def get_feature(base_path: str, filename: str, encoding: str = "utf-8") -> Feature:
@ -56,7 +57,7 @@ def get_feature(base_path: str, filename: str, encoding: str = "utf-8") -> Featu
return feature return feature
def get_features(paths: typing.List[str], **kwargs) -> typing.List[Feature]: def get_features(paths: list[str], **kwargs) -> list[Feature]:
"""Get features for given paths. """Get features for given paths.
:param list paths: `list` of paths (file or dirs) :param list paths: `list` of paths (file or dirs)

View File

@ -1,7 +1,9 @@
"""pytest-bdd missing test code generation.""" """pytest-bdd missing test code generation."""
from __future__ import annotations
import itertools import itertools
import os.path import os.path
from typing import TYPE_CHECKING, cast
import py import py
from mako.lookup import TemplateLookup from mako.lookup import TemplateLookup
@ -11,10 +13,21 @@ from .scenario import find_argumented_step_fixture_name, make_python_docstring,
from .steps import get_step_fixture_name from .steps import get_step_fixture_name
from .types import STEP_TYPES from .types import STEP_TYPES
if TYPE_CHECKING:
from typing import Any, Sequence
from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.fixtures import FixtureDef, FixtureManager
from _pytest.main import Session
from _pytest.python import Function
from .parser import Feature, ScenarioTemplate, Step
template_lookup = TemplateLookup(directories=[os.path.join(os.path.dirname(__file__), "templates")]) template_lookup = TemplateLookup(directories=[os.path.join(os.path.dirname(__file__), "templates")])
def add_options(parser): def add_options(parser: Parser) -> None:
"""Add pytest-bdd options.""" """Add pytest-bdd options."""
group = parser.getgroup("bdd", "Generation") group = parser.getgroup("bdd", "Generation")
@ -35,17 +48,18 @@ def add_options(parser):
) )
def cmdline_main(config): def cmdline_main(config: Config) -> int | None:
"""Check config option to show missing code.""" """Check config option to show missing code."""
if config.option.generate_missing: if config.option.generate_missing:
return show_missing_code(config) return show_missing_code(config)
return None # Make mypy happy
def generate_code(features, scenarios, steps): def generate_code(features: list[Feature], scenarios: list[ScenarioTemplate], steps: list[Step]) -> str:
"""Generate test code for the given filenames.""" """Generate test code for the given filenames."""
grouped_steps = group_steps(steps) grouped_steps = group_steps(steps)
template = template_lookup.get_template("test.py.mak") template = template_lookup.get_template("test.py.mak")
return template.render( code = template.render(
features=features, features=features,
scenarios=scenarios, scenarios=scenarios,
steps=grouped_steps, steps=grouped_steps,
@ -53,16 +67,17 @@ def generate_code(features, scenarios, steps):
make_python_docstring=make_python_docstring, make_python_docstring=make_python_docstring,
make_string_literal=make_string_literal, make_string_literal=make_string_literal,
) )
return cast(str, code)
def show_missing_code(config): def show_missing_code(config: Config) -> int:
"""Wrap pytest session to show missing code.""" """Wrap pytest session to show missing code."""
from _pytest.main import wrap_session from _pytest.main import wrap_session
return wrap_session(config, _show_missing_code_main) return wrap_session(config, _show_missing_code_main)
def print_missing_code(scenarios, steps): def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) -> None:
"""Print missing code with TerminalWriter.""" """Print missing code with TerminalWriter."""
tw = py.io.TerminalWriter() tw = py.io.TerminalWriter()
scenario = step = None scenario = step = None
@ -108,14 +123,10 @@ def print_missing_code(scenarios, steps):
tw.write(code) tw.write(code)
def _find_step_fixturedef(fixturemanager, item, name, type_): def _find_step_fixturedef(
"""Find step fixturedef. fixturemanager: FixtureManager, item: Function, name: str, type_: str
) -> Sequence[FixtureDef[Any]] | None:
:param request: PyTest Item object. """Find step fixturedef."""
:param step: `Step`.
:return: Step function.
"""
step_fixture_name = get_step_fixture_name(name, type_) step_fixture_name = get_step_fixture_name(name, type_)
fixturedefs = fixturemanager.getfixturedefs(step_fixture_name, item.nodeid) fixturedefs = fixturemanager.getfixturedefs(step_fixture_name, item.nodeid)
if fixturedefs is not None: if fixturedefs is not None:
@ -127,7 +138,7 @@ def _find_step_fixturedef(fixturemanager, item, name, type_):
return None return None
def parse_feature_files(paths, **kwargs): def parse_feature_files(paths: list[str], **kwargs: Any) -> tuple[list[Feature], list[ScenarioTemplate], list[Step]]:
"""Parse feature files of given paths. """Parse feature files of given paths.
:param paths: `list` of paths (file or dirs) :param paths: `list` of paths (file or dirs)
@ -146,7 +157,7 @@ def parse_feature_files(paths, **kwargs):
return features, scenarios, steps return features, scenarios, steps
def group_steps(steps): def group_steps(steps: list[Step]) -> list[Step]:
"""Group steps by type.""" """Group steps by type."""
steps = sorted(steps, key=lambda step: step.type) steps = sorted(steps, key=lambda step: step.type)
seen_steps = set() seen_steps = set()
@ -161,7 +172,7 @@ def group_steps(steps):
return grouped_steps return grouped_steps
def _show_missing_code_main(config, session): def _show_missing_code_main(config: Config, session: Session) -> None:
"""Preparing fixture duplicates for output.""" """Preparing fixture duplicates for output."""
tw = py.io.TerminalWriter() tw = py.io.TerminalWriter()
session.perform_collect() session.perform_collect()

View File

@ -1,7 +1,18 @@
from __future__ import annotations
import typing
from _pytest.terminal import TerminalReporter from _pytest.terminal import TerminalReporter
if typing.TYPE_CHECKING:
from typing import Any
def add_options(parser): from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.reports import TestReport
def add_options(parser: Parser) -> None:
group = parser.getgroup("terminal reporting", "reporting", after="general") group = parser.getgroup("terminal reporting", "reporting", after="general")
group._addoption( group._addoption(
"--gherkin-terminal-reporter", "--gherkin-terminal-reporter",
@ -12,7 +23,7 @@ def add_options(parser):
) )
def configure(config): def configure(config: Config) -> None:
if config.option.gherkin_terminal_reporter: if config.option.gherkin_terminal_reporter:
# Get the standard terminal reporter plugin and replace it with our # Get the standard terminal reporter plugin and replace it with our
current_reporter = config.pluginmanager.getplugin("terminalreporter") current_reporter = config.pluginmanager.getplugin("terminalreporter")
@ -33,17 +44,17 @@ def configure(config):
class GherkinTerminalReporter(TerminalReporter): class GherkinTerminalReporter(TerminalReporter):
def __init__(self, config): def __init__(self, config: Config) -> None:
super().__init__(config) super().__init__(config)
def pytest_runtest_logreport(self, report): def pytest_runtest_logreport(self, report: TestReport) -> Any:
rep = report rep = report
res = self.config.hook.pytest_report_teststatus(report=rep, config=self.config) res = self.config.hook.pytest_report_teststatus(report=rep, config=self.config)
cat, letter, word = res cat, letter, word = res
if not letter and not word: if not letter and not word:
# probably passed setup/teardown # probably passed setup/teardown
return return None
if isinstance(word, tuple): if isinstance(word, tuple):
word, word_markup = word word, word_markup = word
@ -88,3 +99,4 @@ class GherkinTerminalReporter(TerminalReporter):
else: else:
return super().pytest_runtest_logreport(rep) return super().pytest_runtest_logreport(rep)
self.stats.setdefault(cat, []).append(rep) self.stats.setdefault(cat, []).append(rep)
return None

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import pytest import pytest
"""Pytest-bdd pytest hooks.""" """Pytest-bdd pytest hooks."""

View File

@ -1,8 +1,11 @@
from __future__ import annotations
import os.path import os.path
import re import re
import textwrap import textwrap
import typing import typing
from collections import OrderedDict from collections import OrderedDict
from typing import cast
from . import exceptions, types from . import exceptions, types
@ -24,8 +27,11 @@ STEP_PREFIXES = [
("But ", None), ("But ", None),
] ]
if typing.TYPE_CHECKING:
from typing import Any, Iterable, Mapping, Match
def split_line(line):
def split_line(line: str) -> list[str]:
"""Split the given Examples line. """Split the given Examples line.
:param str|unicode line: Feature file Examples line. :param str|unicode line: Feature file Examples line.
@ -35,7 +41,7 @@ def split_line(line):
return [cell.replace("\\|", "|").strip() for cell in SPLIT_LINE_RE.split(line)[1:-1]] return [cell.replace("\\|", "|").strip() for cell in SPLIT_LINE_RE.split(line)[1:-1]]
def parse_line(line): 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. """Parse step line to get the step prefix (Scenario, Given, When, Then or And) and the actual step name.
:param line: Line of the Feature file. :param line: Line of the Feature file.
@ -48,7 +54,7 @@ def parse_line(line):
return "", line return "", line
def strip_comments(line): def strip_comments(line: str) -> str:
"""Remove comments. """Remove comments.
:param str line: Line of the Feature file. :param str line: Line of the Feature file.
@ -61,7 +67,7 @@ def strip_comments(line):
return line.strip() return line.strip()
def get_step_type(line): def get_step_type(line: str) -> str | None:
"""Detect step type by the beginning of the line. """Detect step type by the beginning of the line.
:param str line: Line of the Feature file. :param str line: Line of the Feature file.
@ -71,9 +77,10 @@ def get_step_type(line):
for prefix, _type in STEP_PREFIXES: for prefix, _type in STEP_PREFIXES:
if line.startswith(prefix): if line.startswith(prefix):
return _type return _type
return None
def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> "Feature": def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> Feature:
"""Parse the feature file. """Parse the feature file.
:param str basedir: Feature files base directory. :param str basedir: Feature files base directory.
@ -92,10 +99,10 @@ def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> "Feat
background=None, background=None,
description="", description="",
) )
scenario: typing.Optional[ScenarioTemplate] = None scenario: ScenarioTemplate | None = None
mode = None mode: str | None = None
prev_mode = None prev_mode = None
description: typing.List[str] = [] description: list[str] = []
step = None step = None
multiline_step = False multiline_step = False
prev_line = None prev_line = None
@ -164,10 +171,10 @@ def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> "Feat
elif mode and mode not in (types.FEATURE, types.TAG): 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) step = Step(name=parsed_line, type=mode, indent=line_indent, line_number=line_number, keyword=keyword)
if feature.background and not scenario: if feature.background and not scenario:
target = feature.background feature.background.add_step(step)
else: else:
target = scenario scenario = cast(ScenarioTemplate, scenario)
target.add_step(step) scenario.add_step(step)
prev_line = clean_line prev_line = clean_line
feature.description = "\n".join(description).strip() feature.description = "\n".join(description).strip()
@ -177,15 +184,25 @@ def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> "Feat
class Feature: class Feature:
"""Feature.""" """Feature."""
def __init__(self, scenarios, filename, rel_filename, name, tags, background, line_number, description): def __init__(
self.scenarios: typing.Dict[str, ScenarioTemplate] = scenarios self,
self.rel_filename = rel_filename scenarios: OrderedDict,
self.filename = filename filename: str,
self.tags = tags rel_filename: str,
self.name = name name: str | None,
self.line_number = line_number tags: set,
self.description = description background: Background | None,
self.background = background line_number: int,
description: str,
) -> None:
self.scenarios: dict[str, ScenarioTemplate] = scenarios
self.rel_filename: str = rel_filename
self.filename: str = filename
self.tags: set = tags
self.name: str | None = name
self.line_number: int = line_number
self.description: str = description
self.background: Background | None = background
class ScenarioTemplate: class ScenarioTemplate:
@ -193,7 +210,7 @@ class ScenarioTemplate:
Created when parsing the feature file, it will then be combined with the examples to create a Scenario.""" Created when parsing the feature file, it will then be combined with the examples to create a Scenario."""
def __init__(self, feature: Feature, name: str, line_number: int, tags=None): def __init__(self, feature: Feature, name: str, line_number: int, tags=None) -> None:
""" """
:param str name: Scenario name. :param str name: Scenario name.
@ -202,12 +219,12 @@ class ScenarioTemplate:
""" """
self.feature = feature self.feature = feature
self.name = name self.name = name
self._steps: typing.List[Step] = [] self._steps: list[Step] = []
self.examples = Examples() self.examples = Examples()
self.line_number = line_number self.line_number = line_number
self.tags = tags or set() self.tags = tags or set()
def add_step(self, step): def add_step(self, step: Step) -> None:
"""Add step to the scenario. """Add step to the scenario.
:param pytest_bdd.parser.Step step: Step. :param pytest_bdd.parser.Step step: Step.
@ -216,11 +233,11 @@ class ScenarioTemplate:
self._steps.append(step) self._steps.append(step)
@property @property
def steps(self): def steps(self) -> list[Step]:
background = self.feature.background background = self.feature.background
return (background.steps if background else []) + self._steps return (background.steps if background else []) + self._steps
def render(self, context: typing.Mapping[str, typing.Any]) -> "Scenario": def render(self, context: Mapping[str, Any]) -> Scenario:
steps = [ steps = [
Step( Step(
name=templated_step.render(context), name=templated_step.render(context),
@ -238,7 +255,7 @@ class Scenario:
"""Scenario.""" """Scenario."""
def __init__(self, feature: Feature, name: str, line_number: int, steps: "typing.List[Step]", tags=None): def __init__(self, feature: Feature, name: str, line_number: int, steps: list[Step], tags=None) -> None:
"""Scenario constructor. """Scenario constructor.
:param pytest_bdd.parser.Feature feature: Feature. :param pytest_bdd.parser.Feature feature: Feature.
@ -258,7 +275,7 @@ class Step:
"""Step.""" """Step."""
def __init__(self, name, type, indent, line_number, keyword): def __init__(self, name: str, type: str, indent: int, line_number: int, keyword: str) -> None:
"""Step constructor. """Step constructor.
:param str name: step name. :param str name: step name.
@ -267,19 +284,17 @@ class Step:
:param int line_number: line number. :param int line_number: line number.
:param str keyword: step keyword. :param str keyword: step keyword.
""" """
self.name = name self.name: str = name
self.keyword = keyword self.keyword: str = keyword
self.lines = [] self.lines: list[str] = []
self.indent = indent self.indent: int = indent
self.type = type self.type: str = type
self.line_number = line_number self.line_number: int = line_number
self.failed = False self.failed: bool = False
self.start = 0 self.scenario: ScenarioTemplate | None = None
self.stop = 0 self.background: Background | None = None
self.scenario = None
self.background = None
def add_line(self, line): def add_line(self, line: str) -> None:
"""Add line to the multiple step. """Add line to the multiple step.
:param str line: Line of text - the continuation of the step name. :param str line: Line of text - the continuation of the step name.
@ -287,7 +302,7 @@ class Step:
self.lines.append(line) self.lines.append(line)
@property @property
def name(self): def name(self) -> str:
"""Get step name.""" """Get step name."""
multilines_content = textwrap.dedent("\n".join(self.lines)) if self.lines else "" multilines_content = textwrap.dedent("\n".join(self.lines)) if self.lines else ""
@ -303,21 +318,21 @@ class Step:
return "\n".join(lines).strip() return "\n".join(lines).strip()
@name.setter @name.setter
def name(self, value): def name(self, value: str) -> None:
"""Set step name.""" """Set step name."""
self._name = value self._name = value
def __str__(self): def __str__(self) -> str:
"""Full step name including the type.""" """Full step name including the type."""
return f'{self.type.capitalize()} "{self.name}"' return f'{self.type.capitalize()} "{self.name}"'
@property @property
def params(self): def params(self) -> tuple[str, ...]:
"""Get step params.""" """Get step params."""
return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) return tuple(frozenset(STEP_PARAM_RE.findall(self.name)))
def render(self, context: typing.Mapping[str, typing.Any]): def render(self, context: Mapping[str, Any]):
def replacer(m: typing.Match): def replacer(m: Match):
varname = m.group(1) varname = m.group(1)
return str(context[varname]) return str(context[varname])
@ -328,17 +343,17 @@ class Background:
"""Background.""" """Background."""
def __init__(self, feature, line_number): def __init__(self, feature: Feature, line_number: int) -> None:
"""Background constructor. """Background constructor.
:param pytest_bdd.parser.Feature feature: Feature. :param pytest_bdd.parser.Feature feature: Feature.
:param int line_number: Line number. :param int line_number: Line number.
""" """
self.feature = feature self.feature: Feature = feature
self.line_number = line_number self.line_number: int = line_number
self.steps = [] self.steps: list[Step] = []
def add_step(self, step): def add_step(self, step: Step) -> None:
"""Add step to the background.""" """Add step to the background."""
step.background = self step.background = self
self.steps.append(step) self.steps.append(step)
@ -348,40 +363,28 @@ class Examples:
"""Example table.""" """Example table."""
def __init__(self): def __init__(self) -> None:
"""Initialize examples instance.""" """Initialize examples instance."""
self.example_params = [] self.example_params: list[str] = []
self.examples = [] self.examples: list[list[str]] = []
self.line_number = None self.line_number: int | None = None
self.name = None self.name = None
def set_param_names(self, keys): def set_param_names(self, keys: list[str]) -> None:
"""Set parameter names. """Set parameter names.
:param names: `list` of `string` parameter names. :param names: `list` of `string` parameter names.
""" """
self.example_params = [str(key) for key in keys] self.example_params = [str(key) for key in keys]
def add_example(self, values): def add_example(self, values: list[str]) -> None:
"""Add example. """Add example.
:param values: `list` of `string` parameter values. :param values: `list` of `string` parameter values.
""" """
self.examples.append(values) self.examples.append(values)
def add_example_row(self, param, values): def as_contexts(self) -> Iterable[dict[str, Any]]:
"""Add example row.
:param param: `str` parameter name
:param values: `list` of `string` parameter values
"""
if param in self.example_params:
raise exceptions.ExamplesNotValidError(
f"""Example rows should contain unique parameters. "{param}" appeared more than once"""
)
self.example_params.append(param)
def as_contexts(self) -> typing.Iterable[typing.Dict[str, typing.Any]]:
if not self.examples: if not self.examples:
return return
@ -391,12 +394,12 @@ class Examples:
assert len(header) == len(row) assert len(header) == len(row)
yield dict(zip(header, row)) yield dict(zip(header, row))
def __bool__(self): def __bool__(self) -> bool:
"""Bool comparison.""" """Bool comparison."""
return bool(self.examples) return bool(self.examples)
def get_tags(line): def get_tags(line: str | None) -> set[str]:
"""Get tags out of the given line. """Get tags out of the given line.
:param str line: Feature file text line. :param str line: Feature file text line.

View File

@ -1,47 +1,53 @@
"""Step parsers.""" """Step parsers."""
from __future__ import annotations
import abc
import re as base_re import re as base_re
from functools import partial from typing import Any, Dict, cast
import parse as base_parse import parse as base_parse
from parse_type import cfparse as base_cfparse from parse_type import cfparse as base_cfparse
class StepParser: class StepParser(abc.ABC):
"""Parser of the individual step.""" """Parser of the individual step."""
def __init__(self, name): def __init__(self, name: str) -> None:
self.name = name self.name = name
def parse_arguments(self, name): @abc.abstractmethod
def parse_arguments(self, name: str) -> dict[str, Any] | None:
"""Get step arguments from the given step name. """Get step arguments from the given step name.
:return: `dict` of step arguments :return: `dict` of step arguments
""" """
raise NotImplementedError() # pragma: no cover ...
def is_matching(self, name): @abc.abstractmethod
def is_matching(self, name: str) -> bool:
"""Match given name with the step name.""" """Match given name with the step name."""
raise NotImplementedError() # pragma: no cover ...
class re(StepParser): class re(StepParser):
"""Regex step parser.""" """Regex step parser."""
def __init__(self, name, *args, **kwargs): def __init__(self, name: str, *args: Any, **kwargs: Any) -> None:
"""Compile regex.""" """Compile regex."""
super().__init__(name) super().__init__(name)
self.regex = base_re.compile(self.name, *args, **kwargs) self.regex = base_re.compile(self.name, *args, **kwargs)
def parse_arguments(self, name): def parse_arguments(self, name: str) -> dict[str, str] | None:
"""Get step arguments. """Get step arguments.
:return: `dict` of step arguments :return: `dict` of step arguments
""" """
return self.regex.match(name).groupdict() match = self.regex.match(name)
if match is None:
return None
return match.groupdict()
def is_matching(self, name): def is_matching(self, name: str) -> bool:
"""Match given name with the step name.""" """Match given name with the step name."""
return bool(self.regex.match(name)) return bool(self.regex.match(name))
@ -49,19 +55,19 @@ class re(StepParser):
class parse(StepParser): class parse(StepParser):
"""parse step parser.""" """parse step parser."""
def __init__(self, name, *args, **kwargs): def __init__(self, name: str, *args: Any, **kwargs: Any) -> None:
"""Compile parse expression.""" """Compile parse expression."""
super().__init__(name) super().__init__(name)
self.parser = base_parse.compile(self.name, *args, **kwargs) self.parser = base_parse.compile(self.name, *args, **kwargs)
def parse_arguments(self, name): def parse_arguments(self, name: str) -> dict[str, Any]:
"""Get step arguments. """Get step arguments.
:return: `dict` of step arguments :return: `dict` of step arguments
""" """
return self.parser.parse(name).named return cast(Dict[str, Any], self.parser.parse(name).named)
def is_matching(self, name): def is_matching(self, name: str) -> bool:
"""Match given name with the step name.""" """Match given name with the step name."""
try: try:
return bool(self.parser.parse(name)) return bool(self.parser.parse(name))
@ -72,7 +78,7 @@ class parse(StepParser):
class cfparse(parse): class cfparse(parse):
"""cfparse step parser.""" """cfparse step parser."""
def __init__(self, name, *args, **kwargs): def __init__(self, name: str, *args: Any, **kwargs: Any) -> None:
"""Compile parse expression.""" """Compile parse expression."""
super(parse, self).__init__(name) super(parse, self).__init__(name)
self.parser = base_cfparse.Parser(self.name, *args, **kwargs) self.parser = base_cfparse.Parser(self.name, *args, **kwargs)
@ -81,36 +87,22 @@ class cfparse(parse):
class string(StepParser): class string(StepParser):
"""Exact string step parser.""" """Exact string step parser."""
def __init__(self, name): def parse_arguments(self, name: str) -> dict:
"""Stringify"""
name = str(name, **({"encoding": "utf-8"} if isinstance(name, bytes) else {}))
super().__init__(name)
def parse_arguments(self, name):
"""No parameters are available for simple string step. """No parameters are available for simple string step.
:return: `dict` of step arguments :return: `dict` of step arguments
""" """
return {} return {}
def is_matching(self, name): def is_matching(self, name: str) -> bool:
"""Match given name with the step name.""" """Match given name with the step name."""
return self.name == name return self.name == name
def get_parser(step_name): def get_parser(step_name: Any) -> StepParser:
"""Get parser by given name. """Get parser by given name."""
:param step_name: name of the step to parse if isinstance(step_name, StepParser):
:return: step parser object
:rtype: StepArgumentParser
"""
def does_support_parser_interface(obj):
return all(map(partial(hasattr, obj), ["is_matching", "parse_arguments"]))
if does_support_parser_interface(step_name):
return step_name return step_name
else:
return string(step_name) return string(step_name)

View File

@ -1,12 +1,27 @@
"""Pytest plugin entry point. Used for any fixtures needed.""" """Pytest plugin entry point. Used for any fixtures needed."""
from __future__ import annotations
from typing import TYPE_CHECKING, Callable, cast
import pytest import pytest
from . import cucumber_json, generation, gherkin_terminal_reporter, given, reporting, then, when from . import cucumber_json, generation, gherkin_terminal_reporter, given, reporting, then, when
from .utils import CONFIG_STACK from .utils import CONFIG_STACK
if TYPE_CHECKING:
from typing import Any, Generator
def pytest_addhooks(pluginmanager): from _pytest.config import Config, PytestPluginManager
from _pytest.config.argparsing import Parser
from _pytest.fixtures import FixtureRequest
from _pytest.nodes import Item
from _pytest.runner import CallInfo
from pluggy._result import _Result
from .parser import Feature, Scenario, Step
def pytest_addhooks(pluginmanager: PytestPluginManager) -> None:
"""Register plugin hooks.""" """Register plugin hooks."""
from pytest_bdd import hooks from pytest_bdd import hooks
@ -16,13 +31,13 @@ def pytest_addhooks(pluginmanager):
@given("trace") @given("trace")
@when("trace") @when("trace")
@then("trace") @then("trace")
def trace(): def trace() -> None:
"""Enter pytest's pdb trace.""" """Enter pytest's pdb trace."""
pytest.set_trace() pytest.set_trace()
@pytest.fixture @pytest.fixture
def _pytest_bdd_example(): def _pytest_bdd_example() -> dict:
"""The current scenario outline parametrization. """The current scenario outline parametrization.
This is used internally by pytest_bdd. This is used internally by pytest_bdd.
@ -35,7 +50,7 @@ def _pytest_bdd_example():
return {} return {}
def pytest_addoption(parser): def pytest_addoption(parser: Parser) -> None:
"""Add pytest-bdd options.""" """Add pytest-bdd options."""
add_bdd_ini(parser) add_bdd_ini(parser)
cucumber_json.add_options(parser) cucumber_json.add_options(parser)
@ -43,54 +58,72 @@ def pytest_addoption(parser):
gherkin_terminal_reporter.add_options(parser) gherkin_terminal_reporter.add_options(parser)
def add_bdd_ini(parser): def add_bdd_ini(parser: Parser) -> None:
parser.addini("bdd_features_base_dir", "Base features directory.") parser.addini("bdd_features_base_dir", "Base features directory.")
@pytest.mark.trylast @pytest.mark.trylast
def pytest_configure(config): def pytest_configure(config: Config) -> None:
"""Configure all subplugins.""" """Configure all subplugins."""
CONFIG_STACK.append(config) CONFIG_STACK.append(config)
cucumber_json.configure(config) cucumber_json.configure(config)
gherkin_terminal_reporter.configure(config) gherkin_terminal_reporter.configure(config)
def pytest_unconfigure(config): def pytest_unconfigure(config: Config) -> None:
"""Unconfigure all subplugins.""" """Unconfigure all subplugins."""
CONFIG_STACK.pop() CONFIG_STACK.pop()
cucumber_json.unconfigure(config) cucumber_json.unconfigure(config)
@pytest.mark.hookwrapper @pytest.mark.hookwrapper
def pytest_runtest_makereport(item, call): def pytest_runtest_makereport(item: Item, call: CallInfo) -> Generator[None, _Result, None]:
outcome = yield outcome = yield
reporting.runtest_makereport(item, call, outcome.get_result()) reporting.runtest_makereport(item, call, outcome.get_result())
@pytest.mark.tryfirst @pytest.mark.tryfirst
def pytest_bdd_before_scenario(request, feature, scenario): def pytest_bdd_before_scenario(request: FixtureRequest, feature: Feature, scenario: Scenario) -> None:
reporting.before_scenario(request, feature, scenario) reporting.before_scenario(request, feature, scenario)
@pytest.mark.tryfirst @pytest.mark.tryfirst
def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func_args, exception): def pytest_bdd_step_error(
request: FixtureRequest,
feature: Feature,
scenario: Scenario,
step: Step,
step_func: Callable,
step_func_args: dict,
exception: Exception,
) -> None:
reporting.step_error(request, feature, scenario, step, step_func, step_func_args, exception) reporting.step_error(request, feature, scenario, step, step_func, step_func_args, exception)
@pytest.mark.tryfirst @pytest.mark.tryfirst
def pytest_bdd_before_step(request, feature, scenario, step, step_func): def pytest_bdd_before_step(
request: FixtureRequest, feature: Feature, scenario: Scenario, step: Step, step_func: Callable
) -> None:
reporting.before_step(request, feature, scenario, step, step_func) reporting.before_step(request, feature, scenario, step, step_func)
@pytest.mark.tryfirst @pytest.mark.tryfirst
def pytest_bdd_after_step(request, feature, scenario, step, step_func, step_func_args): def pytest_bdd_after_step(
request: FixtureRequest,
feature: Feature,
scenario: Scenario,
step: Step,
step_func: Callable,
step_func_args: dict[str, Any],
) -> None:
reporting.after_step(request, feature, scenario, step, step_func, step_func_args) reporting.after_step(request, feature, scenario, step, step_func, step_func_args)
def pytest_cmdline_main(config): def pytest_cmdline_main(config: Config) -> int | None:
return generation.cmdline_main(config) return generation.cmdline_main(config)
def pytest_bdd_apply_tag(tag, function): def pytest_bdd_apply_tag(tag: str, function: Callable) -> Callable:
mark = getattr(pytest.mark, tag) mark = getattr(pytest.mark, tag)
return mark(function) marked = mark(function)
return cast(Callable, marked)

View File

@ -3,8 +3,20 @@
Collection of the scenario execution statuses, timing and other information Collection of the scenario execution statuses, timing and other information
that enriches the pytest test reporting. that enriches the pytest test reporting.
""" """
from __future__ import annotations
import time import time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable
from _pytest.fixtures import FixtureRequest
from _pytest.nodes import Item
from _pytest.reports import TestReport
from _pytest.runner import CallInfo
from .parser import Feature, Scenario, Step
class StepReport: class StepReport:
@ -13,7 +25,7 @@ class StepReport:
failed = False failed = False
stopped = None stopped = None
def __init__(self, step): def __init__(self, step: Step) -> None:
"""Step report constructor. """Step report constructor.
:param pytest_bdd.parser.Step step: Step. :param pytest_bdd.parser.Step step: Step.
@ -21,7 +33,7 @@ class StepReport:
self.step = step self.step = step
self.started = time.perf_counter() self.started = time.perf_counter()
def serialize(self): def serialize(self) -> dict[str, Any]:
"""Serialize the step execution report. """Serialize the step execution report.
:return: Serialized step execution report. :return: Serialized step execution report.
@ -36,7 +48,7 @@ class StepReport:
"duration": self.duration, "duration": self.duration,
} }
def finalize(self, failed): def finalize(self, failed: bool) -> None:
"""Stop collecting information and finalize the report. """Stop collecting information and finalize the report.
:param bool failed: Whether the step execution is failed. :param bool failed: Whether the step execution is failed.
@ -45,7 +57,7 @@ class StepReport:
self.failed = failed self.failed = failed
@property @property
def duration(self): def duration(self) -> float:
"""Step execution duration. """Step execution duration.
:return: Step execution duration. :return: Step execution duration.
@ -60,17 +72,17 @@ class StepReport:
class ScenarioReport: class ScenarioReport:
"""Scenario execution report.""" """Scenario execution report."""
def __init__(self, scenario, node): def __init__(self, scenario: Scenario) -> None:
"""Scenario report constructor. """Scenario report constructor.
:param pytest_bdd.parser.Scenario scenario: Scenario. :param pytest_bdd.parser.Scenario scenario: Scenario.
:param node: pytest test node object :param node: pytest test node object
""" """
self.scenario = scenario self.scenario: Scenario = scenario
self.step_reports = [] self.step_reports: list[StepReport] = []
@property @property
def current_step_report(self): def current_step_report(self) -> StepReport:
"""Get current step report. """Get current step report.
:return: Last or current step report. :return: Last or current step report.
@ -78,7 +90,7 @@ class ScenarioReport:
""" """
return self.step_reports[-1] return self.step_reports[-1]
def add_step_report(self, step_report): def add_step_report(self, step_report: StepReport) -> None:
"""Add new step report. """Add new step report.
:param step_report: New current step report. :param step_report: New current step report.
@ -86,7 +98,7 @@ class ScenarioReport:
""" """
self.step_reports.append(step_report) self.step_reports.append(step_report)
def serialize(self): def serialize(self) -> dict[str, Any]:
"""Serialize scenario execution report in order to transfer reporting from nodes in the distributed mode. """Serialize scenario execution report in order to transfer reporting from nodes in the distributed mode.
:return: Serialized report. :return: Serialized report.
@ -110,7 +122,7 @@ class ScenarioReport:
}, },
} }
def fail(self): def fail(self) -> None:
"""Stop collecting information and finalize the report as failed.""" """Stop collecting information and finalize the report as failed."""
self.current_step_report.finalize(failed=True) self.current_step_report.finalize(failed=True)
remaining_steps = self.scenario.steps[len(self.step_reports) :] remaining_steps = self.scenario.steps[len(self.step_reports) :]
@ -122,10 +134,10 @@ class ScenarioReport:
self.add_step_report(report) self.add_step_report(report)
def runtest_makereport(item, call, rep): def runtest_makereport(item: Item, call: CallInfo, rep: TestReport) -> None:
"""Store item in the report object.""" """Store item in the report object."""
try: try:
scenario_report = item.__scenario_report__ scenario_report: ScenarioReport = item.__scenario_report__
except AttributeError: except AttributeError:
pass pass
else: else:
@ -133,21 +145,36 @@ def runtest_makereport(item, call, rep):
rep.item = {"name": item.name} rep.item = {"name": item.name}
def before_scenario(request, feature, scenario): def before_scenario(request: FixtureRequest, feature: Feature, scenario: Scenario) -> None:
"""Create scenario report for the item.""" """Create scenario report for the item."""
request.node.__scenario_report__ = ScenarioReport(scenario=scenario, node=request.node) request.node.__scenario_report__ = ScenarioReport(scenario=scenario)
def step_error(request, feature, scenario, step, step_func, step_func_args, exception): def step_error(
request: FixtureRequest,
feature: Feature,
scenario: Scenario,
step: Step,
step_func: Callable,
step_func_args: dict,
exception: Exception,
) -> None:
"""Finalize the step report as failed.""" """Finalize the step report as failed."""
request.node.__scenario_report__.fail() request.node.__scenario_report__.fail()
def before_step(request, feature, scenario, step, step_func): def before_step(request: FixtureRequest, feature: Feature, scenario: Scenario, step: Step, step_func: Callable) -> None:
"""Store step start time.""" """Store step start time."""
request.node.__scenario_report__.add_step_report(StepReport(step=step)) request.node.__scenario_report__.add_step_report(StepReport(step=step))
def after_step(request, feature, scenario, step, step_func, step_func_args): def after_step(
request: FixtureRequest,
feature: Feature,
scenario: Scenario,
step: Step,
step_func: Callable,
step_func_args: dict,
) -> None:
"""Finalize the step report as successful.""" """Finalize the step report as successful."""
request.node.__scenario_report__.current_step_report.finalize(failed=False) request.node.__scenario_report__.current_step_report.finalize(failed=False)

View File

@ -10,29 +10,35 @@ test_publish_article = scenario(
scenario_name="Publishing the article", scenario_name="Publishing the article",
) )
""" """
from __future__ import annotations
import collections import collections
import os import os
import re import re
import typing from typing import TYPE_CHECKING, Callable, cast
import pytest import pytest
from _pytest.fixtures import FixtureLookupError, call_fixture_func from _pytest.fixtures import FixtureLookupError, FixtureManager, FixtureRequest, call_fixture_func
from . import exceptions from . import exceptions
from .feature import get_feature, get_features from .feature import get_feature, get_features
from .steps import get_step_fixture_name, inject_fixture from .steps import get_step_fixture_name, inject_fixture
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path
if typing.TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any, Iterable
from _pytest.mark.structures import ParameterSet from _pytest.mark.structures import ParameterSet
from .parser import Feature, Scenario, ScenarioTemplate from .parser import Feature, Scenario, ScenarioTemplate, Step
PYTHON_REPLACE_REGEX = re.compile(r"\W") PYTHON_REPLACE_REGEX = re.compile(r"\W")
ALPHA_REGEX = re.compile(r"^\d+_*") ALPHA_REGEX = re.compile(r"^\d+_*")
def find_argumented_step_fixture_name(name, type_, fixturemanager, request=None): def find_argumented_step_fixture_name(
name: str, type_: str, fixturemanager: FixtureManager, request: FixtureRequest | None = None
) -> str | None:
"""Find argumented step fixture name.""" """Find argumented step fixture name."""
# happens to be that _arg2fixturedefs is changed during the iteration so we use a copy # happens to be that _arg2fixturedefs is changed during the iteration so we use a copy
for fixturename, fixturedefs in list(fixturemanager._arg2fixturedefs.items()): for fixturename, fixturedefs in list(fixturemanager._arg2fixturedefs.items()):
@ -51,9 +57,10 @@ def find_argumented_step_fixture_name(name, type_, fixturemanager, request=None)
except FixtureLookupError: except FixtureLookupError:
continue continue
return parser_name return parser_name
return None
def _find_step_function(request, step, scenario): def _find_step_function(request: FixtureRequest, step: Step, scenario: Scenario) -> Any:
"""Match the step defined by the regular expression pattern. """Match the step defined by the regular expression pattern.
:param request: PyTest request object. :param request: PyTest request object.
@ -70,9 +77,9 @@ def _find_step_function(request, step, scenario):
except FixtureLookupError: except FixtureLookupError:
try: try:
# Could not find a fixture with the same name, let's see if there is a parser involved # Could not find a fixture with the same name, let's see if there is a parser involved
name = find_argumented_step_fixture_name(name, step.type, request._fixturemanager, request) argumented_name = find_argumented_step_fixture_name(name, step.type, request._fixturemanager, request)
if name: if argumented_name:
return request.getfixturevalue(name) return request.getfixturevalue(argumented_name)
raise raise
except FixtureLookupError: except FixtureLookupError:
raise exceptions.StepDefinitionNotFoundError( raise exceptions.StepDefinitionNotFoundError(
@ -81,7 +88,7 @@ def _find_step_function(request, step, scenario):
) )
def _execute_step_function(request, scenario, step, step_func): def _execute_step_function(request: FixtureRequest, scenario: Scenario, step: Step, step_func: Callable) -> None:
"""Execute step function. """Execute step function.
:param request: PyTest request. :param request: PyTest request.
@ -124,7 +131,7 @@ def _execute_step_function(request, scenario, step, step_func):
raise raise
def _execute_scenario(feature: "Feature", scenario: "Scenario", request): def _execute_scenario(feature: Feature, scenario: Scenario, request: FixtureRequest) -> None:
"""Execute the scenario. """Execute the scenario.
:param feature: Feature. :param feature: Feature.
@ -153,29 +160,29 @@ FakeRequest = collections.namedtuple("FakeRequest", ["module"])
def _get_scenario_decorator( def _get_scenario_decorator(
feature: "Feature", feature_name: str, templated_scenario: "ScenarioTemplate", scenario_name: str feature: Feature, feature_name: str, templated_scenario: ScenarioTemplate, scenario_name: str
): ) -> Callable[[Callable], Callable]:
# HACK: Ideally we would use `def decorator(fn)`, but we want to return a custom exception # HACK: Ideally we would use `def decorator(fn)`, but we want to return a custom exception
# when the decorator is misused. # when the decorator is misused.
# Pytest inspect the signature to determine the required fixtures, and in that case it would look # 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). # 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. # 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. # We can avoid this hack by using a pytest hook and check for misuse instead.
def decorator(*args): def decorator(*args: Callable) -> Callable:
if not args: if not args:
raise exceptions.ScenarioIsDecoratorOnly( raise exceptions.ScenarioIsDecoratorOnly(
"scenario function can only be used as a decorator. Refer to the documentation." "scenario function can only be used as a decorator. Refer to the documentation."
) )
[fn] = args [fn] = args
args = get_args(fn) func_args = get_args(fn)
# We need to tell pytest that the original function requires its fixtures, # We need to tell pytest that the original function requires its fixtures,
# otherwise indirect fixtures would not work. # otherwise indirect fixtures would not work.
@pytest.mark.usefixtures(*args) @pytest.mark.usefixtures(*func_args)
def scenario_wrapper(request, _pytest_bdd_example): def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any:
scenario = templated_scenario.render(_pytest_bdd_example) scenario = templated_scenario.render(_pytest_bdd_example)
_execute_scenario(feature, scenario, request) _execute_scenario(feature, scenario, request)
fixture_values = [request.getfixturevalue(arg) for arg in args] fixture_values = [request.getfixturevalue(arg) for arg in func_args]
return fn(*fixture_values) return fn(*fixture_values)
example_parametrizations = collect_example_parametrizations(templated_scenario) example_parametrizations = collect_example_parametrizations(templated_scenario)
@ -192,14 +199,14 @@ def _get_scenario_decorator(
scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}" scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}"
scenario_wrapper.__scenario__ = templated_scenario scenario_wrapper.__scenario__ = templated_scenario
return scenario_wrapper return cast(Callable, scenario_wrapper)
return decorator return decorator
def collect_example_parametrizations( def collect_example_parametrizations(
templated_scenario: "ScenarioTemplate", templated_scenario: ScenarioTemplate,
) -> "typing.Optional[typing.List[ParameterSet]]": ) -> list[ParameterSet] | None:
# We need to evaluate these iterators and store them as lists, otherwise # We need to evaluate these iterators and store them as lists, otherwise
# we won't be able to do the cartesian product later (the second iterator will be consumed) # we won't be able to do the cartesian product later (the second iterator will be consumed)
contexts = list(templated_scenario.examples.as_contexts()) contexts = list(templated_scenario.examples.as_contexts())
@ -209,7 +216,9 @@ def collect_example_parametrizations(
return [pytest.param(context, id="-".join(context.values())) for context in contexts] return [pytest.param(context, id="-".join(context.values())) for context in contexts]
def scenario(feature_name: str, scenario_name: str, encoding: str = "utf-8", features_base_dir=None): def scenario(
feature_name: str, scenario_name: str, encoding: str = "utf-8", features_base_dir=None
) -> Callable[[Callable], Callable]:
"""Scenario decorator. """Scenario decorator.
:param str feature_name: Feature file name. Absolute or relative to the configured feature base path. :param str feature_name: Feature file name. Absolute or relative to the configured feature base path.
@ -239,44 +248,46 @@ def scenario(feature_name: str, scenario_name: str, encoding: str = "utf-8", fea
) )
def get_features_base_dir(caller_module_path): def get_features_base_dir(caller_module_path: str) -> str:
default_base_dir = os.path.dirname(caller_module_path) default_base_dir = os.path.dirname(caller_module_path)
return get_from_ini("bdd_features_base_dir", default_base_dir) return get_from_ini("bdd_features_base_dir", default_base_dir)
def get_from_ini(key, default): def get_from_ini(key: str, default: str) -> str:
"""Get value from ini config. Return default if value has not been set. """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] config = CONFIG_STACK[-1]
value = config.getini(key) value = config.getini(key)
if not isinstance(value, str):
raise TypeError(f"Expected a string for configuration option {value!r}, got a {type(value)} instead")
return value if value != "" else default return value if value != "" else default
def make_python_name(string): def make_python_name(string: str) -> str:
"""Make python attribute name out of a given string.""" """Make python attribute name out of a given string."""
string = re.sub(PYTHON_REPLACE_REGEX, "", string.replace(" ", "_")) string = re.sub(PYTHON_REPLACE_REGEX, "", string.replace(" ", "_"))
return re.sub(ALPHA_REGEX, "", string).lower() return re.sub(ALPHA_REGEX, "", string).lower()
def make_python_docstring(string): def make_python_docstring(string: str) -> str:
"""Make a python docstring literal out of a given string.""" """Make a python docstring literal out of a given string."""
return '"""{}."""'.format(string.replace('"""', '\\"\\"\\"')) return '"""{}."""'.format(string.replace('"""', '\\"\\"\\"'))
def make_string_literal(string): def make_string_literal(string: str) -> str:
"""Make python string literal out of a given string.""" """Make python string literal out of a given string."""
return "'{}'".format(string.replace("'", "\\'")) return "'{}'".format(string.replace("'", "\\'"))
def get_python_name_generator(name): def get_python_name_generator(name: str) -> Iterable[str]:
"""Generate a sequence of suitable python names out of given arbitrary string name.""" """Generate a sequence of suitable python names out of given arbitrary string name."""
python_name = make_python_name(name) python_name = make_python_name(name)
suffix = "" suffix = ""
index = 0 index = 0
def get_name(): def get_name() -> str:
return f"test_{python_name}{suffix}" return f"test_{python_name}{suffix}"
while True: while True:
@ -285,7 +296,7 @@ def get_python_name_generator(name):
suffix = f"_{index}" suffix = f"_{index}"
def scenarios(*feature_paths, **kwargs): def scenarios(*feature_paths: str, **kwargs: Any) -> None:
"""Parse features from the paths and put all found scenarios in the caller module. """Parse features from the paths and put all found scenarios in the caller module.
:param *feature_paths: feature file paths to use for scenarios :param *feature_paths: feature file paths to use for scenarios
@ -316,7 +327,7 @@ def scenarios(*feature_paths, **kwargs):
if (scenario_object.feature.filename, scenario_name) not in module_scenarios: if (scenario_object.feature.filename, scenario_name) not in module_scenarios:
@scenario(feature.filename, scenario_name, **kwargs) @scenario(feature.filename, scenario_name, **kwargs)
def _scenario(): def _scenario() -> None:
pass # pragma: no cover pass # pragma: no cover
for test_name in get_python_name_generator(scenario_name): for test_name in get_python_name_generator(scenario_name):

View File

@ -1,4 +1,5 @@
"""pytest-bdd scripts.""" """pytest-bdd scripts."""
from __future__ import annotations
import argparse import argparse
import os.path import os.path
@ -11,14 +12,14 @@ from .generation import generate_code, parse_feature_files
MIGRATE_REGEX = re.compile(r"\s?(\w+)\s=\sscenario\((.+)\)", flags=re.MULTILINE) MIGRATE_REGEX = re.compile(r"\s?(\w+)\s=\sscenario\((.+)\)", flags=re.MULTILINE)
def migrate_tests(args): def migrate_tests(args: argparse.Namespace) -> None:
"""Migrate outdated tests to the most recent form.""" """Migrate outdated tests to the most recent form."""
path = args.path path = args.path
for file_path in glob2.iglob(os.path.join(os.path.abspath(path), "**", "*.py")): for file_path in glob2.iglob(os.path.join(os.path.abspath(path), "**", "*.py")):
migrate_tests_in_file(file_path) migrate_tests_in_file(file_path)
def migrate_tests_in_file(file_path): def migrate_tests_in_file(file_path: str) -> None:
"""Migrate all bdd-based tests in the given test file.""" """Migrate all bdd-based tests in the given test file."""
try: try:
with open(file_path, "r+") as fd: with open(file_path, "r+") as fd:
@ -37,21 +38,21 @@ def migrate_tests_in_file(file_path):
pass pass
def check_existense(file_name): def check_existense(file_name: str) -> str:
"""Check file or directory name for existence.""" """Check file or directory name for existence."""
if not os.path.exists(file_name): if not os.path.exists(file_name):
raise argparse.ArgumentTypeError(f"{file_name} is an invalid file or directory name") raise argparse.ArgumentTypeError(f"{file_name} is an invalid file or directory name")
return file_name return file_name
def print_generated_code(args): def print_generated_code(args: argparse.Namespace) -> None:
"""Print generated test code for the given filenames.""" """Print generated test code for the given filenames."""
features, scenarios, steps = parse_feature_files(args.files) features, scenarios, steps = parse_feature_files(args.files)
code = generate_code(features, scenarios, steps) code = generate_code(features, scenarios, steps)
print(code) print(code)
def main(): def main() -> None:
"""Main entry point.""" """Main entry point."""
parser = argparse.ArgumentParser(prog="pytest-bdd") parser = argparse.ArgumentParser(prog="pytest-bdd")
subparsers = parser.add_subparsers(help="sub-command help", dest="command") subparsers = parser.add_subparsers(help="sub-command help", dest="command")

View File

@ -34,16 +34,22 @@ def given_beautiful_article(article):
pass pass
""" """
from __future__ import annotations
import typing
import pytest import pytest
from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureDef, FixtureRequest
from .parsers import get_parser from .parsers import get_parser
from .types import GIVEN, THEN, WHEN from .types import GIVEN, THEN, WHEN
from .utils import get_caller_module_locals from .utils import get_caller_module_locals
if typing.TYPE_CHECKING:
from typing import Any, Callable
def get_step_fixture_name(name, type_):
def get_step_fixture_name(name: str, type_: str) -> str:
"""Get step fixture name. """Get step fixture name.
:param name: string :param name: string
@ -54,7 +60,11 @@ def get_step_fixture_name(name, type_):
return f"pytestbdd_{type_}_{name}" return f"pytestbdd_{type_}_{name}"
def given(name, converters=None, target_fixture=None): def given(
name: Any,
converters: dict[str, Callable] | None = None,
target_fixture: str | None = None,
) -> Callable:
"""Given step decorator. """Given step decorator.
:param name: Step name or a parser object. :param name: Step name or a parser object.
@ -67,7 +77,7 @@ def given(name, converters=None, target_fixture=None):
return _step_decorator(GIVEN, name, converters=converters, target_fixture=target_fixture) return _step_decorator(GIVEN, name, converters=converters, target_fixture=target_fixture)
def when(name, converters=None, target_fixture=None): def when(name: Any, converters: dict[str, Callable] | None = None, target_fixture: str | None = None) -> Callable:
"""When step decorator. """When step decorator.
:param name: Step name or a parser object. :param name: Step name or a parser object.
@ -80,7 +90,7 @@ def when(name, converters=None, target_fixture=None):
return _step_decorator(WHEN, name, converters=converters, target_fixture=target_fixture) return _step_decorator(WHEN, name, converters=converters, target_fixture=target_fixture)
def then(name, converters=None, target_fixture=None): def then(name: Any, converters: dict[str, Callable] | None = None, target_fixture: str | None = None) -> Callable:
"""Then step decorator. """Then step decorator.
:param name: Step name or a parser object. :param name: Step name or a parser object.
@ -93,7 +103,12 @@ def then(name, converters=None, target_fixture=None):
return _step_decorator(THEN, name, converters=converters, target_fixture=target_fixture) return _step_decorator(THEN, name, converters=converters, target_fixture=target_fixture)
def _step_decorator(step_type, step_name, converters=None, target_fixture=None): def _step_decorator(
step_type: str,
step_name: Any,
converters: dict[str, Callable] | None = None,
target_fixture: str | None = None,
) -> Callable:
"""Step decorator for the type and the name. """Step decorator for the type and the name.
:param str step_type: Step type (GIVEN, WHEN or THEN). :param str step_type: Step type (GIVEN, WHEN or THEN).
@ -104,14 +119,14 @@ def _step_decorator(step_type, step_name, converters=None, target_fixture=None):
:return: Decorator function for the step. :return: Decorator function for the step.
""" """
def decorator(func): def decorator(func: Callable) -> Callable:
step_func = func step_func = func
parser_instance = get_parser(step_name) parser_instance = get_parser(step_name)
parsed_step_name = parser_instance.name parsed_step_name = parser_instance.name
step_func.__name__ = str(parsed_step_name) step_func.__name__ = str(parsed_step_name)
def lazy_step_func(): def lazy_step_func() -> Callable:
return step_func return step_func
step_func.step_type = step_type step_func.step_type = step_type
@ -136,7 +151,7 @@ def _step_decorator(step_type, step_name, converters=None, target_fixture=None):
return decorator return decorator
def inject_fixture(request, arg, value): def inject_fixture(request: FixtureRequest, arg: str, value: Any) -> None:
"""Inject fixture into pytest fixture request. """Inject fixture into pytest fixture request.
:param request: pytest fixture request :param request: pytest fixture request
@ -157,7 +172,7 @@ def inject_fixture(request, arg, value):
old_fd = request._fixture_defs.get(arg) old_fd = request._fixture_defs.get(arg)
add_fixturename = arg not in request.fixturenames add_fixturename = arg not in request.fixturenames
def fin(): def fin() -> None:
request._fixturemanager._arg2fixturedefs[arg].remove(fd) request._fixturemanager._arg2fixturedefs[arg].remove(fd)
request._fixture_defs[arg] = old_fd request._fixture_defs[arg] = old_fd

View File

@ -1,4 +1,5 @@
"""Common type definitions.""" """Common type definitions."""
from __future__ import annotations
FEATURE = "feature" FEATURE = "feature"
SCENARIO_OUTLINE = "scenario outline" SCENARIO_OUTLINE = "scenario outline"

View File

@ -1,4 +1,6 @@
"""Various utility functions.""" """Various utility functions."""
from __future__ import annotations
import base64 import base64
import pickle import pickle
import re import re
@ -7,12 +9,15 @@ from inspect import getframeinfo, signature
from sys import _getframe from sys import _getframe
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from typing import Any, Callable
from _pytest.config import Config
from _pytest.pytester import RunResult from _pytest.pytester import RunResult
CONFIG_STACK = [] CONFIG_STACK: list[Config] = []
def get_args(func): def get_args(func: Callable) -> list[str]:
"""Get a list of argument names for a function. """Get a list of argument names for a function.
:param func: The function to inspect. :param func: The function to inspect.
@ -24,7 +29,7 @@ def get_args(func):
return [param.name for param in params if param.kind == param.POSITIONAL_OR_KEYWORD] return [param.name for param in params if param.kind == param.POSITIONAL_OR_KEYWORD]
def get_caller_module_locals(depth=2): def get_caller_module_locals(depth: int = 2) -> dict[str, Any]:
"""Get the caller module locals dictionary. """Get the caller module locals dictionary.
We use sys._getframe instead of inspect.stack(0) because the latter is way slower, since it iterates over We use sys._getframe instead of inspect.stack(0) because the latter is way slower, since it iterates over
@ -33,7 +38,7 @@ def get_caller_module_locals(depth=2):
return _getframe(depth).f_locals return _getframe(depth).f_locals
def get_caller_module_path(depth=2): def get_caller_module_path(depth: int = 2) -> str:
"""Get the caller module path. """Get the caller module path.
We use sys._getframe instead of inspect.stack(0) because the latter is way slower, since it iterates over We use sys._getframe instead of inspect.stack(0) because the latter is way slower, since it iterates over
@ -47,7 +52,7 @@ _DUMP_START = "_pytest_bdd_>>>"
_DUMP_END = "<<<_pytest_bdd_" _DUMP_END = "<<<_pytest_bdd_"
def dump_obj(*objects): def dump_obj(*objects: Any) -> None:
"""Dump objects to stdout so that they can be inspected by the test suite.""" """Dump objects to stdout so that they can be inspected by the test suite."""
for obj in objects: for obj in objects:
dump = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL) dump = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
@ -55,7 +60,7 @@ def dump_obj(*objects):
print(f"{_DUMP_START}{encoded}{_DUMP_END}") print(f"{_DUMP_START}{encoded}{_DUMP_END}")
def collect_dumped_objects(result: "RunResult"): def collect_dumped_objects(result: RunResult) -> list:
"""Parse all the objects dumped with `dump_object` from the result. """Parse all the objects dumped with `dump_object` from the result.
Note: You must run the result with output to stdout enabled. Note: You must run the result with output to stdout enabled.

View File

@ -34,10 +34,14 @@ install_requires =
py py
pytest>=5.0 pytest>=5.0
tests_require = tox
packages = pytest_bdd packages = pytest_bdd
include_package_data = True include_package_data = True
[options.extras_require]
testing =
tox
mypy==0.910
[options.entry_points] [options.entry_points]
pytest11 = pytest11 =
pytest-bdd = pytest_bdd.plugin pytest-bdd = pytest_bdd.plugin

View File

@ -1,10 +1,16 @@
"""Test cucumber json output.""" """Test cucumber json output."""
from __future__ import annotations
import json import json
import os.path import os.path
import textwrap import textwrap
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from _pytest.pytester import RunResult, Testdir
def runandparse(testdir, *args): def runandparse(testdir: Testdir, *args: Any) -> tuple[RunResult, list[dict[str, Any]]]:
"""Run tests in testdir and parse json output.""" """Run tests in testdir and parse json output."""
resultpath = testdir.tmpdir.join("cucumber.json") resultpath = testdir.tmpdir.join("cucumber.json")
result = testdir.runpytest(f"--cucumberjson={resultpath}", "-s", *args) result = testdir.runpytest(f"--cucumberjson={resultpath}", "-s", *args)
@ -16,10 +22,10 @@ def runandparse(testdir, *args):
class OfType: class OfType:
"""Helper object to help compare object type to initialization type""" """Helper object to help compare object type to initialization type"""
def __init__(self, type=None): def __init__(self, type: type = None) -> None:
self.type = type self.type = type
def __eq__(self, other): def __eq__(self, other: object) -> bool:
return isinstance(other, self.type) if self.type else True return isinstance(other, self.type) if self.type else True

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import textwrap import textwrap
import pytest import pytest
@ -43,7 +45,7 @@ def test_default_output_should_be_the_same_as_regular_terminal_reporter(testdir)
regular.assert_outcomes(passed=1, failed=0) regular.assert_outcomes(passed=1, failed=0)
gherkin.assert_outcomes(passed=1, failed=0) gherkin.assert_outcomes(passed=1, failed=0)
def parse_lines(lines): def parse_lines(lines: list[str]) -> list[str]:
return [line for line in lines if not line.startswith("===")] return [line for line in lines if not line.startswith("===")]
assert all(l1 == l2 for l1, l2 in zip(parse_lines(regular.stdout.lines), parse_lines(gherkin.stdout.lines))) assert all(l1 == l2 for l1, l2 in zip(parse_lines(regular.stdout.lines), parse_lines(gherkin.stdout.lines)))

View File

@ -7,10 +7,10 @@ import pytest
class OfType: class OfType:
"""Helper object comparison to which is always 'equal'.""" """Helper object comparison to which is always 'equal'."""
def __init__(self, type=None): def __init__(self, type: type = None) -> None:
self.type = type self.type = type
def __eq__(self, other): def __eq__(self, other: object) -> bool:
return isinstance(other, self.type) if self.type else True return isinstance(other, self.type) if self.type else True

View File

@ -1,6 +1,13 @@
from __future__ import annotations
import typing
import pytest import pytest
from packaging.utils import Version from packaging.utils import Version
if typing.TYPE_CHECKING:
from _pytest.pytester import RunResult
PYTEST_VERSION = Version(pytest.__version__) PYTEST_VERSION = Version(pytest.__version__)
PYTEST_6 = PYTEST_VERSION >= Version("6") PYTEST_6 = PYTEST_VERSION >= Version("6")
@ -8,32 +15,32 @@ PYTEST_6 = PYTEST_VERSION >= Version("6")
if PYTEST_6: if PYTEST_6:
def assert_outcomes( def assert_outcomes(
result, result: RunResult,
passed=0, passed: int = 0,
skipped=0, skipped: int = 0,
failed=0, failed: int = 0,
errors=0, errors: int = 0,
xpassed=0, xpassed: int = 0,
xfailed=0, xfailed: int = 0,
): ) -> None:
"""Compatibility function for result.assert_outcomes""" """Compatibility function for result.assert_outcomes"""
return result.assert_outcomes( result.assert_outcomes(
errors=errors, passed=passed, skipped=skipped, failed=failed, xpassed=xpassed, xfailed=xfailed errors=errors, passed=passed, skipped=skipped, failed=failed, xpassed=xpassed, xfailed=xfailed
) )
else: else:
def assert_outcomes( def assert_outcomes(
result, result: RunResult,
passed=0, passed: int = 0,
skipped=0, skipped: int = 0,
failed=0, failed: int = 0,
errors=0, errors: int = 0,
xpassed=0, xpassed: int = 0,
xfailed=0, xfailed: int = 0,
): ) -> None:
"""Compatibility function for result.assert_outcomes""" """Compatibility function for result.assert_outcomes"""
return result.assert_outcomes( result.assert_outcomes(
error=errors, # Pytest < 6 uses the singular form error=errors, # Pytest < 6 uses the singular form
passed=passed, passed=passed,
skipped=skipped, skipped=skipped,

View File

@ -34,6 +34,12 @@ commands = {env:_PYTEST_CMD:pytest} {env:_PYTEST_MORE_ARGS:} {posargs:-vvl}
deps = black==22.1.0 deps = black==22.1.0
commands = black --check --verbose setup.py docs pytest_bdd tests commands = black --check --verbose setup.py docs pytest_bdd tests
[testenv:mypy]
deps =
mypy==0.931
types-setuptools
commands = mypy
[gh-actions] [gh-actions]
python = python =
3.7: py37 3.7: py37