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.
- _pyprodtest/__init__.py +0 -0
- _pyprodtest/config.py +163 -0
- _pyprodtest/decorator.py +47 -0
- _pyprodtest/hooks.py +284 -0
- _pyprodtest/input_acceptors.py +63 -0
- _pyprodtest/observers/__init__.py +9 -0
- _pyprodtest/observers/csv_report/__init__.py +5 -0
- _pyprodtest/observers/csv_report/csv_observer.py +67 -0
- _pyprodtest/observers/html_report/__init__.py +5 -0
- _pyprodtest/observers/html_report/html_observer.py +69 -0
- _pyprodtest/observers/html_report/templates/report.html +131 -0
- _pyprodtest/observers/json_report/__init__.py +5 -0
- _pyprodtest/observers/json_report/json_observer.py +48 -0
- _pyprodtest/observers/pdf_report/__init__.py +5 -0
- _pyprodtest/observers/pdf_report/pdf_observer.py +334 -0
- _pyprodtest/observers/test_observer.py +24 -0
- _pyprodtest/observers/web_ui/__init__.py +5 -0
- _pyprodtest/observers/web_ui/app.py +41 -0
- _pyprodtest/observers/web_ui/input_acceptor.py +24 -0
- _pyprodtest/observers/web_ui/observer.py +23 -0
- _pyprodtest/observers/web_ui/server.py +71 -0
- _pyprodtest/observers/web_ui/state.py +106 -0
- _pyprodtest/observers/web_ui/static/app.js +290 -0
- _pyprodtest/observers/web_ui/templates/index.html +52 -0
- _pyprodtest/report_settings.py +23 -0
- _pyprodtest/test_record.py +45 -0
- _pyprodtest/web_assets/__init__.py +1 -0
- _pyprodtest/web_assets/pico.min.css +4 -0
- _pyprodtest/web_assets/theme.css +247 -0
- pyprodtest/__init__.py +11 -0
- pyprodtest-0.1.0.dist-info/METADATA +141 -0
- pyprodtest-0.1.0.dist-info/RECORD +36 -0
- pyprodtest-0.1.0.dist-info/WHEEL +5 -0
- pyprodtest-0.1.0.dist-info/entry_points.txt +2 -0
- pyprodtest-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyprodtest-0.1.0.dist-info/top_level.txt +2 -0
_pyprodtest/__init__.py
ADDED
|
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
|
+
)
|
_pyprodtest/decorator.py
ADDED
|
@@ -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,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
|
+
)
|