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

1"""pytest plugin entry point for pytest-beehave.""" 

2 

3from __future__ import annotations 

4 

5import importlib.util 

6import sys 

7from pathlib import Path 

8 

9import pytest 

10 

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 

29 

30features_path_key: pytest.StashKey[Path] = pytest.StashKey() 

31 

32 

33class _PytestTerminalWriter: 

34 """Adapter wrapping pytest's terminal writer to match TerminalWriterProtocol.""" 

35 

36 def __init__(self, config: pytest.Config) -> None: 

37 """Initialise the adapter. 

38 

39 Args: 

40 config: The pytest Config object. 

41 """ 

42 self._config = config 

43 

44 def line(self, text: str = "") -> None: 

45 """Write a line to the terminal. 

46 

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() 

57 

58 

59def _exit_if_missing_configured_path(rootdir: Path, path: Path) -> None: 

60 """Exit pytest if features_path is explicitly configured but missing. 

61 

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) 

71 

72 

73def _run_beehave_sync(config: pytest.Config, path: Path) -> None: 

74 """Bootstrap, assign IDs, and sync stubs for the features directory. 

75 

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 ) 

94 

95 

96def _html_available() -> bool: 

97 """Return True if pytest-html is importable. 

98 

99 Returns: 

100 True when pytest-html is installed. 

101 """ 

102 return importlib.util.find_spec("pytest_html") is not None 

103 

104 

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. 

108 

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 ) 

119 

120 

121def _register_output_plugins(config: pytest.Config, rootdir: Path) -> None: 

122 """Register terminal and HTML output plugins based on configuration. 

123 

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") 

133 

134 

135def pytest_addoption(parser: pytest.Parser) -> None: 

136 """Register --beehave-hatch and --beehave-hatch-force CLI options. 

137 

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 ) 

154 

155 

156def pytest_configure(config: pytest.Config) -> None: 

157 """Read beehave configuration, bootstrap directory, sync stubs. 

158 

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)