Coverage for pytest_beehave/plugin.py: 100%
79 statements
« prev ^ index » next coverage.py v7.8.0, created at 2026-04-21 04:49 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2026-04-21 04:49 +0000
1"""pytest plugin entry point for pytest-beehave."""
3from __future__ import annotations
5import importlib.util
6import sys
7from pathlib import Path
9import pytest
11from pytest_beehave.bootstrap import bootstrap_features_directory
12from pytest_beehave.config import (
13 is_explicitly_configured,
14 read_stub_format,
15 resolve_features_path,
16 show_steps_in_html,
17 show_steps_in_terminal,
18)
19from pytest_beehave.hatch import run_hatch
20from pytest_beehave.html_steps_plugin import HtmlStepsPlugin
21from pytest_beehave.id_generator import assign_ids
22from pytest_beehave.reporter import (
23 report_bootstrap,
24 report_id_write_back,
25 report_sync_actions,
26)
27from pytest_beehave.steps_reporter import StepsReporter
28from pytest_beehave.sync_engine import run_sync
30features_path_key: pytest.StashKey[Path] = pytest.StashKey()
33class _PytestTerminalWriter:
34 """Adapter wrapping pytest's terminal writer to match TerminalWriterProtocol."""
36 def __init__(self, config: pytest.Config) -> None:
37 """Initialise the adapter.
39 Args:
40 config: The pytest Config object.
41 """
42 self._config = config
44 def line(self, text: str = "") -> None:
45 """Write a line to the terminal.
47 Args:
48 text: The line to write.
49 """
50 try:
51 config = self._config
52 writer = config.get_terminal_writer()
53 writer.line(text)
54 except (AssertionError, AttributeError):
55 sys.stdout.write(text + "\n")
56 sys.stdout.flush()
59def _exit_if_missing_configured_path(rootdir: Path, path: Path) -> None:
60 """Exit pytest if features_path is explicitly configured but missing.
62 Args:
63 rootdir: Project root directory.
64 path: Resolved features path.
65 """
66 if not path.exists() and is_explicitly_configured(rootdir):
67 message = f"[beehave] features_path not found: {path}"
68 sys.stderr.write(message + "\n")
69 sys.stderr.flush()
70 pytest.exit(message, returncode=1)
73def _run_beehave_sync(config: pytest.Config, path: Path) -> None:
74 """Bootstrap, assign IDs, and sync stubs for the features directory.
76 Args:
77 config: The pytest Config object.
78 path: The resolved features directory path.
79 """
80 writer = _PytestTerminalWriter(config)
81 report_bootstrap(writer, bootstrap_features_directory(path))
82 errors = assign_ids(path)
83 report_id_write_back(writer, errors)
84 if errors:
85 pytest.exit("[beehave] untagged Examples in read-only files", returncode=1)
86 try:
87 stub_format = read_stub_format(config.rootpath)
88 except SystemExit as exc:
89 pytest.exit(str(exc), returncode=1)
90 report_sync_actions(
91 writer,
92 run_sync(path, config.rootpath / "tests" / "features", stub_format=stub_format),
93 )
96def _html_available() -> bool:
97 """Return True if pytest-html is importable.
99 Returns:
100 True when pytest-html is installed.
101 """
102 return importlib.util.find_spec("pytest_html") is not None
105@pytest.hookimpl(hookwrapper=True)
106def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) -> object:
107 """Attach the test docstring to the report for steps display.
109 Args:
110 item: The test item being reported.
111 call: The call info (unused).
112 """
113 outcome = yield
114 report = outcome.get_result()
115 test_object = getattr(item, "obj", None)
116 report._beehave_docstring = (
117 getattr(test_object, "__doc__", None) or "" if test_object is not None else ""
118 )
121def _register_output_plugins(config: pytest.Config, rootdir: Path) -> None:
122 """Register terminal and HTML output plugins based on configuration.
124 Args:
125 config: The pytest Config object.
126 rootdir: Project root directory.
127 """
128 pm = config.pluginmanager
129 if show_steps_in_terminal(rootdir):
130 pm.register(StepsReporter(config), "beehave-steps-reporter")
131 if show_steps_in_html(rootdir) and _html_available():
132 pm.register(HtmlStepsPlugin(), "beehave-html-steps")
135def pytest_addoption(parser: pytest.Parser) -> None:
136 """Register --beehave-hatch and --beehave-hatch-force CLI options.
138 Args:
139 parser: The pytest argument parser.
140 """
141 group = parser.getgroup("beehave")
142 group.addoption(
143 "--beehave-hatch",
144 action="store_true",
145 default=False,
146 help="Generate bee-themed example features directory and exit.",
147 )
148 group.addoption(
149 "--beehave-hatch-force",
150 action="store_true",
151 default=False,
152 help="Overwrite existing content when using --beehave-hatch.",
153 )
156def pytest_configure(config: pytest.Config) -> None:
157 """Read beehave configuration, bootstrap directory, sync stubs.
159 Args:
160 config: The pytest Config object (provides rootdir and stash).
161 """
162 rootdir = config.rootpath
163 path = resolve_features_path(rootdir)
164 if config.getoption("--beehave-hatch", default=False):
165 force = bool(config.getoption("--beehave-hatch-force", default=False))
166 try:
167 written = run_hatch(path, force)
168 except SystemExit as exc:
169 pytest.exit(str(exc), returncode=1)
170 writer = _PytestTerminalWriter(config)
171 for entry in written:
172 writer.line(f"[beehave] HATCH {entry}")
173 pytest.exit("[beehave] hatch complete", returncode=0)
174 _exit_if_missing_configured_path(rootdir, path)
175 config.stash[features_path_key] = path
176 if path.exists():
177 _run_beehave_sync(config, path)
178 _register_output_plugins(config, rootdir)