testreporthub 1.0.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.
- testreporthub/__init__.py +3 -0
- testreporthub/__main__.py +4 -0
- testreporthub/aggregator.py +61 -0
- testreporthub/cli.py +343 -0
- testreporthub/db.py +406 -0
- testreporthub/models.py +107 -0
- testreporthub/parsers/__init__.py +4 -0
- testreporthub/parsers/base.py +27 -0
- testreporthub/parsers/cypress_json.py +92 -0
- testreporthub/parsers/junit_xml.py +114 -0
- testreporthub/parsers/newman_json.py +99 -0
- testreporthub/parsers/playwright_json.py +145 -0
- testreporthub/parsers/registry.py +51 -0
- testreporthub/reporter.py +83 -0
- testreporthub-1.0.0.dist-info/METADATA +116 -0
- testreporthub-1.0.0.dist-info/RECORD +18 -0
- testreporthub-1.0.0.dist-info/WHEEL +4 -0
- testreporthub-1.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Walk inputs, dispatch to parsers, aggregate into a TestRun."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable, List, Optional
|
|
9
|
+
|
|
10
|
+
from testreporthub.models import TestRun
|
|
11
|
+
from testreporthub.parsers import DEFAULT_REGISTRY, ParseError
|
|
12
|
+
from testreporthub.parsers.base import BaseParser
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _iter_files(paths: Iterable[Path]) -> List[Path]:
|
|
16
|
+
files: List[Path] = []
|
|
17
|
+
for p in paths:
|
|
18
|
+
if p.is_file():
|
|
19
|
+
files.append(p)
|
|
20
|
+
elif p.is_dir():
|
|
21
|
+
files.extend(sorted(p.rglob("*")))
|
|
22
|
+
return [f for f in files if f.is_file()]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def collect(
|
|
26
|
+
inputs: Iterable[str],
|
|
27
|
+
format_name: Optional[str] = None,
|
|
28
|
+
run_id: Optional[str] = None,
|
|
29
|
+
) -> TestRun:
|
|
30
|
+
"""Parse every input and return a unified TestRun.
|
|
31
|
+
|
|
32
|
+
If `format_name` is set, every file is parsed with that parser
|
|
33
|
+
(errors are fatal). Otherwise we auto-detect per file and silently
|
|
34
|
+
skip files no parser claims.
|
|
35
|
+
"""
|
|
36
|
+
paths = [Path(p) for p in inputs]
|
|
37
|
+
files = _iter_files(paths)
|
|
38
|
+
|
|
39
|
+
results = []
|
|
40
|
+
skipped: List[Path] = []
|
|
41
|
+
|
|
42
|
+
for file in files:
|
|
43
|
+
parser: Optional[BaseParser]
|
|
44
|
+
if format_name:
|
|
45
|
+
parser = DEFAULT_REGISTRY.get(format_name)
|
|
46
|
+
else:
|
|
47
|
+
parser = DEFAULT_REGISTRY.detect(file)
|
|
48
|
+
if parser is None:
|
|
49
|
+
skipped.append(file)
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
results.extend(parser.parse_file(file))
|
|
53
|
+
except ParseError:
|
|
54
|
+
# Auto-detected parsers shouldn't crash the whole run.
|
|
55
|
+
skipped.append(file)
|
|
56
|
+
|
|
57
|
+
return TestRun(
|
|
58
|
+
generated_at=datetime.utcnow(),
|
|
59
|
+
run_id=run_id or f"trh-{uuid.uuid4().hex[:8]}",
|
|
60
|
+
results=results,
|
|
61
|
+
)
|
testreporthub/cli.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from testreporthub import __version__
|
|
13
|
+
from testreporthub.aggregator import collect
|
|
14
|
+
from testreporthub.db import (
|
|
15
|
+
compare_runs,
|
|
16
|
+
find_flaky_tests,
|
|
17
|
+
get_run_trends,
|
|
18
|
+
list_runs,
|
|
19
|
+
quality_gate_check,
|
|
20
|
+
save_run,
|
|
21
|
+
)
|
|
22
|
+
from testreporthub.parsers import DEFAULT_REGISTRY
|
|
23
|
+
from testreporthub.reporter import render
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_labels(raw: Tuple[str, ...]) -> Dict[str, str]:
|
|
27
|
+
"""Parse --label key=value pairs into a dict."""
|
|
28
|
+
labels: Dict[str, str] = {}
|
|
29
|
+
for item in raw:
|
|
30
|
+
if "=" not in item:
|
|
31
|
+
raise click.BadParameter(f"Label must be key=value, got: {item!r}")
|
|
32
|
+
k, v = item.split("=", 1)
|
|
33
|
+
labels[k.strip()] = v.strip()
|
|
34
|
+
return labels
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.group()
|
|
38
|
+
@click.version_option(__version__, prog_name="testreporthub")
|
|
39
|
+
def main() -> None:
|
|
40
|
+
"""TestReportHub — one report, every framework."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── collect ──────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
@main.command()
|
|
46
|
+
@click.argument("inputs", nargs=-1, required=True, type=click.Path(exists=True))
|
|
47
|
+
@click.option("-o", "--output", "output_path", default="report.html", show_default=True,
|
|
48
|
+
type=click.Path(), help="Output file path.")
|
|
49
|
+
@click.option("--format", "output_format", default=None,
|
|
50
|
+
type=click.Choice(["html", "json", "ndjson"], case_sensitive=False),
|
|
51
|
+
help="Output format. Defaults to 'html' if .html, else 'json'.")
|
|
52
|
+
@click.option("--parser", "parser_format", default=None,
|
|
53
|
+
type=click.Choice(DEFAULT_REGISTRY.names(), case_sensitive=False),
|
|
54
|
+
help="Force a specific input parser.")
|
|
55
|
+
@click.option("--run-id", "run_id", default=None, help="Optional run identifier.")
|
|
56
|
+
@click.option("--quiet", is_flag=True, help="Suppress summary output.")
|
|
57
|
+
@click.option("--fail-on-failed", is_flag=True, help="Exit 1 if any tests failed.")
|
|
58
|
+
@click.option("--fail-on-error", is_flag=True, help="Exit 1 if any tests errored.")
|
|
59
|
+
def collect_cmd(
|
|
60
|
+
inputs: List[str],
|
|
61
|
+
output_path: str,
|
|
62
|
+
output_format: Optional[str],
|
|
63
|
+
parser_format: Optional[str],
|
|
64
|
+
run_id: Optional[str],
|
|
65
|
+
quiet: bool,
|
|
66
|
+
fail_on_failed: bool,
|
|
67
|
+
fail_on_error: bool,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Collect test reports from INPUTS and write a unified report."""
|
|
70
|
+
if not output_format:
|
|
71
|
+
ext = Path(output_path).suffix.lower()
|
|
72
|
+
output_format = "json" if ext == ".json" else "html"
|
|
73
|
+
|
|
74
|
+
run = collect(inputs, format_name=parser_format, run_id=run_id)
|
|
75
|
+
out_path = Path(output_path)
|
|
76
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
if output_format == "html":
|
|
79
|
+
render(run, out_path)
|
|
80
|
+
elif output_format == "json":
|
|
81
|
+
out_path.write_text(run.model_dump_json(indent=2), encoding="utf-8")
|
|
82
|
+
elif output_format == "ndjson":
|
|
83
|
+
with out_path.open("w", encoding="utf-8") as f:
|
|
84
|
+
for r in run.results:
|
|
85
|
+
f.write(r.model_dump_json() + "\n")
|
|
86
|
+
|
|
87
|
+
if not quiet:
|
|
88
|
+
click.echo(f"TestReportHub • run {run.run_id}")
|
|
89
|
+
click.echo(f" total={run.total} passed={len(run.passed)} "
|
|
90
|
+
f"failed={len(run.failed)} error={len(run.errored)} "
|
|
91
|
+
f"skipped={len(run.skipped)}")
|
|
92
|
+
click.echo(f" frameworks: {', '.join(run.frameworks) or '—'}")
|
|
93
|
+
click.echo(f" wrote: {out_path.resolve()}")
|
|
94
|
+
|
|
95
|
+
exit_code = 0
|
|
96
|
+
if fail_on_failed and len(run.failed) > 0:
|
|
97
|
+
click.echo("❌ Failing CI: Found failed tests.", err=True)
|
|
98
|
+
exit_code = 1
|
|
99
|
+
elif fail_on_error and len(run.errored) > 0:
|
|
100
|
+
click.echo("❌ Failing CI: Found errored tests.", err=True)
|
|
101
|
+
exit_code = 1
|
|
102
|
+
|
|
103
|
+
sys.exit(exit_code)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── formats ──────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
@main.command()
|
|
109
|
+
def formats() -> None:
|
|
110
|
+
"""List supported input formats."""
|
|
111
|
+
for name in DEFAULT_REGISTRY.names():
|
|
112
|
+
click.echo(f" {name}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── ingest ───────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
@main.command()
|
|
118
|
+
@click.argument("inputs", nargs=-1, required=True, type=click.Path(exists=True))
|
|
119
|
+
@click.option("--db", "db_path", default="testpulse.db", show_default=True,
|
|
120
|
+
type=click.Path(dir_okay=False), help="Path to the local SQLite database.")
|
|
121
|
+
@click.option("--parser", "parser_format", default=None,
|
|
122
|
+
type=click.Choice(DEFAULT_REGISTRY.names(), case_sensitive=False),
|
|
123
|
+
help="Force a specific input parser.")
|
|
124
|
+
@click.option("--run-id", "run_id", default=None, help="Optional run identifier.")
|
|
125
|
+
@click.option("--label", "raw_labels", multiple=True,
|
|
126
|
+
help="Attach a label (key=value). Repeatable. E.g. --label app=checkout --label env=staging")
|
|
127
|
+
def ingest(
|
|
128
|
+
inputs: List[str],
|
|
129
|
+
db_path: str,
|
|
130
|
+
parser_format: Optional[str],
|
|
131
|
+
run_id: Optional[str],
|
|
132
|
+
raw_labels: Tuple[str, ...],
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Ingest reports and save to the local TestPulse SQLite database."""
|
|
135
|
+
labels = _parse_labels(raw_labels) if raw_labels else None
|
|
136
|
+
run = collect(inputs, format_name=parser_format, run_id=run_id)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
save_run(Path(db_path), run, labels=labels)
|
|
140
|
+
click.echo(f"✅ Successfully ingested {run.total} results into {db_path}")
|
|
141
|
+
click.echo(f" Run ID: {run.run_id}")
|
|
142
|
+
if labels:
|
|
143
|
+
lbl_str = ", ".join(f"{k}={v}" for k, v in labels.items())
|
|
144
|
+
click.echo(f" Labels: {lbl_str}")
|
|
145
|
+
except Exception as e:
|
|
146
|
+
click.echo(f"❌ Database error: {e}", err=True)
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ── flaky ────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
@main.command()
|
|
153
|
+
@click.option("--db", "db_path", default="testpulse.db", show_default=True,
|
|
154
|
+
type=click.Path(exists=True, dir_okay=False))
|
|
155
|
+
@click.option("--limit", default=20, show_default=True)
|
|
156
|
+
@click.option("--label", "raw_labels", multiple=True,
|
|
157
|
+
help="Filter by label (key=value). Repeatable.")
|
|
158
|
+
def flaky(db_path: str, limit: int, raw_labels: Tuple[str, ...]) -> None:
|
|
159
|
+
"""Identify flaky tests based on historical runs."""
|
|
160
|
+
path = Path(db_path)
|
|
161
|
+
labels = _parse_labels(raw_labels) if raw_labels else None
|
|
162
|
+
|
|
163
|
+
click.echo(f"🔍 Analyzing flaky tests in {db_path}...\n")
|
|
164
|
+
flaky_tests = find_flaky_tests(path, limit=limit, labels=labels)
|
|
165
|
+
|
|
166
|
+
if not flaky_tests:
|
|
167
|
+
click.echo("🎉 No flaky tests detected! Your suite is stable.")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
click.echo(f"{'Test Name':<50} | {'Framework':<12} | {'Runs':<6} | {'Pass':<6} | {'Fail':<6} | {'Flake %'}")
|
|
171
|
+
click.echo("-" * 105)
|
|
172
|
+
|
|
173
|
+
for t in flaky_tests:
|
|
174
|
+
flake_rate = (t["fail_count"] / t["total_runs"]) * 100
|
|
175
|
+
name = t["full_name"][:47] + "..." if len(t["full_name"]) > 50 else t["full_name"]
|
|
176
|
+
click.echo(
|
|
177
|
+
f"{name:<50} | {t['framework']:<12} | {t['total_runs']:<6} | "
|
|
178
|
+
f"{t['pass_count']:<6} | {t['fail_count']:<6} | {flake_rate:.1f}%"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── runs ─────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
@main.command()
|
|
185
|
+
@click.option("--db", "db_path", default="testpulse.db", show_default=True,
|
|
186
|
+
type=click.Path(exists=True, dir_okay=False))
|
|
187
|
+
@click.option("--limit", default=20, show_default=True)
|
|
188
|
+
@click.option("--label", "raw_labels", multiple=True,
|
|
189
|
+
help="Filter by label (key=value). Repeatable.")
|
|
190
|
+
def runs(db_path: str, limit: int, raw_labels: Tuple[str, ...]) -> None:
|
|
191
|
+
"""List recent run IDs."""
|
|
192
|
+
path = Path(db_path)
|
|
193
|
+
labels = _parse_labels(raw_labels) if raw_labels else None
|
|
194
|
+
|
|
195
|
+
for r in list_runs(path, limit=limit, labels=labels):
|
|
196
|
+
ts = r["generated_at"][:19].replace("T", " ")
|
|
197
|
+
lbl_str = ""
|
|
198
|
+
if r.get("labels"):
|
|
199
|
+
lbl_str = " " + ", ".join(f"{k}={v}" for k, v in r["labels"].items())
|
|
200
|
+
click.echo(
|
|
201
|
+
f"{r['run_id']:<20} {ts} "
|
|
202
|
+
f"total={r['total']} pass={r['passed']} fail={r['failed']}{lbl_str}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── compare ──────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
@main.command()
|
|
209
|
+
@click.option("--db", "db_path", default="testpulse.db", show_default=True,
|
|
210
|
+
type=click.Path(exists=True, dir_okay=False))
|
|
211
|
+
@click.option("--run-a", required=True, help="Baseline run ID.")
|
|
212
|
+
@click.option("--run-b", required=True, help="Current run ID.")
|
|
213
|
+
@click.option("--json-output", "as_json", is_flag=True, help="Output as JSON.")
|
|
214
|
+
def compare(db_path: str, run_a: str, run_b: str, as_json: bool) -> None:
|
|
215
|
+
"""Compare two runs and surface regressions, fixes, and new failures."""
|
|
216
|
+
path = Path(db_path)
|
|
217
|
+
result = compare_runs(path, run_a, run_b)
|
|
218
|
+
|
|
219
|
+
if as_json:
|
|
220
|
+
click.echo(json.dumps(result, indent=2, default=str))
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
click.echo(f"📊 Comparing {run_a} → {run_b}\n")
|
|
224
|
+
|
|
225
|
+
def _section(title: str, items: list, color: str) -> None:
|
|
226
|
+
if not items:
|
|
227
|
+
return
|
|
228
|
+
click.echo(click.style(f"\n{title} ({len(items)})", fg=color, bold=True))
|
|
229
|
+
click.echo("-" * 80)
|
|
230
|
+
for item in items:
|
|
231
|
+
if "before" in item:
|
|
232
|
+
test = item["after"]
|
|
233
|
+
else:
|
|
234
|
+
test = item
|
|
235
|
+
click.echo(f" • {test['full_name']:<60} [{test['framework']}]")
|
|
236
|
+
|
|
237
|
+
_section("🔴 REGRESSIONS (passed → failed)", result["regressions"], "red")
|
|
238
|
+
_section("🟡 NEW FAILURES (didn't exist before)", result["new_failures"], "yellow")
|
|
239
|
+
_section("🟢 FIXES (failed → passed)", result["fixes"], "green")
|
|
240
|
+
_section("🔵 NEW PASSES (didn't exist before)", result["new_passes"], "blue")
|
|
241
|
+
_section("⚫ STILL FAILING", result["still_failing"], "white")
|
|
242
|
+
|
|
243
|
+
click.echo("\n" + "=" * 80)
|
|
244
|
+
click.echo(
|
|
245
|
+
f"Summary: {len(result['regressions'])} regressions, "
|
|
246
|
+
f"{len(result['fixes'])} fixes, "
|
|
247
|
+
f"{len(result['new_failures'])} new failures, "
|
|
248
|
+
f"{len(result['still_failing'])} still failing"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── history ──────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
@main.command()
|
|
255
|
+
@click.option("--db", "db_path", default="testpulse.db", show_default=True,
|
|
256
|
+
type=click.Path(exists=True, dir_okay=False))
|
|
257
|
+
@click.option("--limit", default=20, show_default=True)
|
|
258
|
+
@click.option("--label", "raw_labels", multiple=True,
|
|
259
|
+
help="Filter by label (key=value). Repeatable.")
|
|
260
|
+
def history(db_path: str, limit: int, raw_labels: Tuple[str, ...]) -> None:
|
|
261
|
+
"""Show trends across recent runs."""
|
|
262
|
+
path = Path(db_path)
|
|
263
|
+
labels = _parse_labels(raw_labels) if raw_labels else None
|
|
264
|
+
trends = get_run_trends(path, limit=limit, labels=labels)
|
|
265
|
+
|
|
266
|
+
if not trends:
|
|
267
|
+
click.echo("No runs found. Run `trh ingest` first.")
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
click.echo(f"📈 Run history (last {len(trends)} runs)\n")
|
|
271
|
+
click.echo(f"{'Run ID':<20} | {'Time':<20} | {'Total':<6} | {'Pass':<6} | {'Fail':<6} | {'Pass %'}")
|
|
272
|
+
click.echo("-" * 90)
|
|
273
|
+
|
|
274
|
+
for t in trends:
|
|
275
|
+
ts = t["generated_at"][:19].replace("T", " ")
|
|
276
|
+
click.echo(
|
|
277
|
+
f"{t['run_id']:<20} | {ts:<20} | {t['total']:<6} | "
|
|
278
|
+
f"{t['passed']:<6} | {t['failed']:<6} | {t['pass_rate']:.1f}%"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
totals = [t["total"] for t in trends]
|
|
282
|
+
pass_rates = [t["pass_rate"] for t in trends]
|
|
283
|
+
avg_pass = sum(pass_rates) / len(pass_rates)
|
|
284
|
+
click.echo("\n" + "=" * 90)
|
|
285
|
+
click.echo(
|
|
286
|
+
f"Avg pass rate: {avg_pass:.1f}% | "
|
|
287
|
+
f"Runs: {len(trends)} | "
|
|
288
|
+
f"Avg tests/run: {sum(totals) / len(totals):.0f}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ── quality-gate ─────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
@main.command("quality-gate")
|
|
295
|
+
@click.option("--db", "db_path", default="testpulse.db", show_default=True,
|
|
296
|
+
type=click.Path(exists=True, dir_okay=False))
|
|
297
|
+
@click.option("--label", "raw_labels", multiple=True,
|
|
298
|
+
help="Scope the gate to specific labels (key=value). Repeatable.")
|
|
299
|
+
@click.option("--min-pass-rate", default=95.0, show_default=True, type=float,
|
|
300
|
+
help="Minimum pass rate (%) for the latest run.")
|
|
301
|
+
@click.option("--max-flake-rate", default=5.0, show_default=True, type=float,
|
|
302
|
+
help="Maximum acceptable flaky test rate (%).")
|
|
303
|
+
@click.option("--max-regressions", default=0, show_default=True, type=int,
|
|
304
|
+
help="Maximum allowed regressions vs previous run.")
|
|
305
|
+
@click.option("--lookback", default=5, show_default=True, type=int,
|
|
306
|
+
help="Number of recent runs to consider.")
|
|
307
|
+
@click.option("--fail-on-breach", is_flag=True,
|
|
308
|
+
help="Exit with code 1 if any gate check fails.")
|
|
309
|
+
@click.option("--json-output", "as_json", is_flag=True, help="Output as JSON.")
|
|
310
|
+
def quality_gate(
|
|
311
|
+
db_path: str,
|
|
312
|
+
raw_labels: Tuple[str, ...],
|
|
313
|
+
min_pass_rate: float,
|
|
314
|
+
max_flake_rate: float,
|
|
315
|
+
max_regressions: int,
|
|
316
|
+
lookback: int,
|
|
317
|
+
fail_on_breach: bool,
|
|
318
|
+
as_json: bool,
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Evaluate quality gates for runs matching the given labels."""
|
|
321
|
+
path = Path(db_path)
|
|
322
|
+
labels = _parse_labels(raw_labels) if raw_labels else None
|
|
323
|
+
|
|
324
|
+
result = quality_gate_check(
|
|
325
|
+
path,
|
|
326
|
+
labels=labels,
|
|
327
|
+
min_pass_rate=min_pass_rate,
|
|
328
|
+
max_flake_rate=max_flake_rate,
|
|
329
|
+
max_regressions=max_regressions,
|
|
330
|
+
lookback=lookback,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if as_json:
|
|
334
|
+
click.echo(json.dumps(result, indent=2, default=str))
|
|
335
|
+
else:
|
|
336
|
+
click.echo(result["summary"])
|
|
337
|
+
|
|
338
|
+
if fail_on_breach and not result["passed"]:
|
|
339
|
+
sys.exit(1)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
if __name__ == "__main__":
|
|
343
|
+
main()
|