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