pyprodtest 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. _pyprodtest/__init__.py +0 -0
  2. _pyprodtest/config.py +163 -0
  3. _pyprodtest/decorator.py +47 -0
  4. _pyprodtest/hooks.py +284 -0
  5. _pyprodtest/input_acceptors.py +63 -0
  6. _pyprodtest/observers/__init__.py +9 -0
  7. _pyprodtest/observers/csv_report/__init__.py +5 -0
  8. _pyprodtest/observers/csv_report/csv_observer.py +67 -0
  9. _pyprodtest/observers/html_report/__init__.py +5 -0
  10. _pyprodtest/observers/html_report/html_observer.py +69 -0
  11. _pyprodtest/observers/html_report/templates/report.html +131 -0
  12. _pyprodtest/observers/json_report/__init__.py +5 -0
  13. _pyprodtest/observers/json_report/json_observer.py +48 -0
  14. _pyprodtest/observers/pdf_report/__init__.py +5 -0
  15. _pyprodtest/observers/pdf_report/pdf_observer.py +334 -0
  16. _pyprodtest/observers/test_observer.py +24 -0
  17. _pyprodtest/observers/web_ui/__init__.py +5 -0
  18. _pyprodtest/observers/web_ui/app.py +41 -0
  19. _pyprodtest/observers/web_ui/input_acceptor.py +24 -0
  20. _pyprodtest/observers/web_ui/observer.py +23 -0
  21. _pyprodtest/observers/web_ui/server.py +71 -0
  22. _pyprodtest/observers/web_ui/state.py +106 -0
  23. _pyprodtest/observers/web_ui/static/app.js +290 -0
  24. _pyprodtest/observers/web_ui/templates/index.html +52 -0
  25. _pyprodtest/report_settings.py +23 -0
  26. _pyprodtest/test_record.py +45 -0
  27. _pyprodtest/web_assets/__init__.py +1 -0
  28. _pyprodtest/web_assets/pico.min.css +4 -0
  29. _pyprodtest/web_assets/theme.css +247 -0
  30. pyprodtest/__init__.py +11 -0
  31. pyprodtest-0.1.0.dist-info/METADATA +141 -0
  32. pyprodtest-0.1.0.dist-info/RECORD +36 -0
  33. pyprodtest-0.1.0.dist-info/WHEEL +5 -0
  34. pyprodtest-0.1.0.dist-info/entry_points.txt +2 -0
  35. pyprodtest-0.1.0.dist-info/licenses/LICENSE +21 -0
  36. pyprodtest-0.1.0.dist-info/top_level.txt +2 -0
File without changes
_pyprodtest/config.py ADDED
@@ -0,0 +1,163 @@
1
+ """Load and validate project configuration from pyprodtest.yaml."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ import yaml
8
+
9
+ DEFAULT_UI_NAME = "Production test execution"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class UiConfig:
14
+ """Configuration for the live operator UI."""
15
+
16
+ enabled: bool = True
17
+ host: str = "127.0.0.1"
18
+ port: int = 8765
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ReportsConfig:
23
+ """Configuration shared by the final report observers."""
24
+
25
+ output: str = "pyprodtest-report"
26
+ html: bool = True
27
+ json: bool = True
28
+ csv: bool = True
29
+ pdf: bool = True
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class PyProdTestConfig:
34
+ """Validated settings for one PyProdTest session."""
35
+
36
+ name: str = DEFAULT_UI_NAME
37
+ tests: list[str] | None = None
38
+ ui: UiConfig = field(default_factory=UiConfig)
39
+ reports: ReportsConfig = field(default_factory=ReportsConfig)
40
+
41
+
42
+ def load_config(rootpath: Path) -> PyProdTestConfig:
43
+ """Load pyprodtest.yaml from the pytest project root."""
44
+ config_path = rootpath / "pyprodtest.yaml"
45
+ if not config_path.exists():
46
+ return PyProdTestConfig()
47
+
48
+ try:
49
+ document = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
50
+ except yaml.YAMLError as error:
51
+ raise pytest.UsageError(
52
+ f"Invalid PyProdTest configuration {config_path}: {error}"
53
+ ) from error
54
+ if not isinstance(document, dict):
55
+ raise pytest.UsageError(
56
+ f"PyProdTest configuration {config_path} must contain a YAML mapping"
57
+ )
58
+
59
+ name = _non_empty_string(document.get("name", DEFAULT_UI_NAME), "name")
60
+ tests = _load_tests(document.get("tests"))
61
+ ui = _mapping(document.get("ui", {}), "ui")
62
+ reports = _mapping(document.get("reports", {}), "reports")
63
+ return PyProdTestConfig(
64
+ name=name,
65
+ tests=tests,
66
+ ui=UiConfig(
67
+ enabled=_boolean(ui.get("enabled", True), "ui.enabled"),
68
+ host=_non_empty_string(ui.get("host", "127.0.0.1"), "ui.host"),
69
+ port=_port(ui.get("port", 8765)),
70
+ ),
71
+ reports=ReportsConfig(
72
+ output=_non_empty_string(
73
+ reports.get("output", "pyprodtest-report"), "reports.output"
74
+ ),
75
+ html=_boolean(reports.get("html", True), "reports.html"),
76
+ json=_boolean(reports.get("json", True), "reports.json"),
77
+ csv=_boolean(reports.get("csv", True), "reports.csv"),
78
+ pdf=_boolean(reports.get("pdf", True), "reports.pdf"),
79
+ ),
80
+ )
81
+
82
+
83
+ def apply_test_plan(
84
+ config: pytest.Config, items: list[pytest.Item], plan: list[str] | None
85
+ ) -> None:
86
+ """Select and order collected items using the configured test plan."""
87
+ if plan is None:
88
+ return
89
+
90
+ ranked = [(item, _selection_index(item.nodeid, plan)) for item in items]
91
+ matched = {plan[index] for _, index in ranked if index < len(plan)}
92
+ unmatched = [selection for selection in plan if selection not in matched]
93
+ if unmatched:
94
+ formatted = "\n - ".join(unmatched)
95
+ raise pytest.UsageError(
96
+ f"PyProdTest plan entries matched no tests:\n - {formatted}"
97
+ )
98
+
99
+ ranked.sort(key=lambda entry: entry[1])
100
+ selected = [item for item, index in ranked if index < len(plan)]
101
+ deselected = [item for item, index in ranked if index == len(plan)]
102
+ items[:] = selected
103
+ if deselected:
104
+ config.hook.pytest_deselected(items=deselected)
105
+
106
+
107
+ def _load_tests(value: object) -> list[str] | None:
108
+ if value is None:
109
+ return None
110
+ if not isinstance(value, list) or not all(
111
+ isinstance(selection, str) and selection for selection in value
112
+ ):
113
+ raise pytest.UsageError(
114
+ "PyProdTest configuration 'tests' must be a list of paths or node IDs"
115
+ )
116
+ return [selection.replace("\\", "/") for selection in value]
117
+
118
+
119
+ def _mapping(value: object, setting: str) -> dict[str, object]:
120
+ if not isinstance(value, dict):
121
+ raise pytest.UsageError(
122
+ f"PyProdTest configuration '{setting}' must be a mapping"
123
+ )
124
+ return value
125
+
126
+
127
+ def _boolean(value: object, setting: str) -> bool:
128
+ if not isinstance(value, bool):
129
+ raise pytest.UsageError(
130
+ f"PyProdTest configuration '{setting}' must be true or false"
131
+ )
132
+ return value
133
+
134
+
135
+ def _non_empty_string(value: object, setting: str) -> str:
136
+ if not isinstance(value, str) or not value.strip():
137
+ raise pytest.UsageError(
138
+ f"PyProdTest configuration '{setting}' must be a non-empty string"
139
+ )
140
+ return value.strip()
141
+
142
+
143
+ def _port(value: object) -> int:
144
+ if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 65535:
145
+ raise pytest.UsageError(
146
+ "PyProdTest configuration 'ui.port' must be an integer from 0 to 65535"
147
+ )
148
+ return value
149
+
150
+
151
+ def _selection_index(nodeid: str, plan: list[str]) -> int:
152
+ return next(
153
+ (index for index, selection in enumerate(plan) if _matches(nodeid, selection)),
154
+ len(plan),
155
+ )
156
+
157
+
158
+ def _matches(nodeid: str, selection: str) -> bool:
159
+ return (
160
+ nodeid == selection
161
+ or nodeid.startswith(f"{selection}::")
162
+ or nodeid.startswith(f"{selection}[")
163
+ )
@@ -0,0 +1,47 @@
1
+ """
2
+ Additional decorators for PyProdTest tests.
3
+ These decorators add metadata to test functions and are optional.
4
+ """
5
+
6
+
7
+ def info(name, desc):
8
+ """
9
+ Adds name and description metadata to a test function.
10
+ """
11
+
12
+ def wrapper(fn):
13
+ fn.test_meta = getattr(fn, "test_meta", {})
14
+ fn.test_meta["name"] = name
15
+ fn.test_meta["desc"] = desc
16
+ return fn
17
+
18
+ return wrapper
19
+
20
+
21
+ def req(*reqs):
22
+ """
23
+ Adds requirement IDs to the metadata.
24
+ """
25
+
26
+ def wrapper(fn):
27
+ fn.test_meta = getattr(fn, "test_meta", {})
28
+ fn.test_meta["requirements"] = list(reqs)
29
+ return fn
30
+
31
+ return wrapper
32
+
33
+
34
+ def step(*steps):
35
+ """
36
+ Adds step metadata to the metadata
37
+ """
38
+
39
+ def wrapper(fn):
40
+ fn.test_meta = getattr(fn, "test_meta", {})
41
+ existing_steps = fn.test_meta.get("steps", [])
42
+ # Decorators are applied from the bottom upwards, so prepend each
43
+ # group to retain the order in which stacked decorators are written.
44
+ fn.test_meta["steps"] = [*steps, *existing_steps]
45
+ return fn
46
+
47
+ return wrapper
_pyprodtest/hooks.py ADDED
@@ -0,0 +1,284 @@
1
+ """Pytest plugin hooks for capturing and forwarding test information."""
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from _pyprodtest.config import PyProdTestConfig, apply_test_plan, load_config
12
+ from _pyprodtest.input_acceptors import ConsoleInputAcceptor, InputAcceptor, TestInput
13
+ from _pyprodtest.observers import (
14
+ CsvObserver,
15
+ HtmlObserver,
16
+ JsonObserver,
17
+ PdfObserver,
18
+ TestObserver,
19
+ )
20
+ from _pyprodtest.observers.web_ui import WebUi
21
+ from _pyprodtest.report_settings import ReportSettings
22
+ from _pyprodtest.test_record import CapturedLog, TestRecord
23
+
24
+ LOGGER = logging.getLogger(__name__)
25
+
26
+
27
+ @dataclass
28
+ class PluginState:
29
+ """State owned by one pytest session."""
30
+
31
+ observers: list[TestObserver] = field(default_factory=list)
32
+ records: dict[str, TestRecord] = field(default_factory=dict)
33
+ current_test_nodeid: str | None = None
34
+ log_handler: logging.Handler | None = None
35
+ input_acceptor: InputAcceptor = field(default_factory=ConsoleInputAcceptor)
36
+ report_settings: ReportSettings = field(default_factory=ReportSettings)
37
+ cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list)
38
+ config: PyProdTestConfig = field(default_factory=PyProdTestConfig)
39
+
40
+ def on_tests_collected(self, test_records: list[TestRecord]) -> None:
41
+ for observer in self.observers:
42
+ observer.on_tests_collected(test_records)
43
+
44
+ def on_test_run(self, test_record: TestRecord) -> None:
45
+ for observer in self.observers:
46
+ observer.on_test_run(test_record)
47
+
48
+ def on_test_end(self, test_record: TestRecord) -> None:
49
+ for observer in self.observers:
50
+ observer.on_test_end(test_record)
51
+
52
+
53
+ _state: PluginState | None = None
54
+
55
+
56
+ @pytest.fixture(scope="session")
57
+ def report() -> ReportSettings:
58
+ """Return mutable settings for the final standalone HTML report."""
59
+ if _state is None:
60
+ raise RuntimeError("PyProdTest is not configured")
61
+ return _state.report_settings
62
+
63
+
64
+ @pytest.fixture(scope="session")
65
+ def input(request: pytest.FixtureRequest) -> TestInput:
66
+ """Return input from the acceptor selected by the plugin's run mode."""
67
+ if _state is None:
68
+ raise RuntimeError("PyProdTest is not configured")
69
+
70
+ def accept(prompt: str, input_type: type[str] | type[bool] = str) -> str | bool:
71
+ capture_manager = request.config.pluginmanager.get_plugin("capturemanager")
72
+ if capture_manager is None:
73
+ return _state.input_acceptor.accept(prompt, input_type)
74
+
75
+ # pytest's standard disabled-capture context leaves stdin blocked.
76
+ # Explicitly include stdin while suspending capture for the prompt.
77
+ capture_manager.suspend(in_=True)
78
+ try:
79
+ return _state.input_acceptor.accept(prompt, input_type)
80
+ finally:
81
+ capture_manager.resume()
82
+
83
+ return accept
84
+
85
+
86
+ class TestLogHandler(logging.Handler):
87
+ """Attach log records to the currently executing test."""
88
+
89
+ def emit(self, record: logging.LogRecord) -> None:
90
+ if _state is None or _state.current_test_nodeid is None:
91
+ return
92
+
93
+ test_record = _state.records.get(_state.current_test_nodeid)
94
+ if test_record is not None:
95
+ test_record.logs.append(CapturedLog.from_record(record))
96
+
97
+
98
+ def pytest_configure(config: pytest.Config) -> None:
99
+ """Create isolated plugin state for this pytest session."""
100
+ global _state
101
+
102
+ _enable_live_logging(config)
103
+
104
+ project_config = load_config(config.rootpath)
105
+ report_settings = ReportSettings.from_output_path(
106
+ _timestamped_report_output(project_config.reports.output)
107
+ )
108
+ collect_only = config.getoption("--collect-only")
109
+ observers, cleanup_callbacks = _create_report_observers(
110
+ project_config, report_settings, collect_only=collect_only
111
+ )
112
+ input_acceptor: InputAcceptor = ConsoleInputAcceptor()
113
+ if project_config.ui.enabled and not collect_only:
114
+ web_ui = WebUi(
115
+ host=project_config.ui.host,
116
+ port=project_config.ui.port,
117
+ name=project_config.name,
118
+ )
119
+ web_ui.start(open_browser=True)
120
+ LOGGER.info("PyProdTest web UI: %s", web_ui.url)
121
+ observers.append(web_ui.observer)
122
+ input_acceptor = web_ui.input_acceptor
123
+ cleanup_callbacks.append(web_ui.finish_and_stop)
124
+
125
+ root_logger = logging.getLogger()
126
+ handler = TestLogHandler()
127
+ root_logger.addHandler(handler)
128
+ if not root_logger.isEnabledFor(logging.INFO):
129
+ root_logger.setLevel(logging.INFO)
130
+
131
+ _state = PluginState(
132
+ observers=observers,
133
+ log_handler=handler,
134
+ input_acceptor=input_acceptor,
135
+ report_settings=report_settings,
136
+ cleanup_callbacks=cleanup_callbacks,
137
+ config=project_config,
138
+ )
139
+ LOGGER.debug("PyProdTest observers configured: %s", observers)
140
+
141
+
142
+ def _timestamped_report_output(
143
+ output: str | Path, timestamp: datetime | None = None
144
+ ) -> Path:
145
+ """Append a filesystem-safe session timestamp to a report basename."""
146
+ output_path = Path(output)
147
+ timestamp = timestamp or datetime.now().astimezone()
148
+ suffix = timestamp.strftime("%Y%m%d-%H%M%S")
149
+ return output_path.parent / f"{output_path.name}-{suffix}"
150
+
151
+
152
+ def _create_report_observers(
153
+ project_config: PyProdTestConfig,
154
+ report_settings: ReportSettings,
155
+ *,
156
+ collect_only: bool,
157
+ ) -> tuple[list[TestObserver], list[Callable[[], None]]]:
158
+ """Compose only the report observers enabled for this session."""
159
+ observers: list[TestObserver] = []
160
+ cleanup_callbacks: list[Callable[[], None]] = []
161
+
162
+ if project_config.reports.html:
163
+ html_observer = HtmlObserver(report_settings)
164
+ observers.append(html_observer)
165
+ if not collect_only:
166
+ cleanup_callbacks.append(html_observer.finalize)
167
+
168
+ if project_config.reports.json:
169
+ json_observer = JsonObserver(report_settings)
170
+ observers.append(json_observer)
171
+ if not collect_only:
172
+ cleanup_callbacks.append(json_observer.finalize)
173
+
174
+ if project_config.reports.csv:
175
+ csv_observer = CsvObserver(report_settings)
176
+ observers.append(csv_observer)
177
+ if not collect_only:
178
+ cleanup_callbacks.append(csv_observer.finalize)
179
+
180
+ if project_config.reports.pdf:
181
+ pdf_observer = PdfObserver(report_settings, project_config.name)
182
+ observers.append(pdf_observer)
183
+ if not collect_only:
184
+ cleanup_callbacks.append(pdf_observer.finalize)
185
+
186
+ return observers, cleanup_callbacks
187
+
188
+
189
+ def _enable_live_logging(config: pytest.Config) -> None:
190
+ """Show INFO logs live unless the user configured another CLI level."""
191
+ if config.getoption("--log-cli-level") is not None:
192
+ return
193
+
194
+ config.option.log_cli_level = config.getini("log_cli_level") or "INFO"
195
+
196
+
197
+ def pytest_unconfigure() -> None:
198
+ """Release state after the pytest session."""
199
+ global _state
200
+ if _state is not None and _state.log_handler is not None:
201
+ root_logger = logging.getLogger()
202
+ root_logger.removeHandler(_state.log_handler)
203
+ if _state is not None:
204
+ for cleanup in _state.cleanup_callbacks:
205
+ cleanup()
206
+ _state = None
207
+
208
+
209
+ def pytest_report_collectionfinish(items: list[pytest.Item]) -> None:
210
+ """Build records and notify observers after collection."""
211
+ if _state is None:
212
+ return
213
+
214
+ for item in items:
215
+ test_metadata = getattr(getattr(item, "function", None), "test_meta", {})
216
+ _state.records[item.nodeid] = TestRecord(
217
+ name=test_metadata.get("name", item.nodeid),
218
+ description=test_metadata.get("desc", ""),
219
+ requirements=list(test_metadata.get("requirements", [])),
220
+ steps=list(test_metadata.get("steps", [])),
221
+ nodeid=item.nodeid,
222
+ )
223
+
224
+ records = list(_state.records.values())
225
+ LOGGER.debug("Collected test records: %s", records)
226
+ _state.on_tests_collected(records)
227
+
228
+
229
+ def pytest_collection_modifyitems(
230
+ config: pytest.Config, items: list[pytest.Item]
231
+ ) -> None:
232
+ """Apply an optional user-facing test plan to pytest's collected items."""
233
+ if _state is not None:
234
+ apply_test_plan(config, items, _state.config.tests)
235
+
236
+
237
+ def pytest_runtest_call(item: pytest.Item) -> None:
238
+ """Notify observers immediately before the test body runs."""
239
+ if _state is None or (test_record := _state.records.get(item.nodeid)) is None:
240
+ return
241
+
242
+ test_record.outcome = "running"
243
+ _state.on_test_run(test_record)
244
+
245
+
246
+ def pytest_runtest_logstart(nodeid: str, location: tuple[str, int | None, str]) -> None:
247
+ """Begin attributing log records to a test."""
248
+ if _state is not None:
249
+ _state.current_test_nodeid = nodeid
250
+
251
+
252
+ def pytest_runtest_logfinish(
253
+ nodeid: str, location: tuple[str, int | None, str]
254
+ ) -> None:
255
+ """Stop attributing log records after a test finishes."""
256
+ if _state is not None and _state.current_test_nodeid == nodeid:
257
+ _state.current_test_nodeid = None
258
+
259
+
260
+ def pytest_runtest_logreport(report: pytest.TestReport) -> None:
261
+ """Capture phase results and notify observers after teardown."""
262
+ if _state is None or (test_record := _state.records.get(report.nodeid)) is None:
263
+ return
264
+
265
+ _update_test_record(test_record, report)
266
+
267
+ if report.when == "teardown":
268
+ _state.on_test_end(test_record)
269
+
270
+
271
+ def _update_test_record(test_record: TestRecord, report: pytest.TestReport) -> None:
272
+ """Apply one pytest phase report to a domain test record."""
273
+ test_record.duration += report.duration
274
+ if report.failed:
275
+ test_record.outcome = "failed"
276
+ failure_reason = report.longreprtext or str(report.longrepr)
277
+ if test_record.failure_reason:
278
+ test_record.failure_reason += f"\n\n{failure_reason}"
279
+ else:
280
+ test_record.failure_reason = failure_reason
281
+ elif report.when == "call" and test_record.outcome != "failed":
282
+ test_record.outcome = report.outcome
283
+ elif report.skipped and test_record.outcome not in {"failed", "passed"}:
284
+ test_record.outcome = "skipped"
@@ -0,0 +1,63 @@
1
+ """Input acceptors used to obtain operator decisions during tests."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Protocol, overload
5
+
6
+
7
+ class TestInput(Protocol):
8
+ """Callable interface exposed to tests by the ``input`` fixture."""
9
+
10
+ @overload
11
+ def __call__(self, prompt: str, input_type: type[str] = str) -> str: ...
12
+
13
+ @overload
14
+ def __call__(self, prompt: str, input_type: type[bool]) -> bool: ...
15
+
16
+ def __call__(
17
+ self, prompt: str, input_type: type[str] | type[bool] = str
18
+ ) -> bool | str: ...
19
+
20
+
21
+ class InputAcceptor(ABC):
22
+ """Interface for an operator-facing source of test input."""
23
+
24
+ @overload
25
+ def accept(self, prompt: str, input_type: type[str] = str) -> str: ...
26
+
27
+ @overload
28
+ def accept(self, prompt: str, input_type: type[bool]) -> bool: ...
29
+
30
+ @abstractmethod
31
+ def accept(
32
+ self, prompt: str, input_type: type[str] | type[bool] = str
33
+ ) -> bool | str:
34
+ """Ask an operator for a value of ``input_type``."""
35
+
36
+
37
+ class ConsoleInputAcceptor(InputAcceptor):
38
+ """Obtain operator decisions from the process console."""
39
+
40
+ @overload
41
+ def accept(self, prompt: str, input_type: type[str] = str) -> str: ...
42
+
43
+ @overload
44
+ def accept(self, prompt: str, input_type: type[bool]) -> bool: ...
45
+
46
+ def accept(
47
+ self, prompt: str, input_type: type[str] | type[bool] = str
48
+ ) -> bool | str:
49
+ """Read text once, or prompt until receiving an unambiguous yes/no."""
50
+ print("\n")
51
+
52
+ if input_type is str:
53
+ return input(f"{prompt}: ")
54
+ if input_type is not bool:
55
+ raise TypeError("input_type must be bool or str")
56
+
57
+ while True:
58
+ response = input(f"{prompt} [y/n]: ").strip().casefold()
59
+ if response in {"y", "yes"}:
60
+ return True
61
+ if response in {"n", "no"}:
62
+ return False
63
+ print("Please answer 'yes' or 'no'.")
@@ -0,0 +1,9 @@
1
+ """Built-in PyProdTest observers."""
2
+
3
+ from _pyprodtest.observers.csv_report import CsvObserver
4
+ from _pyprodtest.observers.html_report import HtmlObserver
5
+ from _pyprodtest.observers.json_report import JsonObserver
6
+ from _pyprodtest.observers.pdf_report import PdfObserver
7
+ from _pyprodtest.observers.test_observer import TestObserver
8
+
9
+ __all__ = ["CsvObserver", "HtmlObserver", "JsonObserver", "PdfObserver", "TestObserver"]
@@ -0,0 +1,5 @@
1
+ """CSV report observer."""
2
+
3
+ from _pyprodtest.observers.csv_report.csv_observer import CsvObserver
4
+
5
+ __all__ = ["CsvObserver"]
@@ -0,0 +1,67 @@
1
+ """Tabular CSV report observer."""
2
+
3
+ import csv
4
+ import json
5
+ from collections.abc import Sequence
6
+ from dataclasses import asdict
7
+
8
+ from _pyprodtest.observers.test_observer import TestObserver
9
+ from _pyprodtest.report_settings import ReportSettings
10
+ from _pyprodtest.test_record import TestRecord
11
+
12
+ FIELDNAMES = (
13
+ "name",
14
+ "description",
15
+ "nodeid",
16
+ "outcome",
17
+ "duration",
18
+ "requirements",
19
+ "steps",
20
+ "failure_reason",
21
+ "logs",
22
+ )
23
+
24
+
25
+ class CsvObserver(TestObserver):
26
+ """Write one lossless, spreadsheet-friendly row per test."""
27
+
28
+ def __init__(self, settings: ReportSettings) -> None:
29
+ self.settings = settings
30
+ self._test_records: list[TestRecord] = []
31
+
32
+ def on_tests_collected(self, test_records: Sequence[TestRecord]) -> None:
33
+ self._test_records = list(test_records)
34
+
35
+ def on_test_run(self, test_record: TestRecord) -> None:
36
+ pass
37
+
38
+ def on_test_end(self, test_record: TestRecord) -> None:
39
+ pass
40
+
41
+ def finalize(self) -> None:
42
+ """Write the CSV report when reporting is enabled."""
43
+ if not self.settings.enabled:
44
+ return
45
+ output_path = self.settings.output_path.parent / (
46
+ f"{self.settings.output_path.name}.csv"
47
+ )
48
+ output_path.parent.mkdir(parents=True, exist_ok=True)
49
+ with output_path.open("w", encoding="utf-8", newline="") as report_file:
50
+ writer = csv.DictWriter(report_file, fieldnames=FIELDNAMES)
51
+ writer.writeheader()
52
+ for record in self._test_records:
53
+ writer.writerow(
54
+ {
55
+ "name": record.name,
56
+ "description": record.description,
57
+ "nodeid": record.nodeid,
58
+ "outcome": record.outcome,
59
+ "duration": record.duration,
60
+ "requirements": json.dumps(record.requirements),
61
+ "steps": json.dumps(record.steps),
62
+ "failure_reason": record.failure_reason,
63
+ "logs": json.dumps(
64
+ [asdict(log) for log in record.logs], ensure_ascii=False
65
+ ),
66
+ }
67
+ )
@@ -0,0 +1,5 @@
1
+ """Standalone HTML report observer."""
2
+
3
+ from _pyprodtest.observers.html_report.html_observer import HtmlObserver
4
+
5
+ __all__ = ["HtmlObserver"]