pytest-verifies 1.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.
- pytest_verifies/__init__.py +13 -0
- pytest_verifies/baseline.py +156 -0
- pytest_verifies/cli.py +205 -0
- pytest_verifies/context.py +331 -0
- pytest_verifies/models.py +221 -0
- pytest_verifies/plugin.py +682 -0
- pytest_verifies/py.typed +0 -0
- pytest_verifies/reporters/__init__.py +84 -0
- pytest_verifies/reporters/csv_reporter.py +74 -0
- pytest_verifies/reporters/html_reporter.py +467 -0
- pytest_verifies/reporters/json_reporter.py +26 -0
- pytest_verifies/reporters/markdown_reporter.py +155 -0
- pytest_verifies/storage.py +312 -0
- pytest_verifies-1.1.0.dist-info/METADATA +303 -0
- pytest_verifies-1.1.0.dist-info/RECORD +18 -0
- pytest_verifies-1.1.0.dist-info/WHEEL +4 -0
- pytest_verifies-1.1.0.dist-info/entry_points.txt +5 -0
- pytest_verifies-1.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""pytest-verifies: Enterprise requirements traceability plugin for pytest.
|
|
2
|
+
|
|
3
|
+
Enables automated linking between test executions and formal engineering
|
|
4
|
+
requirements, producing comprehensive Requirements Traceability Matrix (RTM) artefacts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pytest_verifies.context import step, verifies
|
|
10
|
+
|
|
11
|
+
__version__ = "1.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["step", "verifies", "__version__"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Loader and validator for baseline requirement manifests.
|
|
2
|
+
|
|
3
|
+
Supports loading requirement manifests from JSON, CSV, and YAML formats with
|
|
4
|
+
comprehensive schema validation and consistent British English error reporting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pytest_verifies.models import BaselineRequirement
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_baseline(path: str | Path) -> dict[str, BaselineRequirement]:
|
|
18
|
+
"""Load and validate a baseline requirement manifest from disk.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
path: Path to the requirement manifest file (.json, .csv, .yaml, or .yml).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
dict[str, BaselineRequirement]: Mapping of requirement ID to requirement details.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
FileNotFoundError: If the manifest file does not exist.
|
|
28
|
+
ValueError: If the file format is unsupported or the schema is invalid.
|
|
29
|
+
RuntimeError: If PyYAML is required but not installed.
|
|
30
|
+
"""
|
|
31
|
+
manifest_path = Path(path)
|
|
32
|
+
if not manifest_path.is_file():
|
|
33
|
+
raise FileNotFoundError(f"Baseline requirements manifest not found: '{manifest_path}'")
|
|
34
|
+
|
|
35
|
+
suffix = manifest_path.suffix.lower()
|
|
36
|
+
if suffix == ".json":
|
|
37
|
+
return _load_json_baseline(manifest_path)
|
|
38
|
+
elif suffix == ".csv":
|
|
39
|
+
return _load_csv_baseline(manifest_path)
|
|
40
|
+
elif suffix in (".yaml", ".yml"):
|
|
41
|
+
return _load_yaml_baseline(manifest_path)
|
|
42
|
+
else:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Unsupported baseline file format '{suffix}'. "
|
|
45
|
+
"Supported formats are .json, .csv, and .yaml / .yml."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_json_baseline(path: Path) -> dict[str, BaselineRequirement]:
|
|
50
|
+
"""Load baseline requirements from a JSON file."""
|
|
51
|
+
try:
|
|
52
|
+
with path.open("r", encoding="utf-8") as f:
|
|
53
|
+
data = json.load(f)
|
|
54
|
+
except json.JSONDecodeError as exc:
|
|
55
|
+
raise ValueError(f"Malformed JSON in baseline manifest '{path}': {exc}") from exc
|
|
56
|
+
|
|
57
|
+
return _parse_baseline_data(data, path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_csv_baseline(path: Path) -> dict[str, BaselineRequirement]:
|
|
61
|
+
"""Load baseline requirements from a CSV file."""
|
|
62
|
+
requirements: dict[str, BaselineRequirement] = {}
|
|
63
|
+
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
|
64
|
+
reader = csv.DictReader(f)
|
|
65
|
+
if not reader.fieldnames or "id" not in [
|
|
66
|
+
name.strip().lower() for name in reader.fieldnames
|
|
67
|
+
]:
|
|
68
|
+
raise ValueError(f"CSV baseline manifest '{path}' must contain an 'id' header column.")
|
|
69
|
+
|
|
70
|
+
for line_no, row in enumerate(reader, start=2):
|
|
71
|
+
if not any((v or "").strip() for k, v in row.items() if k and isinstance(v, str)):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# Normalise keys to lowercase
|
|
75
|
+
norm_row = {k.strip().lower(): (v.strip() if v else "") for k, v in row.items() if k}
|
|
76
|
+
req_id = norm_row.get("id", "").strip()
|
|
77
|
+
if not req_id:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"Line {line_no} in CSV baseline manifest '{path}' contains an empty requirement ID."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
requirements[req_id] = BaselineRequirement.from_dict(norm_row)
|
|
83
|
+
|
|
84
|
+
return requirements
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_yaml_baseline(path: Path) -> dict[str, BaselineRequirement]:
|
|
88
|
+
"""Load baseline requirements from a YAML file."""
|
|
89
|
+
try:
|
|
90
|
+
import yaml # type: ignore[import-untyped]
|
|
91
|
+
except ImportError as exc:
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
"PyYAML is required to parse YAML baseline files. Please install pyyaml."
|
|
94
|
+
) from exc
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
with path.open("r", encoding="utf-8") as f:
|
|
98
|
+
data = yaml.safe_load(f)
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
raise ValueError(f"Malformed YAML in baseline manifest '{path}': {exc}") from exc
|
|
101
|
+
|
|
102
|
+
return _parse_baseline_data(data, path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_baseline_data(data: Any, path: Path) -> dict[str, BaselineRequirement]:
|
|
106
|
+
"""Parse dictionary or list data structures into a validated requirement mapping."""
|
|
107
|
+
requirements: dict[str, BaselineRequirement] = {}
|
|
108
|
+
|
|
109
|
+
if isinstance(data, dict):
|
|
110
|
+
if "requirements" in data and isinstance(data["requirements"], list):
|
|
111
|
+
items = data["requirements"]
|
|
112
|
+
for idx, item in enumerate(items):
|
|
113
|
+
if (
|
|
114
|
+
not isinstance(item, dict)
|
|
115
|
+
or item.get("id") is None
|
|
116
|
+
or not str(item["id"]).strip()
|
|
117
|
+
):
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Item {idx} in 'requirements' list of '{path}' must be a dictionary with an 'id'."
|
|
120
|
+
)
|
|
121
|
+
req = BaselineRequirement.from_dict(item)
|
|
122
|
+
requirements[req.id] = req
|
|
123
|
+
else:
|
|
124
|
+
# Format: {"REQ-1": {"title": "..."}, "REQ-2": {...}}
|
|
125
|
+
for key, val in data.items():
|
|
126
|
+
req_id = str(key).strip() if key is not None else ""
|
|
127
|
+
if not req_id:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Baseline manifest '{path}' contains an empty requirement ID."
|
|
130
|
+
)
|
|
131
|
+
if isinstance(val, dict):
|
|
132
|
+
item_dict = dict(val)
|
|
133
|
+
if item_dict.get("id") is None or not str(item_dict["id"]).strip():
|
|
134
|
+
item_dict["id"] = req_id
|
|
135
|
+
req = BaselineRequirement.from_dict(item_dict)
|
|
136
|
+
elif val is None:
|
|
137
|
+
req = BaselineRequirement(id=req_id, text="")
|
|
138
|
+
else:
|
|
139
|
+
# Could be scalar description: {"REQ-1": "Requirement text"}
|
|
140
|
+
req = BaselineRequirement(id=req_id, text=str(val).strip())
|
|
141
|
+
requirements[req.id] = req
|
|
142
|
+
|
|
143
|
+
elif isinstance(data, list):
|
|
144
|
+
for idx, item in enumerate(data):
|
|
145
|
+
if not isinstance(item, dict) or item.get("id") is None or not str(item["id"]).strip():
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"Item {idx} in '{path}' must be a dictionary containing a non-empty 'id'."
|
|
148
|
+
)
|
|
149
|
+
req = BaselineRequirement.from_dict(item)
|
|
150
|
+
requirements[req.id] = req
|
|
151
|
+
else:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"Unexpected data format in baseline manifest '{path}'. Expected dict or list."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return requirements
|
pytest_verifies/cli.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Command-line interface for pytest-verifies.
|
|
2
|
+
|
|
3
|
+
Provides the standalone `pytest-verifies combine` command to merge execution shards,
|
|
4
|
+
validate against baseline manifests, evaluate quality gates, and output reports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
from pytest_verifies.baseline import load_baseline
|
|
14
|
+
from pytest_verifies.models import BaselineRequirement, RTMReport
|
|
15
|
+
from pytest_verifies.reporters import (
|
|
16
|
+
generate_csv_report,
|
|
17
|
+
generate_html_report,
|
|
18
|
+
generate_json_report,
|
|
19
|
+
generate_markdown_report,
|
|
20
|
+
render_console_summary,
|
|
21
|
+
)
|
|
22
|
+
from pytest_verifies.storage import consolidate_requirements, read_shards
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
"""Construct command-line argument parser with subcommands."""
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="pytest-verifies",
|
|
29
|
+
description="Enterprise requirements traceability matrix tool for pytest.",
|
|
30
|
+
)
|
|
31
|
+
subparsers = parser.add_subparsers(dest="subcommand", help="Available subcommands")
|
|
32
|
+
|
|
33
|
+
# `combine` subcommand
|
|
34
|
+
combine_parser = subparsers.add_parser(
|
|
35
|
+
"combine",
|
|
36
|
+
help="Combine multiple execution shards and generate traceability reports.",
|
|
37
|
+
)
|
|
38
|
+
combine_parser.add_argument(
|
|
39
|
+
"shards",
|
|
40
|
+
nargs="+",
|
|
41
|
+
help="One or more shard file paths (.jsonl) or directories containing shards.",
|
|
42
|
+
)
|
|
43
|
+
combine_parser.add_argument(
|
|
44
|
+
"--baseline",
|
|
45
|
+
"-b",
|
|
46
|
+
type=str,
|
|
47
|
+
default=None,
|
|
48
|
+
help="Path to baseline requirements manifest (.json, .csv, .yaml).",
|
|
49
|
+
)
|
|
50
|
+
combine_parser.add_argument(
|
|
51
|
+
"--json",
|
|
52
|
+
type=str,
|
|
53
|
+
default=None,
|
|
54
|
+
help="Path to generate structured JSON report.",
|
|
55
|
+
)
|
|
56
|
+
combine_parser.add_argument(
|
|
57
|
+
"--csv",
|
|
58
|
+
type=str,
|
|
59
|
+
default=None,
|
|
60
|
+
help="Path to generate tabular CSV report.",
|
|
61
|
+
)
|
|
62
|
+
combine_parser.add_argument(
|
|
63
|
+
"--md",
|
|
64
|
+
type=str,
|
|
65
|
+
default=None,
|
|
66
|
+
help="Path to generate Markdown summary report.",
|
|
67
|
+
)
|
|
68
|
+
combine_parser.add_argument(
|
|
69
|
+
"--html",
|
|
70
|
+
type=str,
|
|
71
|
+
default=None,
|
|
72
|
+
help="Path to generate interactive HTML5 report.",
|
|
73
|
+
)
|
|
74
|
+
combine_parser.add_argument(
|
|
75
|
+
"--disallow-orphans",
|
|
76
|
+
action="store_true",
|
|
77
|
+
default=False,
|
|
78
|
+
help="Fail quality gate if observed requirements are not in the baseline.",
|
|
79
|
+
)
|
|
80
|
+
combine_parser.add_argument(
|
|
81
|
+
"--fail-under",
|
|
82
|
+
type=float,
|
|
83
|
+
default=None,
|
|
84
|
+
help="Minimum required verified requirement coverage percentage (0.0 to 100.0).",
|
|
85
|
+
)
|
|
86
|
+
combine_parser.add_argument(
|
|
87
|
+
"--color",
|
|
88
|
+
choices=["yes", "no", "auto"],
|
|
89
|
+
default="auto",
|
|
90
|
+
help="Control colour formatting in terminal output (default: auto).",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def combine_command(args: argparse.Namespace) -> int:
|
|
97
|
+
"""Execute the combine subcommand to merge shards and verify quality gates.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
int: Exit status code (0 for success, 1 for quality gate failure).
|
|
101
|
+
"""
|
|
102
|
+
if args.disallow_orphans and not args.baseline:
|
|
103
|
+
print(
|
|
104
|
+
"Error: --disallow-orphans requires --baseline to be specified.",
|
|
105
|
+
file=sys.stderr,
|
|
106
|
+
)
|
|
107
|
+
return 1
|
|
108
|
+
|
|
109
|
+
baseline_map: dict[str, BaselineRequirement] | None = None
|
|
110
|
+
if args.baseline:
|
|
111
|
+
try:
|
|
112
|
+
baseline_map = load_baseline(args.baseline)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
print(f"Error loading baseline manifest '{args.baseline}': {exc}", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
runs, records = read_shards(args.shards)
|
|
118
|
+
if not records and not runs:
|
|
119
|
+
print(
|
|
120
|
+
f"Warning: No valid verification records found in specified paths: {args.shards}",
|
|
121
|
+
file=sys.stderr,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
report: RTMReport = consolidate_requirements(records, baseline=baseline_map, runs=runs)
|
|
125
|
+
|
|
126
|
+
# Output requested reports
|
|
127
|
+
if args.json:
|
|
128
|
+
generate_json_report(report, args.json)
|
|
129
|
+
print(f"Generated JSON report: {args.json}")
|
|
130
|
+
if args.csv:
|
|
131
|
+
generate_csv_report(report, args.csv)
|
|
132
|
+
print(f"Generated CSV report: {args.csv}")
|
|
133
|
+
if args.md:
|
|
134
|
+
generate_markdown_report(report, args.md)
|
|
135
|
+
print(f"Generated Markdown report: {args.md}")
|
|
136
|
+
if args.html:
|
|
137
|
+
generate_html_report(report, args.html)
|
|
138
|
+
print(f"Generated HTML report: {args.html}")
|
|
139
|
+
|
|
140
|
+
# Display console summary using TerminalWriter
|
|
141
|
+
from _pytest._io.terminalwriter import TerminalWriter
|
|
142
|
+
|
|
143
|
+
tw = TerminalWriter(sys.stdout)
|
|
144
|
+
if getattr(args, "color", "auto") == "yes":
|
|
145
|
+
tw.hasmarkup = True
|
|
146
|
+
elif getattr(args, "color", "auto") == "no":
|
|
147
|
+
tw.hasmarkup = False
|
|
148
|
+
|
|
149
|
+
render_console_summary(report, tw)
|
|
150
|
+
|
|
151
|
+
# Quality Gate Checks
|
|
152
|
+
exit_code = 0
|
|
153
|
+
|
|
154
|
+
# 1. Any requirement with consolidated status FAILED
|
|
155
|
+
failed_reqs = [r.req_id for r in report.requirements if r.status == "FAILED"]
|
|
156
|
+
if failed_reqs:
|
|
157
|
+
print(
|
|
158
|
+
f"Quality gate failure: {len(failed_reqs)} requirement(s) failed: {', '.join(failed_reqs)}",
|
|
159
|
+
file=sys.stderr,
|
|
160
|
+
)
|
|
161
|
+
exit_code = 1
|
|
162
|
+
|
|
163
|
+
# 2. Disallow orphans check
|
|
164
|
+
if args.disallow_orphans and report.orphan_count > 0:
|
|
165
|
+
orphan_ids = [r.req_id for r in report.requirements if r.is_orphan]
|
|
166
|
+
print(
|
|
167
|
+
f"Quality gate failure: Found {len(orphan_ids)} orphaned requirement(s) not in baseline: {', '.join(orphan_ids)}",
|
|
168
|
+
file=sys.stderr,
|
|
169
|
+
)
|
|
170
|
+
exit_code = 1
|
|
171
|
+
|
|
172
|
+
# 3. Minimum coverage percentage threshold
|
|
173
|
+
if args.fail_under is not None:
|
|
174
|
+
if report.coverage_percentage < args.fail_under:
|
|
175
|
+
print(
|
|
176
|
+
f"Quality gate failure: Coverage {report.coverage_percentage:.1f}% is below threshold of {args.fail_under:.1f}%",
|
|
177
|
+
file=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
exit_code = 1
|
|
180
|
+
|
|
181
|
+
return exit_code
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
185
|
+
"""Main CLI entry point."""
|
|
186
|
+
parser = build_parser()
|
|
187
|
+
args_list = list(argv if argv is not None else sys.argv[1:])
|
|
188
|
+
|
|
189
|
+
# Ergonomic fallback: if first argument is not a known subcommand and not help, default to combine
|
|
190
|
+
if args_list and args_list[0] not in ("combine", "-h", "--help"):
|
|
191
|
+
args_list.insert(0, "combine")
|
|
192
|
+
elif not args_list:
|
|
193
|
+
parser.print_help()
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
args = parser.parse_args(args_list)
|
|
197
|
+
if args.subcommand == "combine":
|
|
198
|
+
return combine_command(args)
|
|
199
|
+
else:
|
|
200
|
+
parser.print_help()
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
sys.exit(main())
|