powercontract 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.
@@ -0,0 +1,3 @@
1
+ """PowerContract public package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,498 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from math import isclose, isfinite
5
+ from pathlib import Path
6
+
7
+ from .errors import CaptureError
8
+ from .model import Capture, Contract
9
+
10
+
11
+ def load_capture(
12
+ path: str | Path,
13
+ contract: Contract,
14
+ additional_paths: list[str | Path] | None = None,
15
+ ) -> Capture:
16
+ paths = [Path(path), *(Path(p) for p in (additional_paths or []))]
17
+ if len(paths) > 1:
18
+ if all(_looks_like_siglent_csv(_read_lines(path)) for path in paths):
19
+ return _load_siglent_group(paths, contract)
20
+ raise CaptureError(
21
+ "multi-file capture groups currently require Siglent per-channel CSV; "
22
+ "use a single multi-channel CSV for other formats"
23
+ )
24
+
25
+ path = paths[0]
26
+ lines = _read_lines(path)
27
+ if _looks_like_siglent_csv(lines):
28
+ return _load_siglent_group(paths, contract)
29
+ if _looks_like_rigol_sequence_csv(lines):
30
+ return _load_rigol_sequence_csv(lines, contract, path)
31
+ if _looks_like_paired_xy_csv(lines):
32
+ return _load_paired_xy_csv(lines, contract, path)
33
+ if _looks_like_source_units_csv(lines):
34
+ return _load_source_units_csv(lines, contract, path)
35
+ if _looks_like_legacy_metadata_xy_csv(lines):
36
+ return _load_legacy_metadata_xy_csv(lines, contract, path)
37
+ return _load_table_csv(lines, contract)
38
+
39
+
40
+ def _read_lines(path: Path) -> list[str]:
41
+ try:
42
+ lines = path.read_text(encoding="utf-8-sig").splitlines()
43
+ except OSError as exc:
44
+ raise CaptureError(f"cannot read capture {path}: {exc}") from exc
45
+ if not lines:
46
+ raise CaptureError(f"capture {path} is empty")
47
+ return lines
48
+
49
+
50
+ def _load_table_csv(lines: list[str], contract: Contract) -> Capture:
51
+ expected = {contract.time_column, *(s.column for s in contract.signals.values())}
52
+ header_index, delimiter = _find_header(lines, expected)
53
+ reader = csv.DictReader(lines[header_index:], delimiter=delimiter)
54
+ if reader.fieldnames is None:
55
+ raise CaptureError("capture has no header")
56
+ reader.fieldnames = [field.strip().strip('"') for field in reader.fieldnames]
57
+ if len(set(reader.fieldnames)) != len(reader.fieldnames):
58
+ raise CaptureError("capture has duplicate column names")
59
+ missing = expected.difference(reader.fieldnames)
60
+ if missing:
61
+ raise CaptureError(f"capture is missing columns: {', '.join(sorted(missing))}")
62
+
63
+ times: list[float] = []
64
+ signal_values: dict[str, list[float]] = {name: [] for name in contract.signals}
65
+ for row_number, row in enumerate(reader, start=header_index + 2):
66
+ if not any((value or "").strip() for value in row.values()):
67
+ continue
68
+ try:
69
+ t = float(row[contract.time_column])
70
+ values = {name: float(row[signal.column]) for name, signal in contract.signals.items()}
71
+ except (TypeError, ValueError) as exc:
72
+ raise CaptureError(f"row {row_number} contains non-numeric waveform data") from exc
73
+ _validate_sample_row(t, values.values(), row_number)
74
+ if times and t <= times[-1]:
75
+ raise CaptureError(f"time must be strictly increasing (row {row_number})")
76
+ times.append(t)
77
+ for name, value in values.items():
78
+ signal_values[name].append(value)
79
+
80
+ if len(times) < 2:
81
+ raise CaptureError("capture needs at least two samples")
82
+ return Capture(tuple(times), {name: tuple(values) for name, values in signal_values.items()})
83
+
84
+
85
+
86
+ def _row(line: str) -> list[str]:
87
+ fields = [field.strip().strip('"') for field in next(csv.reader([line]))]
88
+ while fields and not fields[-1]:
89
+ fields.pop()
90
+ return fields
91
+
92
+
93
+ def _looks_like_rigol_sequence_csv(lines: list[str]) -> bool:
94
+ if len(lines) < 3:
95
+ return False
96
+ names = _row(lines[0])
97
+ units = _row(lines[1])
98
+ return (
99
+ len(names) >= 4
100
+ and len(units) >= len(names) - 1
101
+ and names[0].lower() == "x"
102
+ and "Start" in names
103
+ and "Increment" in names
104
+ and units[0].lower() == "sequence"
105
+ )
106
+
107
+
108
+ def _load_rigol_sequence_csv(lines: list[str], contract: Contract, path: Path) -> Capture:
109
+ names = _row(lines[0])
110
+ units = _row(lines[1])
111
+ start_index = names.index("Start")
112
+ increment_index = names.index("Increment")
113
+ source_names = names[1:start_index]
114
+ if not source_names:
115
+ raise CaptureError(f"{path}: Rigol CSV contains no analog sources")
116
+ if len(set(source_names)) != len(source_names):
117
+ raise CaptureError(f"{path}: Rigol CSV contains duplicate source names")
118
+ if any(units[index].lower() not in {"volt", "v"} for index in range(1, start_index)):
119
+ raise CaptureError(f"{path}: Rigol CSV analog sources must be in volts")
120
+
121
+ expected = {signal.column for signal in contract.signals.values()}
122
+ missing = expected.difference(source_names)
123
+ if missing:
124
+ raise CaptureError(f"{path}: Rigol CSV is missing source(s): {', '.join(sorted(missing))}")
125
+
126
+ try:
127
+ start = float(units[start_index])
128
+ increment = float(units[increment_index])
129
+ except (IndexError, ValueError) as exc:
130
+ raise CaptureError(f"{path}: Rigol CSV has invalid Start/Increment metadata") from exc
131
+ if not isfinite(start) or not isfinite(increment) or increment <= 0:
132
+ raise CaptureError(f"{path}: Rigol Start/Increment must be finite and increment > 0")
133
+
134
+ source_positions = {name: names.index(name) for name in expected}
135
+ times: list[float] = []
136
+ signal_values: dict[str, list[float]] = {name: [] for name in contract.signals}
137
+ for row_number, line in enumerate(lines[2:], start=3):
138
+ fields = _row(line)
139
+ if not fields or not any(fields):
140
+ continue
141
+ try:
142
+ sequence = float(fields[0])
143
+ t = start + sequence * increment
144
+ values = {
145
+ name: float(fields[source_positions[signal.column]])
146
+ for name, signal in contract.signals.items()
147
+ }
148
+ except (IndexError, ValueError) as exc:
149
+ raise CaptureError(f"{path}: row {row_number} contains invalid Rigol waveform data") from exc
150
+ _validate_sample_row(t, values.values(), row_number, path)
151
+ if times and t <= times[-1]:
152
+ raise CaptureError(f"{path}: derived time must be strictly increasing (row {row_number})")
153
+ times.append(t)
154
+ for name, value in values.items():
155
+ signal_values[name].append(value)
156
+
157
+ if len(times) < 2:
158
+ raise CaptureError(f"{path}: capture needs at least two samples")
159
+ return Capture(tuple(times), {name: tuple(values) for name, values in signal_values.items()})
160
+
161
+
162
+ def _looks_like_source_units_csv(lines: list[str]) -> bool:
163
+ if len(lines) < 3:
164
+ return False
165
+ names = _row(lines[0])
166
+ units = _row(lines[1])
167
+ if len(names) < 2 or len(units) < len(names):
168
+ return False
169
+ return (
170
+ names[0].lower() in {"source", "x-axis", "x", "time"}
171
+ and units[0].lower() in {"second", "seconds", "s"}
172
+ and all(unit.lower() in {"volt", "volts", "v"} for unit in units[1:len(names)])
173
+ )
174
+
175
+
176
+ def _load_source_units_csv(lines: list[str], contract: Contract, path: Path) -> Capture:
177
+ names = _row(lines[0])
178
+ source_names = names[1:]
179
+ if len(set(source_names)) != len(source_names):
180
+ raise CaptureError(f"{path}: capture contains duplicate source names")
181
+ expected = {signal.column for signal in contract.signals.values()}
182
+ missing = expected.difference(source_names)
183
+ if missing:
184
+ raise CaptureError(f"{path}: capture is missing source(s): {', '.join(sorted(missing))}")
185
+
186
+ positions = {source: source_names.index(source) + 1 for source in expected}
187
+ times: list[float] = []
188
+ signal_values: dict[str, list[float]] = {name: [] for name in contract.signals}
189
+ for row_number, line in enumerate(lines[2:], start=3):
190
+ fields = _row(line)
191
+ if not fields or not any(fields):
192
+ continue
193
+ try:
194
+ t = float(fields[0])
195
+ values = {
196
+ name: float(fields[positions[signal.column]])
197
+ for name, signal in contract.signals.items()
198
+ }
199
+ except (IndexError, ValueError) as exc:
200
+ raise CaptureError(f"{path}: row {row_number} contains invalid waveform data") from exc
201
+ _validate_sample_row(t, values.values(), row_number, path)
202
+ if times and t <= times[-1]:
203
+ raise CaptureError(f"{path}: time must be strictly increasing (row {row_number})")
204
+ times.append(t)
205
+ for name, value in values.items():
206
+ signal_values[name].append(value)
207
+
208
+ if len(times) < 2:
209
+ raise CaptureError(f"{path}: capture needs at least two samples")
210
+ return Capture(tuple(times), {name: tuple(values) for name, values in signal_values.items()})
211
+
212
+
213
+ def _looks_like_paired_xy_csv(lines: list[str]) -> bool:
214
+ if len(lines) < 3:
215
+ return False
216
+ names = _row(lines[0])
217
+ units = _row(lines[1])
218
+ if len(names) < 2 or len(names) % 2 or len(units) < len(names):
219
+ return False
220
+ return all(
221
+ units[index].lower() in {"second", "seconds", "s"}
222
+ and units[index + 1].lower() in {"volt", "volts", "v"}
223
+ for index in range(0, len(names), 2)
224
+ )
225
+
226
+
227
+ def _load_paired_xy_csv(lines: list[str], contract: Contract, path: Path) -> Capture:
228
+ names = _row(lines[0])
229
+ source_positions = {names[index + 1]: (index, index + 1) for index in range(0, len(names), 2)}
230
+ if len(source_positions) != len(names) // 2:
231
+ raise CaptureError(f"{path}: paired XY CSV contains duplicate source names")
232
+ expected = {signal.column for signal in contract.signals.values()}
233
+ missing = expected.difference(source_positions)
234
+ if missing:
235
+ raise CaptureError(f"{path}: paired XY CSV is missing source(s): {', '.join(sorted(missing))}")
236
+
237
+ source_times: dict[str, list[float]] = {source: [] for source in expected}
238
+ source_values: dict[str, list[float]] = {source: [] for source in expected}
239
+ for row_number, line in enumerate(lines[2:], start=3):
240
+ fields = _row(line)
241
+ if not fields or not any(fields):
242
+ continue
243
+ for source in expected:
244
+ time_index, value_index = source_positions[source]
245
+ try:
246
+ t = float(fields[time_index])
247
+ value = float(fields[value_index])
248
+ except (IndexError, ValueError) as exc:
249
+ raise CaptureError(f"{path}: row {row_number} contains invalid paired XY data") from exc
250
+ _validate_sample_row(t, (value,), row_number, path)
251
+ if source_times[source] and t <= source_times[source][-1]:
252
+ raise CaptureError(f"{path}: {source} time must be strictly increasing (row {row_number})")
253
+ source_times[source].append(t)
254
+ source_values[source].append(value)
255
+
256
+ reference_source = next(iter(sorted(expected)))
257
+ reference_times = tuple(source_times[reference_source])
258
+ if len(reference_times) < 2:
259
+ raise CaptureError(f"{path}: capture needs at least two samples")
260
+ for source in expected:
261
+ if tuple(source_times[source]) != reference_times:
262
+ raise CaptureError(f"{path}: paired XY source {source} does not share the same time axis")
263
+
264
+ signal_values = {
265
+ name: tuple(source_values[signal.column])
266
+ for name, signal in contract.signals.items()
267
+ }
268
+ return Capture(reference_times, signal_values)
269
+
270
+
271
+ _LEGACY_XY_METADATA = {
272
+ "Record Length",
273
+ "Sample Interval",
274
+ "Trigger Point",
275
+ "Trigger Time",
276
+ "Horizontal Offset",
277
+ }
278
+
279
+
280
+ def _looks_like_legacy_metadata_xy_csv(lines: list[str]) -> bool:
281
+ hits = 0
282
+ numeric_xy = 0
283
+ for line in lines[:20]:
284
+ fields = _row(line)
285
+ if fields and fields[0] in _LEGACY_XY_METADATA:
286
+ hits += 1
287
+ if len(fields) >= 5:
288
+ try:
289
+ float(fields[3])
290
+ float(fields[4])
291
+ except ValueError:
292
+ pass
293
+ else:
294
+ numeric_xy += 1
295
+ return hits >= 2 and numeric_xy >= 2
296
+
297
+
298
+ def _load_legacy_metadata_xy_csv(lines: list[str], contract: Contract, path: Path) -> Capture:
299
+ if len(contract.signals) != 1:
300
+ raise CaptureError(
301
+ f"{path}: legacy metadata+XY CSV contains one waveform; "
302
+ "use a single-signal contract or normalize multiple traces into one table"
303
+ )
304
+ signal_name, signal = next(iter(contract.signals.items()))
305
+ if signal.column.lower() not in {"value", "voltage", "amplitude"}:
306
+ raise CaptureError(
307
+ f"{path}: legacy metadata+XY CSV has no embedded channel name; "
308
+ f"map signals.{signal_name}.column to Value"
309
+ )
310
+
311
+ times: list[float] = []
312
+ values: list[float] = []
313
+ for row_number, line in enumerate(lines, start=1):
314
+ fields = _row(line)
315
+ if len(fields) < 5:
316
+ continue
317
+ try:
318
+ t = float(fields[3])
319
+ value = float(fields[4])
320
+ except ValueError:
321
+ continue
322
+ _validate_sample_row(t, (value,), row_number, path)
323
+ if times and t <= times[-1]:
324
+ raise CaptureError(f"{path}: time must be strictly increasing (row {row_number})")
325
+ times.append(t)
326
+ values.append(value)
327
+
328
+ if len(times) < 2:
329
+ raise CaptureError(f"{path}: capture needs at least two waveform samples")
330
+ return Capture(tuple(times), {signal_name: tuple(values)})
331
+
332
+
333
+ def _looks_like_siglent_csv(lines: list[str]) -> bool:
334
+ head = lines[:100]
335
+ return "Second,Value" in head and any(line.startswith("Source,CH") for line in head)
336
+
337
+
338
+ def _load_siglent_group(paths: list[Path], contract: Contract) -> Capture:
339
+ traces: dict[str, tuple[dict[str, str], tuple[float, ...], tuple[float, ...]]] = {}
340
+ for path in paths:
341
+ metadata, times, values = _read_siglent_trace(path)
342
+ source = metadata["Source"]
343
+ if source in traces:
344
+ raise CaptureError(f"Siglent source {source} appears more than once")
345
+ traces[source] = (metadata, times, values)
346
+
347
+ expected_sources = {signal.column for signal in contract.signals.values()}
348
+ missing = expected_sources.difference(traces)
349
+ if missing:
350
+ raise CaptureError(
351
+ "Siglent capture group is missing source(s): " + ", ".join(sorted(missing))
352
+ )
353
+
354
+ reference_source = next(iter(sorted(traces)))
355
+ reference_meta, reference_times, _ = traces[reference_source]
356
+ comparable_metadata = (
357
+ "Record Length",
358
+ "Sample Interval",
359
+ "Horizontal Units",
360
+ "Horizontal Scale",
361
+ "Model",
362
+ "Serial Number",
363
+ "Software Version",
364
+ )
365
+ # Validate every file the user supplied, not only the sources referenced by
366
+ # the contract. Silently ignoring an incompatible extra file would make a
367
+ # claimed capture group less trustworthy.
368
+ for source in sorted(traces):
369
+ metadata, times, _ = traces[source]
370
+ if times != reference_times:
371
+ raise CaptureError(f"Siglent source {source} does not share the same time axis")
372
+ for key in comparable_metadata:
373
+ if metadata.get(key) != reference_meta.get(key):
374
+ raise CaptureError(
375
+ f"Siglent source {source} has different {key!r}: "
376
+ f"{metadata.get(key)!r} != {reference_meta.get(key)!r}"
377
+ )
378
+ if metadata.get("Horizontal Units") != "s":
379
+ raise CaptureError(f"Siglent source {source} horizontal units must be seconds")
380
+ if metadata.get("Vertical Units") != "V":
381
+ raise CaptureError(f"Siglent source {source} vertical units must be volts")
382
+
383
+ signal_values = {
384
+ name: traces[signal.column][2]
385
+ for name, signal in contract.signals.items()
386
+ }
387
+ return Capture(reference_times, signal_values)
388
+
389
+
390
+ def _read_siglent_trace(
391
+ path: Path,
392
+ ) -> tuple[dict[str, str], tuple[float, ...], tuple[float, ...]]:
393
+ lines = _read_lines(path)
394
+ try:
395
+ header_index = lines.index("Second,Value")
396
+ except ValueError as exc:
397
+ raise CaptureError(f"{path} is not a supported Siglent CSV export") from exc
398
+
399
+ metadata: dict[str, str] = {}
400
+ for line in lines[:header_index]:
401
+ fields = next(csv.reader([line]))
402
+ if len(fields) >= 2:
403
+ key = fields[0].strip()
404
+ if not key:
405
+ continue
406
+ if key in metadata:
407
+ raise CaptureError(f"{path}: duplicate Siglent metadata field {key!r}")
408
+ metadata[key] = fields[1].strip()
409
+
410
+ required_meta = {
411
+ "Record Length",
412
+ "Sample Interval",
413
+ "Vertical Units",
414
+ "Horizontal Units",
415
+ "Source",
416
+ }
417
+ missing_meta = required_meta.difference(metadata)
418
+ if missing_meta:
419
+ raise CaptureError(
420
+ f"{path} is missing Siglent metadata: {', '.join(sorted(missing_meta))}"
421
+ )
422
+
423
+ reader = csv.DictReader(lines[header_index:])
424
+ times: list[float] = []
425
+ values: list[float] = []
426
+ for row_number, row in enumerate(reader, start=header_index + 2):
427
+ try:
428
+ t = float(row["Second"])
429
+ value = float(row["Value"])
430
+ except (KeyError, TypeError, ValueError) as exc:
431
+ raise CaptureError(f"{path}: row {row_number} is not numeric") from exc
432
+ _validate_sample_row(t, (value,), row_number, path)
433
+ if times and t <= times[-1]:
434
+ raise CaptureError(f"{path}: time must be strictly increasing (row {row_number})")
435
+ times.append(t)
436
+ values.append(value)
437
+
438
+ if len(times) < 2:
439
+ raise CaptureError(f"{path}: capture needs at least two samples")
440
+
441
+ record_length = metadata["Record Length"]
442
+ if not record_length.startswith("Analog:"):
443
+ raise CaptureError(f"{path}: unsupported Record Length {record_length!r}")
444
+ try:
445
+ declared = int(record_length.removeprefix("Analog:"))
446
+ except ValueError as exc:
447
+ raise CaptureError(f"{path}: invalid Record Length {record_length!r}") from exc
448
+ if declared != len(times):
449
+ raise CaptureError(
450
+ f"{path}: Record Length declares {declared} samples but file contains {len(times)}"
451
+ )
452
+
453
+ try:
454
+ declared_interval = float(metadata["Sample Interval"])
455
+ except ValueError as exc:
456
+ raise CaptureError(
457
+ f"{path}: invalid Sample Interval {metadata['Sample Interval']!r}"
458
+ ) from exc
459
+ if not isfinite(declared_interval) or declared_interval <= 0:
460
+ raise CaptureError(
461
+ f"{path}: Sample Interval must be finite and > 0, got {metadata['Sample Interval']!r}"
462
+ )
463
+
464
+ interval_abs_tol = max(1e-15, declared_interval * 1e-9)
465
+ for index in range(1, len(times)):
466
+ observed_interval = times[index] - times[index - 1]
467
+ if not isclose(
468
+ observed_interval,
469
+ declared_interval,
470
+ rel_tol=1e-6,
471
+ abs_tol=interval_abs_tol,
472
+ ):
473
+ raise CaptureError(
474
+ f"{path}: time axis interval at sample {index} is {observed_interval:.9g} s "
475
+ f"but metadata declares {declared_interval:.9g} s"
476
+ )
477
+
478
+ return metadata, tuple(times), tuple(values)
479
+
480
+
481
+ def _validate_sample_row(
482
+ time: float,
483
+ values,
484
+ row_number: int,
485
+ path: Path | None = None,
486
+ ) -> None:
487
+ prefix = f"{path}: " if path is not None else ""
488
+ if not isfinite(time) or any(not isfinite(value) for value in values):
489
+ raise CaptureError(f"{prefix}row {row_number} contains non-finite waveform data")
490
+
491
+
492
+ def _find_header(lines: list[str], expected: set[str]) -> tuple[int, str]:
493
+ for index, line in enumerate(lines[:100]):
494
+ for delimiter in (",", ";", "\t"):
495
+ fields = [field.strip().strip('"') for field in next(csv.reader([line], delimiter=delimiter))]
496
+ if expected.issubset(fields):
497
+ return index, delimiter
498
+ raise CaptureError("could not find a CSV header containing all configured columns")
powercontract/cli.py ADDED
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .capture import load_capture
10
+ from .errors import PowerContractError
11
+ from .evaluate import evaluate
12
+ from .spec import load_contract
13
+ from .units import format_seconds
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="powercontract",
19
+ description="Validate oscilloscope captures against electrical requirements.",
20
+ )
21
+ parser.add_argument(
22
+ "--version",
23
+ action="version",
24
+ version=f"%(prog)s {__version__}",
25
+ )
26
+ sub = parser.add_subparsers(dest="command", required=True)
27
+ check = sub.add_parser("check", help="evaluate capture data against one contract")
28
+ check.add_argument("capture", type=Path)
29
+ check.add_argument("contract", type=Path)
30
+ check.add_argument(
31
+ "--with",
32
+ dest="additional_captures",
33
+ action="append",
34
+ default=[],
35
+ type=Path,
36
+ metavar="CAPTURE",
37
+ help="add another per-channel capture file from the same acquisition",
38
+ )
39
+ check.add_argument("--json", dest="json_path", type=Path, help="write a machine-readable report")
40
+ return parser
41
+
42
+
43
+ def main(argv: list[str] | None = None) -> int:
44
+ args = build_parser().parse_args(argv)
45
+ try:
46
+ contract = load_contract(args.contract)
47
+ capture = load_capture(args.capture, contract, args.additional_captures)
48
+ result = evaluate(contract, capture)
49
+ except PowerContractError as exc:
50
+ print(f"error: {exc}", file=sys.stderr)
51
+ return 2
52
+
53
+ for finding in result.findings:
54
+ margin = ""
55
+ if finding.margin is not None:
56
+ if finding.unit == "s":
57
+ margin = f"; margin {format_seconds(finding.margin)}"
58
+ else:
59
+ margin = f"; margin {finding.margin:.6g} {finding.unit or ''}".rstrip()
60
+ print(f"{finding.status:<13} {finding.requirement_id:<16} {finding.summary}{margin}")
61
+
62
+ passed = sum(f.status == "PASS" for f in result.findings)
63
+ failed = sum(f.status == "FAIL" for f in result.findings)
64
+ indeterminate = sum(f.status == "INDETERMINATE" for f in result.findings)
65
+ print(
66
+ f"\n{passed}/{len(result.findings)} requirements passed"
67
+ f"; {failed} failed; {indeterminate} indeterminate"
68
+ )
69
+
70
+ if args.json_path:
71
+ payload = {
72
+ "schema_version": 1,
73
+ "powercontract_version": __version__,
74
+ "passed": result.passed,
75
+ "events": result.events,
76
+ "findings": [finding.as_dict() for finding in result.findings],
77
+ }
78
+ try:
79
+ args.json_path.write_text(
80
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
81
+ encoding="utf-8",
82
+ )
83
+ except OSError as exc:
84
+ print(f"error: cannot write JSON report: {exc}", file=sys.stderr)
85
+ return 2
86
+
87
+ return 0 if result.passed else 1
88
+
89
+
90
+ if __name__ == "__main__":
91
+ raise SystemExit(main())
@@ -0,0 +1,10 @@
1
+ class PowerContractError(Exception):
2
+ """Base error for invalid input or unsupported specifications."""
3
+
4
+
5
+ class SpecError(PowerContractError):
6
+ """Raised when a contract file is invalid."""
7
+
8
+
9
+ class CaptureError(PowerContractError):
10
+ """Raised when a capture cannot be parsed safely."""