kontra 0.5.2__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.
Files changed (124) hide show
  1. kontra/__init__.py +1871 -0
  2. kontra/api/__init__.py +22 -0
  3. kontra/api/compare.py +340 -0
  4. kontra/api/decorators.py +153 -0
  5. kontra/api/results.py +2121 -0
  6. kontra/api/rules.py +681 -0
  7. kontra/cli/__init__.py +0 -0
  8. kontra/cli/commands/__init__.py +1 -0
  9. kontra/cli/commands/config.py +153 -0
  10. kontra/cli/commands/diff.py +450 -0
  11. kontra/cli/commands/history.py +196 -0
  12. kontra/cli/commands/profile.py +289 -0
  13. kontra/cli/commands/validate.py +468 -0
  14. kontra/cli/constants.py +6 -0
  15. kontra/cli/main.py +48 -0
  16. kontra/cli/renderers.py +304 -0
  17. kontra/cli/utils.py +28 -0
  18. kontra/config/__init__.py +34 -0
  19. kontra/config/loader.py +127 -0
  20. kontra/config/models.py +49 -0
  21. kontra/config/settings.py +797 -0
  22. kontra/connectors/__init__.py +0 -0
  23. kontra/connectors/db_utils.py +251 -0
  24. kontra/connectors/detection.py +323 -0
  25. kontra/connectors/handle.py +368 -0
  26. kontra/connectors/postgres.py +127 -0
  27. kontra/connectors/sqlserver.py +226 -0
  28. kontra/engine/__init__.py +0 -0
  29. kontra/engine/backends/duckdb_session.py +227 -0
  30. kontra/engine/backends/duckdb_utils.py +18 -0
  31. kontra/engine/backends/polars_backend.py +47 -0
  32. kontra/engine/engine.py +1205 -0
  33. kontra/engine/executors/__init__.py +15 -0
  34. kontra/engine/executors/base.py +50 -0
  35. kontra/engine/executors/database_base.py +528 -0
  36. kontra/engine/executors/duckdb_sql.py +607 -0
  37. kontra/engine/executors/postgres_sql.py +162 -0
  38. kontra/engine/executors/registry.py +69 -0
  39. kontra/engine/executors/sqlserver_sql.py +163 -0
  40. kontra/engine/materializers/__init__.py +14 -0
  41. kontra/engine/materializers/base.py +42 -0
  42. kontra/engine/materializers/duckdb.py +110 -0
  43. kontra/engine/materializers/factory.py +22 -0
  44. kontra/engine/materializers/polars_connector.py +131 -0
  45. kontra/engine/materializers/postgres.py +157 -0
  46. kontra/engine/materializers/registry.py +138 -0
  47. kontra/engine/materializers/sqlserver.py +160 -0
  48. kontra/engine/result.py +15 -0
  49. kontra/engine/sql_utils.py +611 -0
  50. kontra/engine/sql_validator.py +609 -0
  51. kontra/engine/stats.py +194 -0
  52. kontra/engine/types.py +138 -0
  53. kontra/errors.py +533 -0
  54. kontra/logging.py +85 -0
  55. kontra/preplan/__init__.py +5 -0
  56. kontra/preplan/planner.py +253 -0
  57. kontra/preplan/postgres.py +179 -0
  58. kontra/preplan/sqlserver.py +191 -0
  59. kontra/preplan/types.py +24 -0
  60. kontra/probes/__init__.py +20 -0
  61. kontra/probes/compare.py +400 -0
  62. kontra/probes/relationship.py +283 -0
  63. kontra/reporters/__init__.py +0 -0
  64. kontra/reporters/json_reporter.py +190 -0
  65. kontra/reporters/rich_reporter.py +11 -0
  66. kontra/rules/__init__.py +35 -0
  67. kontra/rules/base.py +186 -0
  68. kontra/rules/builtin/__init__.py +40 -0
  69. kontra/rules/builtin/allowed_values.py +156 -0
  70. kontra/rules/builtin/compare.py +188 -0
  71. kontra/rules/builtin/conditional_not_null.py +213 -0
  72. kontra/rules/builtin/conditional_range.py +310 -0
  73. kontra/rules/builtin/contains.py +138 -0
  74. kontra/rules/builtin/custom_sql_check.py +182 -0
  75. kontra/rules/builtin/disallowed_values.py +140 -0
  76. kontra/rules/builtin/dtype.py +203 -0
  77. kontra/rules/builtin/ends_with.py +129 -0
  78. kontra/rules/builtin/freshness.py +240 -0
  79. kontra/rules/builtin/length.py +193 -0
  80. kontra/rules/builtin/max_rows.py +35 -0
  81. kontra/rules/builtin/min_rows.py +46 -0
  82. kontra/rules/builtin/not_null.py +121 -0
  83. kontra/rules/builtin/range.py +222 -0
  84. kontra/rules/builtin/regex.py +143 -0
  85. kontra/rules/builtin/starts_with.py +129 -0
  86. kontra/rules/builtin/unique.py +124 -0
  87. kontra/rules/condition_parser.py +203 -0
  88. kontra/rules/execution_plan.py +455 -0
  89. kontra/rules/factory.py +103 -0
  90. kontra/rules/predicates.py +25 -0
  91. kontra/rules/registry.py +24 -0
  92. kontra/rules/static_predicates.py +120 -0
  93. kontra/scout/__init__.py +9 -0
  94. kontra/scout/backends/__init__.py +17 -0
  95. kontra/scout/backends/base.py +111 -0
  96. kontra/scout/backends/duckdb_backend.py +359 -0
  97. kontra/scout/backends/postgres_backend.py +519 -0
  98. kontra/scout/backends/sqlserver_backend.py +577 -0
  99. kontra/scout/dtype_mapping.py +150 -0
  100. kontra/scout/patterns.py +69 -0
  101. kontra/scout/profiler.py +801 -0
  102. kontra/scout/reporters/__init__.py +39 -0
  103. kontra/scout/reporters/json_reporter.py +165 -0
  104. kontra/scout/reporters/markdown_reporter.py +152 -0
  105. kontra/scout/reporters/rich_reporter.py +144 -0
  106. kontra/scout/store.py +208 -0
  107. kontra/scout/suggest.py +200 -0
  108. kontra/scout/types.py +652 -0
  109. kontra/state/__init__.py +29 -0
  110. kontra/state/backends/__init__.py +79 -0
  111. kontra/state/backends/base.py +348 -0
  112. kontra/state/backends/local.py +480 -0
  113. kontra/state/backends/postgres.py +1010 -0
  114. kontra/state/backends/s3.py +543 -0
  115. kontra/state/backends/sqlserver.py +969 -0
  116. kontra/state/fingerprint.py +166 -0
  117. kontra/state/types.py +1061 -0
  118. kontra/version.py +1 -0
  119. kontra-0.5.2.dist-info/METADATA +122 -0
  120. kontra-0.5.2.dist-info/RECORD +124 -0
  121. kontra-0.5.2.dist-info/WHEEL +5 -0
  122. kontra-0.5.2.dist-info/entry_points.txt +2 -0
  123. kontra-0.5.2.dist-info/licenses/LICENSE +17 -0
  124. kontra-0.5.2.dist-info/top_level.txt +1 -0
kontra/state/types.py ADDED
@@ -0,0 +1,1061 @@
1
+ # src/kontra/state/types.py
2
+ """
3
+ State data types for validation result persistence.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from enum import Enum
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from kontra.version import VERSION
15
+
16
+
17
+ # -----------------------------------------------------------------------------
18
+ # Annotation Types (v0.5)
19
+ # -----------------------------------------------------------------------------
20
+
21
+
22
+ @dataclass
23
+ class Annotation:
24
+ """
25
+ Annotation on a validation run or specific rule result.
26
+
27
+ Annotations provide "memory without authority" - agents and humans
28
+ can record context about runs (resolutions, root causes, acknowledgments)
29
+ without affecting Kontra's behavior.
30
+
31
+ Invariants:
32
+ - Append-only: annotations are never updated or deleted
33
+ - Uninterpreted: Kontra stores annotation_type but doesn't define vocabulary
34
+ - Opt-in reads: annotations never appear unless explicitly requested
35
+ - Never read during validation or diff
36
+
37
+ Common annotation_type values (suggested, not enforced):
38
+ - "resolution": I fixed this
39
+ - "root_cause": This failed because...
40
+ - "false_positive": This isn't actually a problem
41
+ - "acknowledged": I saw this, will address later
42
+ - "suppressed": Intentionally ignoring this
43
+ - "note": General comment
44
+ """
45
+
46
+ # Identity
47
+ id: Optional[int] = None # Database-assigned ID (None for new annotations)
48
+ run_id: int = 0 # Reference to kontra_runs.id
49
+ rule_result_id: Optional[int] = None # Reference to kontra_rule_results.id (None for run-level)
50
+ rule_id: Optional[str] = None # Semantic rule ID (e.g., "COL:email:not_null") for cross-run queries
51
+
52
+ # Who created it
53
+ actor_type: str = "agent" # "agent" | "human" | "system"
54
+ actor_id: str = "" # e.g., "repair-agent-v2", "alice@example.com"
55
+
56
+ # What it says
57
+ annotation_type: str = "" # Uninterpreted by Kontra
58
+ summary: str = "" # Human-readable summary
59
+ payload: Optional[Dict[str, Any]] = None # Arbitrary structured data
60
+
61
+ # When
62
+ created_at: Optional[datetime] = None
63
+
64
+ def __post_init__(self):
65
+ if self.created_at is None:
66
+ self.created_at = datetime.now(timezone.utc)
67
+
68
+ def to_dict(self) -> Dict[str, Any]:
69
+ """Convert to dictionary for serialization."""
70
+ d: Dict[str, Any] = {
71
+ "run_id": self.run_id,
72
+ "actor_type": self.actor_type,
73
+ "actor_id": self.actor_id,
74
+ "annotation_type": self.annotation_type,
75
+ "summary": self.summary,
76
+ "created_at": self.created_at.isoformat() if self.created_at else None,
77
+ }
78
+ if self.id is not None:
79
+ d["id"] = self.id
80
+ if self.rule_result_id is not None:
81
+ d["rule_result_id"] = self.rule_result_id
82
+ if self.rule_id is not None:
83
+ d["rule_id"] = self.rule_id
84
+ if self.payload is not None:
85
+ d["payload"] = self.payload
86
+ return d
87
+
88
+ @classmethod
89
+ def from_dict(cls, d: Dict[str, Any]) -> "Annotation":
90
+ """Create from dictionary."""
91
+ created_at = d.get("created_at")
92
+ if isinstance(created_at, str):
93
+ created_at = created_at.replace("Z", "+00:00")
94
+ created_at = datetime.fromisoformat(created_at)
95
+
96
+ return cls(
97
+ id=d.get("id"),
98
+ run_id=d.get("run_id", 0),
99
+ rule_result_id=d.get("rule_result_id"),
100
+ rule_id=d.get("rule_id"),
101
+ actor_type=d.get("actor_type", "agent"),
102
+ actor_id=d.get("actor_id", ""),
103
+ annotation_type=d.get("annotation_type", ""),
104
+ summary=d.get("summary", ""),
105
+ payload=d.get("payload"),
106
+ created_at=created_at,
107
+ )
108
+
109
+ def to_json(self) -> str:
110
+ """Serialize to JSON string (single line for JSONL)."""
111
+ return json.dumps(self.to_dict(), default=str)
112
+
113
+ @classmethod
114
+ def from_json(cls, json_str: str) -> "Annotation":
115
+ """Deserialize from JSON string."""
116
+ return cls.from_dict(json.loads(json_str))
117
+
118
+ def to_llm(self) -> str:
119
+ """Token-optimized format for LLM context."""
120
+ ts = self.created_at.strftime("%Y-%m-%d %H:%M") if self.created_at else "?"
121
+ parts = [
122
+ f"[{self.annotation_type}]",
123
+ f"by {self.actor_type}:{self.actor_id}",
124
+ f"@ {ts}",
125
+ ]
126
+ if self.summary:
127
+ parts.append(f'"{self.summary}"')
128
+ return " ".join(parts)
129
+
130
+
131
+ # -----------------------------------------------------------------------------
132
+ # Severity and Failure Mode Enums
133
+ # -----------------------------------------------------------------------------
134
+
135
+
136
+ class Severity(str, Enum):
137
+ """
138
+ Rule severity levels for pipeline control.
139
+
140
+ Severity determines how failures affect pipeline execution:
141
+ - BLOCKING: Fails the pipeline (exit code 1)
142
+ - WARNING: Warns but continues (exit code 0)
143
+ - INFO: Logs only, no warning (exit code 0)
144
+
145
+ Default severity is BLOCKING for backwards compatibility.
146
+ """
147
+
148
+ BLOCKING = "blocking" # Fails pipeline, exit code 1
149
+ WARNING = "warning" # Warns but continues, exit code 0
150
+ INFO = "info" # Logs only, exit code 0
151
+
152
+ def __str__(self) -> str:
153
+ return self.value
154
+
155
+ @classmethod
156
+ def from_str(cls, value: Optional[str]) -> "Severity":
157
+ """Parse severity from string, defaulting to BLOCKING."""
158
+ if value is None:
159
+ return cls.BLOCKING
160
+ try:
161
+ return cls(value.lower())
162
+ except ValueError:
163
+ return cls.BLOCKING
164
+
165
+
166
+ class FailureMode(str, Enum):
167
+ """
168
+ Semantic failure modes for agent reasoning.
169
+
170
+ Each failure mode indicates WHY a rule failed, enabling:
171
+ - Better error messages for humans
172
+ - Structured reasoning for LLM agents
173
+ - Targeted remediation suggestions
174
+ """
175
+
176
+ # Value-level failures
177
+ NOVEL_CATEGORY = "novel_category" # Unexpected values in allowed_values
178
+ NULL_VALUES = "null_values" # NULL values found
179
+ DUPLICATE_VALUES = "duplicate_values" # Uniqueness violated
180
+
181
+ # Range/bound failures
182
+ RANGE_VIOLATION = "range_violation" # Values outside min/max bounds
183
+
184
+ # Schema failures
185
+ SCHEMA_DRIFT = "schema_drift" # Column type doesn't match expected
186
+
187
+ # Temporal failures
188
+ FRESHNESS_LAG = "freshness_lag" # Data is stale
189
+
190
+ # Dataset-level failures
191
+ ROW_COUNT_LOW = "row_count_low" # Below minimum rows
192
+ ROW_COUNT_HIGH = "row_count_high" # Above maximum rows
193
+
194
+ # Pattern failures
195
+ PATTERN_MISMATCH = "pattern_mismatch" # Regex pattern not matched
196
+
197
+ # Custom rule failures
198
+ CUSTOM_CHECK_FAILED = "custom_check_failed" # custom_sql_check failed
199
+
200
+ # Cross-column failures
201
+ COMPARISON_FAILED = "comparison_failed" # Compare rule failed
202
+ CONDITIONAL_NULL = "conditional_null" # Conditional not-null failed
203
+ CONDITIONAL_RANGE_VIOLATION = "conditional_range_violation" # Conditional range failed
204
+
205
+ # Configuration/contract errors
206
+ CONFIG_ERROR = "config_error" # Contract/config issue (e.g., column not found)
207
+
208
+ def __str__(self) -> str:
209
+ return self.value
210
+
211
+
212
+ @dataclass
213
+ class RuleDiff:
214
+ """Diff for a single rule between two states."""
215
+
216
+ rule_id: str
217
+ change_type: str # "new_failure", "resolved", "regression", "improvement", "unchanged"
218
+
219
+ # Counts
220
+ before_count: int = 0
221
+ after_count: int = 0
222
+ delta: int = 0
223
+
224
+ # Status change
225
+ was_passing: bool = True
226
+ now_passing: bool = True
227
+
228
+ # Details from the after state
229
+ severity: str = "blocking" # blocking, warning, info
230
+ failure_mode: Optional[str] = None
231
+ message: Optional[str] = None
232
+
233
+ def to_dict(self) -> Dict[str, Any]:
234
+ """Convert to dictionary."""
235
+ return {
236
+ "rule_id": self.rule_id,
237
+ "change_type": self.change_type,
238
+ "before_count": self.before_count,
239
+ "after_count": self.after_count,
240
+ "delta": self.delta,
241
+ "was_passing": self.was_passing,
242
+ "now_passing": self.now_passing,
243
+ "severity": self.severity,
244
+ "failure_mode": self.failure_mode,
245
+ "message": self.message,
246
+ }
247
+
248
+
249
+ @dataclass
250
+ class RuleState:
251
+ """State for a single rule execution."""
252
+
253
+ rule_id: str
254
+ rule_name: str
255
+ passed: bool
256
+ failed_count: int
257
+ execution_source: str # "metadata", "sql", "polars"
258
+
259
+ # Severity level
260
+ severity: str = "blocking" # "blocking", "warning", "info"
261
+
262
+ # Optional details for failure analysis
263
+ failure_mode: Optional[str] = None # "novel_category", "null_spike", etc.
264
+ details: Optional[Dict[str, Any]] = None
265
+ message: Optional[str] = None
266
+
267
+ # Column info (if applicable)
268
+ column: Optional[str] = None
269
+
270
+ # Database-assigned ID for normalized schema (v0.5)
271
+ id: Optional[int] = None
272
+
273
+ # Annotations (opt-in, never loaded by default)
274
+ annotations: Optional[List["Annotation"]] = None
275
+
276
+ def to_dict(self, include_annotations: bool = False) -> Dict[str, Any]:
277
+ """Convert to dictionary for serialization."""
278
+ d: Dict[str, Any] = {
279
+ "rule_id": self.rule_id,
280
+ "rule_name": self.rule_name,
281
+ "passed": self.passed,
282
+ "failed_count": self.failed_count,
283
+ "execution_source": self.execution_source,
284
+ "severity": self.severity,
285
+ }
286
+ if self.failure_mode:
287
+ d["failure_mode"] = self.failure_mode
288
+ if self.details:
289
+ d["details"] = self.details
290
+ if self.message:
291
+ d["message"] = self.message
292
+ if self.column:
293
+ d["column"] = self.column
294
+ if self.id is not None:
295
+ d["id"] = self.id
296
+ if include_annotations and self.annotations:
297
+ d["annotations"] = [a.to_dict() for a in self.annotations]
298
+ return d
299
+
300
+ @classmethod
301
+ def from_dict(cls, d: Dict[str, Any]) -> "RuleState":
302
+ """Create from dictionary."""
303
+ annotations = None
304
+ if "annotations" in d and d["annotations"]:
305
+ annotations = [Annotation.from_dict(a) for a in d["annotations"]]
306
+
307
+ return cls(
308
+ rule_id=d["rule_id"],
309
+ rule_name=d["rule_name"],
310
+ passed=d["passed"],
311
+ failed_count=d["failed_count"],
312
+ execution_source=d["execution_source"],
313
+ severity=d.get("severity", "blocking"),
314
+ failure_mode=d.get("failure_mode"),
315
+ details=d.get("details"),
316
+ message=d.get("message"),
317
+ column=d.get("column"),
318
+ id=d.get("id"),
319
+ annotations=annotations,
320
+ )
321
+
322
+ @classmethod
323
+ def from_result(cls, result: Dict[str, Any]) -> RuleState:
324
+ """Create from validation engine result dict."""
325
+ # Extract column from rule_id if present (COL:column:rule_name format)
326
+ rule_id = result.get("rule_id", "")
327
+ column = None
328
+ if rule_id.startswith("COL:"):
329
+ parts = rule_id.split(":")
330
+ if len(parts) >= 2:
331
+ column = parts[1]
332
+
333
+ return cls(
334
+ rule_id=rule_id,
335
+ rule_name=result.get("rule_name", result.get("name", "")),
336
+ passed=result.get("passed", False),
337
+ failed_count=result.get("failed_count", 0),
338
+ execution_source=result.get("execution_source", "polars"),
339
+ severity=result.get("severity", "blocking"),
340
+ failure_mode=result.get("failure_mode"),
341
+ details=result.get("details"),
342
+ message=result.get("message"),
343
+ column=column,
344
+ )
345
+
346
+
347
+ @dataclass
348
+ class StateSummary:
349
+ """Summary statistics for a validation run."""
350
+
351
+ passed: bool
352
+ total_rules: int
353
+ passed_rules: int
354
+ failed_rules: int
355
+ row_count: Optional[int] = None
356
+ column_count: Optional[int] = None
357
+
358
+ # Severity-based failure counts
359
+ blocking_failures: int = 0
360
+ warning_failures: int = 0
361
+ info_failures: int = 0
362
+
363
+ def to_dict(self) -> Dict[str, Any]:
364
+ """Convert to dictionary."""
365
+ d: Dict[str, Any] = {
366
+ "passed": self.passed,
367
+ "total_rules": self.total_rules,
368
+ "passed_rules": self.passed_rules,
369
+ "failed_rules": self.failed_rules,
370
+ "blocking_failures": self.blocking_failures,
371
+ "warning_failures": self.warning_failures,
372
+ "info_failures": self.info_failures,
373
+ }
374
+ if self.row_count is not None:
375
+ d["row_count"] = self.row_count
376
+ if self.column_count is not None:
377
+ d["column_count"] = self.column_count
378
+ return d
379
+
380
+ @classmethod
381
+ def from_dict(cls, d: Dict[str, Any]) -> StateSummary:
382
+ """Create from dictionary."""
383
+ return cls(
384
+ passed=d["passed"],
385
+ total_rules=d["total_rules"],
386
+ passed_rules=d["passed_rules"],
387
+ failed_rules=d["failed_rules"],
388
+ row_count=d.get("row_count"),
389
+ column_count=d.get("column_count"),
390
+ blocking_failures=d.get("blocking_failures", 0),
391
+ warning_failures=d.get("warning_failures", 0),
392
+ info_failures=d.get("info_failures", 0),
393
+ )
394
+
395
+
396
+ @dataclass
397
+ class ValidationState:
398
+ """
399
+ Complete state snapshot for a validation run.
400
+
401
+ Designed for:
402
+ - Persistence to local filesystem, S3, or database
403
+ - Comparison across runs (diff)
404
+ - Agent reasoning about changes over time
405
+ """
406
+
407
+ # Identification
408
+ contract_fingerprint: str
409
+ dataset_fingerprint: Optional[str]
410
+ contract_name: str
411
+ dataset_uri: str
412
+
413
+ # Timing
414
+ run_at: datetime
415
+
416
+ # Results
417
+ summary: StateSummary
418
+ rules: List[RuleState]
419
+
420
+ # Metadata
421
+ schema_version: str = "2.0" # v2.0 for normalized schema
422
+ engine_version: str = field(default_factory=lambda: VERSION)
423
+
424
+ # Optional context
425
+ duration_ms: Optional[int] = None
426
+
427
+ # Database-assigned ID for normalized schema (v0.5)
428
+ id: Optional[int] = None
429
+
430
+ # Annotations (opt-in, never loaded by default)
431
+ annotations: Optional[List["Annotation"]] = None
432
+
433
+ def to_dict(self, include_annotations: bool = False) -> Dict[str, Any]:
434
+ """Convert to dictionary for JSON serialization."""
435
+ d: Dict[str, Any] = {
436
+ "schema_version": self.schema_version,
437
+ "engine_version": self.engine_version,
438
+ "contract_fingerprint": self.contract_fingerprint,
439
+ "dataset_fingerprint": self.dataset_fingerprint,
440
+ "contract_name": self.contract_name,
441
+ "dataset_uri": self.dataset_uri,
442
+ "run_at": self.run_at.isoformat(),
443
+ "summary": self.summary.to_dict(),
444
+ "rules": [r.to_dict(include_annotations=include_annotations) for r in self.rules],
445
+ "duration_ms": self.duration_ms,
446
+ }
447
+ if self.id is not None:
448
+ d["id"] = self.id
449
+ if include_annotations and self.annotations:
450
+ d["annotations"] = [a.to_dict() for a in self.annotations]
451
+ return d
452
+
453
+ @classmethod
454
+ def from_dict(cls, d: Dict[str, Any]) -> "ValidationState":
455
+ """Create from dictionary."""
456
+ run_at = d["run_at"]
457
+ if isinstance(run_at, str):
458
+ # Parse ISO format, handle both Z and +00:00 suffixes
459
+ run_at = run_at.replace("Z", "+00:00")
460
+ run_at = datetime.fromisoformat(run_at)
461
+
462
+ annotations = None
463
+ if "annotations" in d and d["annotations"]:
464
+ annotations = [Annotation.from_dict(a) for a in d["annotations"]]
465
+
466
+ return cls(
467
+ schema_version=d.get("schema_version", "1.0"),
468
+ engine_version=d.get("engine_version", "unknown"),
469
+ contract_fingerprint=d["contract_fingerprint"],
470
+ dataset_fingerprint=d.get("dataset_fingerprint"),
471
+ contract_name=d["contract_name"],
472
+ dataset_uri=d["dataset_uri"],
473
+ run_at=run_at,
474
+ summary=StateSummary.from_dict(d["summary"]),
475
+ rules=[RuleState.from_dict(r) for r in d["rules"]],
476
+ duration_ms=d.get("duration_ms"),
477
+ id=d.get("id"),
478
+ annotations=annotations,
479
+ )
480
+
481
+ def to_json(self, indent: int = 2, include_annotations: bool = False) -> str:
482
+ """Serialize to JSON string."""
483
+ return json.dumps(
484
+ self.to_dict(include_annotations=include_annotations),
485
+ indent=indent,
486
+ default=str,
487
+ )
488
+
489
+ @classmethod
490
+ def from_json(cls, json_str: str) -> "ValidationState":
491
+ """Deserialize from JSON string."""
492
+ return cls.from_dict(json.loads(json_str))
493
+
494
+ @classmethod
495
+ def from_validation_result(
496
+ cls,
497
+ result: Dict[str, Any],
498
+ contract_fingerprint: str,
499
+ dataset_fingerprint: Optional[str],
500
+ contract_name: str,
501
+ dataset_uri: str,
502
+ ) -> ValidationState:
503
+ """
504
+ Create a ValidationState from engine.run() result.
505
+
506
+ Args:
507
+ result: The dict returned by ValidationEngine.run()
508
+ contract_fingerprint: Hash of the contract
509
+ dataset_fingerprint: Hash of dataset metadata (optional)
510
+ contract_name: Name from contract
511
+ dataset_uri: URI of the dataset
512
+ """
513
+ summary_data = result.get("summary", {})
514
+ results_list = result.get("results", [])
515
+ stats = result.get("stats", {})
516
+
517
+ # Build summary
518
+ total = summary_data.get("total_rules", len(results_list))
519
+ passed_count = sum(1 for r in results_list if r.get("passed", False))
520
+ failed_count = total - passed_count
521
+
522
+ # Count failures by severity
523
+ blocking_failures = 0
524
+ warning_failures = 0
525
+ info_failures = 0
526
+ for r in results_list:
527
+ if not r.get("passed", False):
528
+ severity = r.get("severity", "blocking")
529
+ if severity == "blocking":
530
+ blocking_failures += 1
531
+ elif severity == "warning":
532
+ warning_failures += 1
533
+ elif severity == "info":
534
+ info_failures += 1
535
+
536
+ # Get row/column counts from stats if available
537
+ dataset_stats = stats.get("dataset", {}) if stats else {}
538
+ row_count = dataset_stats.get("nrows")
539
+ column_count = dataset_stats.get("ncols")
540
+
541
+ # Use summary_data if available, otherwise calculate
542
+ summary = StateSummary(
543
+ passed=summary_data.get("passed", blocking_failures == 0),
544
+ total_rules=total,
545
+ passed_rules=passed_count,
546
+ failed_rules=failed_count,
547
+ row_count=row_count,
548
+ column_count=column_count,
549
+ blocking_failures=summary_data.get("blocking_failures", blocking_failures),
550
+ warning_failures=summary_data.get("warning_failures", warning_failures),
551
+ info_failures=summary_data.get("info_failures", info_failures),
552
+ )
553
+
554
+ # Build rule states
555
+ rules = [RuleState.from_result(r) for r in results_list]
556
+
557
+ # Duration
558
+ run_meta = stats.get("run_meta", {}) if stats else {}
559
+ duration_ms = run_meta.get("duration_ms_total")
560
+
561
+ return cls(
562
+ contract_fingerprint=contract_fingerprint,
563
+ dataset_fingerprint=dataset_fingerprint,
564
+ contract_name=contract_name,
565
+ dataset_uri=dataset_uri,
566
+ run_at=datetime.now(timezone.utc),
567
+ summary=summary,
568
+ rules=rules,
569
+ duration_ms=duration_ms,
570
+ )
571
+
572
+ def get_rule(self, rule_id: str) -> Optional[RuleState]:
573
+ """Get a specific rule state by ID."""
574
+ for rule in self.rules:
575
+ if rule.rule_id == rule_id:
576
+ return rule
577
+ return None
578
+
579
+ def get_failed_rules(self) -> List[RuleState]:
580
+ """Get all failed rules."""
581
+ return [r for r in self.rules if not r.passed]
582
+
583
+ def get_passed_rules(self) -> List[RuleState]:
584
+ """Get all passed rules."""
585
+ return [r for r in self.rules if r.passed]
586
+
587
+ def to_llm(self) -> str:
588
+ """
589
+ Render state in token-optimized format for LLM context.
590
+
591
+ Design:
592
+ - Failed rules get detail, passed rules get summarized
593
+ - Failure modes and severity surfaced for reasoning
594
+ - Compact but complete enough for agent decisions
595
+ """
596
+ lines = []
597
+
598
+ # Header
599
+ ts = self.run_at.strftime("%Y-%m-%dT%H:%M")
600
+ status = "PASSED" if self.summary.passed else "FAILED"
601
+ lines.append(f"# State: {self.contract_name} @ {ts}")
602
+ lines.append(f"result: {status} ({self.summary.passed_rules}/{self.summary.total_rules} passed)")
603
+
604
+ # Show severity breakdown if there are failures
605
+ if self.summary.failed_rules > 0:
606
+ severity_parts = []
607
+ if self.summary.blocking_failures > 0:
608
+ severity_parts.append(f"{self.summary.blocking_failures} blocking")
609
+ if self.summary.warning_failures > 0:
610
+ severity_parts.append(f"{self.summary.warning_failures} warning")
611
+ if self.summary.info_failures > 0:
612
+ severity_parts.append(f"{self.summary.info_failures} info")
613
+ if severity_parts:
614
+ lines.append(f"failures: {', '.join(severity_parts)}")
615
+
616
+ if self.summary.row_count:
617
+ lines.append(f"rows: {self.summary.row_count:,}")
618
+
619
+ # Failed rules with details
620
+ failed = self.get_failed_rules()
621
+ if failed:
622
+ lines.append("")
623
+ lines.append(f"## Failed ({len(failed)})")
624
+ for rule in failed[:10]: # Limit to top 10
625
+ parts = [rule.rule_id]
626
+ # Include severity if not blocking
627
+ if rule.severity != "blocking":
628
+ parts.append(f"[{rule.severity}]")
629
+ if rule.failed_count > 0:
630
+ count_str = f"{rule.failed_count:,}" if rule.failed_count < 1000000 else f"{rule.failed_count/1000000:.1f}M"
631
+ parts.append(f"{count_str} failures")
632
+ if rule.failure_mode:
633
+ parts.append(rule.failure_mode)
634
+ if rule.message:
635
+ # Truncate long messages
636
+ msg = rule.message[:50] + "..." if len(rule.message) > 50 else rule.message
637
+ parts.append(msg)
638
+ lines.append(f"- {' | '.join(parts)}")
639
+
640
+ if len(failed) > 10:
641
+ lines.append(f" ... and {len(failed) - 10} more")
642
+
643
+ # Passed rules summary (grouped by execution source)
644
+ passed = self.get_passed_rules()
645
+ if passed:
646
+ lines.append("")
647
+ lines.append(f"## Passed ({len(passed)})")
648
+
649
+ # Group by execution source
650
+ by_source: Dict[str, List[RuleState]] = {}
651
+ for rule in passed:
652
+ src = rule.execution_source or "unknown"
653
+ by_source.setdefault(src, []).append(rule)
654
+
655
+ # Group by rule name within source
656
+ summary_parts = []
657
+ for src, rules in sorted(by_source.items()):
658
+ # Count by rule name
659
+ by_name: Dict[str, int] = {}
660
+ for r in rules:
661
+ name = r.rule_id.split(":")[-1] if ":" in r.rule_id else r.rule_id
662
+ by_name[name] = by_name.get(name, 0) + 1
663
+
664
+ for name, count in sorted(by_name.items(), key=lambda x: -x[1]):
665
+ summary_parts.append(f"{count}x {name} [{src}]")
666
+
667
+ lines.append(", ".join(summary_parts[:8]))
668
+ if len(summary_parts) > 8:
669
+ lines.append(f" ... and {len(summary_parts) - 8} more categories")
670
+
671
+ # Footer with fingerprint (for agent tracking)
672
+ lines.append("")
673
+ lines.append(f"fingerprint: {self.contract_fingerprint}")
674
+
675
+ return "\n".join(lines)
676
+
677
+
678
+ @dataclass
679
+ class RunSummary:
680
+ """
681
+ Lightweight summary of a validation run for history listing.
682
+
683
+ Used by get_history() to return a list of runs without loading
684
+ full rule details. Optimized for quick scanning and display.
685
+ """
686
+
687
+ run_id: str # Unique identifier for the run
688
+ timestamp: datetime # When the run occurred
689
+ passed: bool # Overall pass/fail status
690
+ failed_count: int # Total failures across all rules
691
+ total_rows: Optional[int] # Row count if available
692
+ contract_name: str # Name of the contract
693
+ contract_fingerprint: str # Fingerprint for filtering
694
+
695
+ # Optional metadata
696
+ total_rules: int = 0
697
+ blocking_failures: int = 0
698
+ warning_failures: int = 0
699
+ duration_ms: Optional[int] = None
700
+
701
+ def to_dict(self) -> Dict[str, Any]:
702
+ """Convert to dictionary for serialization."""
703
+ d: Dict[str, Any] = {
704
+ "run_id": self.run_id,
705
+ "timestamp": self.timestamp.isoformat(),
706
+ "passed": self.passed,
707
+ "failed_count": self.failed_count,
708
+ "contract_name": self.contract_name,
709
+ "contract_fingerprint": self.contract_fingerprint,
710
+ "total_rules": self.total_rules,
711
+ "blocking_failures": self.blocking_failures,
712
+ "warning_failures": self.warning_failures,
713
+ }
714
+ if self.total_rows is not None:
715
+ d["total_rows"] = self.total_rows
716
+ if self.duration_ms is not None:
717
+ d["duration_ms"] = self.duration_ms
718
+ return d
719
+
720
+ @classmethod
721
+ def from_dict(cls, d: Dict[str, Any]) -> "RunSummary":
722
+ """Create from dictionary."""
723
+ timestamp = d["timestamp"]
724
+ if isinstance(timestamp, str):
725
+ timestamp = timestamp.replace("Z", "+00:00")
726
+ timestamp = datetime.fromisoformat(timestamp)
727
+
728
+ return cls(
729
+ run_id=d["run_id"],
730
+ timestamp=timestamp,
731
+ passed=d["passed"],
732
+ failed_count=d["failed_count"],
733
+ total_rows=d.get("total_rows"),
734
+ contract_name=d["contract_name"],
735
+ contract_fingerprint=d["contract_fingerprint"],
736
+ total_rules=d.get("total_rules", 0),
737
+ blocking_failures=d.get("blocking_failures", 0),
738
+ warning_failures=d.get("warning_failures", 0),
739
+ duration_ms=d.get("duration_ms"),
740
+ )
741
+
742
+ @classmethod
743
+ def from_validation_state(cls, state: "ValidationState", run_id: str) -> "RunSummary":
744
+ """Create from a full ValidationState."""
745
+ return cls(
746
+ run_id=run_id,
747
+ timestamp=state.run_at,
748
+ passed=state.summary.passed,
749
+ failed_count=state.summary.failed_rules,
750
+ total_rows=state.summary.row_count,
751
+ contract_name=state.contract_name,
752
+ contract_fingerprint=state.contract_fingerprint,
753
+ total_rules=state.summary.total_rules,
754
+ blocking_failures=state.summary.blocking_failures,
755
+ warning_failures=state.summary.warning_failures,
756
+ duration_ms=state.duration_ms,
757
+ )
758
+
759
+ def to_llm(self) -> str:
760
+ """Token-optimized format for LLM context."""
761
+ ts = self.timestamp.strftime("%Y-%m-%d %H:%M")
762
+ status = "PASS" if self.passed else "FAIL"
763
+ rows = f"{self.total_rows:,}" if self.total_rows else "?"
764
+ return f"{ts} | {status} | {self.failed_count} failures | {rows} rows"
765
+
766
+
767
+ @dataclass
768
+ class StateDiff:
769
+ """
770
+ Diff between two validation states.
771
+
772
+ Captures what changed between runs to enable:
773
+ - Human-readable diff output
774
+ - LLM reasoning about regressions
775
+ - Automated alerting on changes
776
+ """
777
+
778
+ # Source states
779
+ before: "ValidationState"
780
+ after: "ValidationState"
781
+
782
+ # Summary
783
+ status_changed: bool = False
784
+ has_regressions: bool = False
785
+ has_improvements: bool = False
786
+
787
+ # Rule-level changes
788
+ new_failures: List[RuleDiff] = field(default_factory=list)
789
+ resolved: List[RuleDiff] = field(default_factory=list)
790
+ regressions: List[RuleDiff] = field(default_factory=list) # count increased
791
+ improvements: List[RuleDiff] = field(default_factory=list) # count decreased
792
+ unchanged: List[RuleDiff] = field(default_factory=list)
793
+
794
+ @classmethod
795
+ def compute(cls, before: "ValidationState", after: "ValidationState") -> "StateDiff":
796
+ """
797
+ Compute diff between two states.
798
+
799
+ Args:
800
+ before: Earlier state
801
+ after: Later state
802
+
803
+ Returns:
804
+ StateDiff with categorized changes
805
+ """
806
+ diff = cls(before=before, after=after)
807
+ diff.status_changed = before.summary.passed != after.summary.passed
808
+
809
+ # Index rules by ID
810
+ before_rules = {r.rule_id: r for r in before.rules}
811
+ after_rules = {r.rule_id: r for r in after.rules}
812
+
813
+ # Process all rules in after state
814
+ for rule_id, after_rule in after_rules.items():
815
+ before_rule = before_rules.get(rule_id)
816
+
817
+ if before_rule is None:
818
+ # New rule (not in before state)
819
+ if not after_rule.passed:
820
+ rule_diff = RuleDiff(
821
+ rule_id=rule_id,
822
+ change_type="new_failure",
823
+ after_count=after_rule.failed_count,
824
+ delta=after_rule.failed_count,
825
+ was_passing=True, # Didn't exist, treat as "was passing"
826
+ now_passing=False,
827
+ severity=after_rule.severity,
828
+ failure_mode=after_rule.failure_mode,
829
+ message=after_rule.message,
830
+ )
831
+ diff.new_failures.append(rule_diff)
832
+ continue
833
+
834
+ # Rule exists in both states
835
+ was_passing = before_rule.passed
836
+ now_passing = after_rule.passed
837
+ before_count = before_rule.failed_count
838
+ after_count = after_rule.failed_count
839
+ delta = after_count - before_count
840
+
841
+ rule_diff = RuleDiff(
842
+ rule_id=rule_id,
843
+ change_type="unchanged",
844
+ before_count=before_count,
845
+ after_count=after_count,
846
+ delta=delta,
847
+ was_passing=was_passing,
848
+ now_passing=now_passing,
849
+ severity=after_rule.severity,
850
+ failure_mode=after_rule.failure_mode,
851
+ message=after_rule.message,
852
+ )
853
+
854
+ if was_passing and not now_passing:
855
+ # Was passing, now failing
856
+ rule_diff.change_type = "new_failure"
857
+ diff.new_failures.append(rule_diff)
858
+ elif not was_passing and now_passing:
859
+ # Was failing, now passing
860
+ rule_diff.change_type = "resolved"
861
+ diff.resolved.append(rule_diff)
862
+ elif delta > 0:
863
+ # Count increased (regression)
864
+ rule_diff.change_type = "regression"
865
+ diff.regressions.append(rule_diff)
866
+ elif delta < 0:
867
+ # Count decreased (improvement)
868
+ rule_diff.change_type = "improvement"
869
+ diff.improvements.append(rule_diff)
870
+ else:
871
+ # No change
872
+ diff.unchanged.append(rule_diff)
873
+
874
+ # Check for rules removed (in before but not in after)
875
+ for rule_id, before_rule in before_rules.items():
876
+ if rule_id not in after_rules:
877
+ # Rule was removed - if it was failing, that's a "resolution"
878
+ if not before_rule.passed:
879
+ rule_diff = RuleDiff(
880
+ rule_id=rule_id,
881
+ change_type="resolved",
882
+ before_count=before_rule.failed_count,
883
+ after_count=0,
884
+ delta=-before_rule.failed_count,
885
+ was_passing=False,
886
+ now_passing=True,
887
+ )
888
+ diff.resolved.append(rule_diff)
889
+
890
+ # Set summary flags
891
+ diff.has_regressions = len(diff.new_failures) > 0 or len(diff.regressions) > 0
892
+ diff.has_improvements = len(diff.resolved) > 0 or len(diff.improvements) > 0
893
+
894
+ return diff
895
+
896
+ def to_dict(self) -> Dict[str, Any]:
897
+ """Convert to dictionary for JSON serialization."""
898
+ return {
899
+ "before_run_at": self.before.run_at.isoformat(),
900
+ "after_run_at": self.after.run_at.isoformat(),
901
+ "contract_name": self.after.contract_name,
902
+ "contract_fingerprint": self.after.contract_fingerprint,
903
+ "status_changed": self.status_changed,
904
+ "has_regressions": self.has_regressions,
905
+ "has_improvements": self.has_improvements,
906
+ "summary": {
907
+ "before": self.before.summary.to_dict(),
908
+ "after": self.after.summary.to_dict(),
909
+ },
910
+ "new_failures": [r.to_dict() for r in self.new_failures],
911
+ "resolved": [r.to_dict() for r in self.resolved],
912
+ "regressions": [r.to_dict() for r in self.regressions],
913
+ "improvements": [r.to_dict() for r in self.improvements],
914
+ }
915
+
916
+ def to_json(self, indent: int = 2) -> str:
917
+ """Serialize to JSON string."""
918
+ return json.dumps(self.to_dict(), indent=indent, default=str)
919
+
920
+ def to_llm(self) -> str:
921
+ """
922
+ Render diff in token-optimized format for LLM context.
923
+
924
+ Focus on changes, skip unchanged rules entirely.
925
+ """
926
+ lines = []
927
+
928
+ # Header
929
+ before_ts = self.before.run_at.strftime("%Y-%m-%d")
930
+ after_ts = self.after.run_at.strftime("%Y-%m-%d %H:%M")
931
+
932
+ if self.has_regressions:
933
+ status = "REGRESSION"
934
+ elif self.has_improvements:
935
+ status = "IMPROVED"
936
+ else:
937
+ status = "NO_CHANGE"
938
+
939
+ lines.append(f"# Diff: {self.after.contract_name}")
940
+ lines.append(f"comparing: {before_ts} → {after_ts}")
941
+ lines.append(f"status: {status}")
942
+
943
+ # Summary change
944
+ if self.status_changed:
945
+ before_status = "PASS" if self.before.summary.passed else "FAIL"
946
+ after_status = "PASS" if self.after.summary.passed else "FAIL"
947
+ lines.append(f"overall: {before_status} → {after_status}")
948
+
949
+ # New failures (most important) - group by severity
950
+ if self.new_failures:
951
+ # Separate by severity
952
+ blocking = [rd for rd in self.new_failures if rd.severity == "blocking"]
953
+ warnings = [rd for rd in self.new_failures if rd.severity == "warning"]
954
+ infos = [rd for rd in self.new_failures if rd.severity == "info"]
955
+
956
+ if blocking:
957
+ lines.append("")
958
+ lines.append(f"## New Blocking Failures ({len(blocking)})")
959
+ for rd in blocking[:5]:
960
+ parts = [rd.rule_id]
961
+ if rd.after_count > 0:
962
+ count_str = f"{rd.after_count:,}" if rd.after_count < 1000000 else f"{rd.after_count/1000000:.1f}M"
963
+ parts.append(f"+{count_str}")
964
+ if rd.failure_mode:
965
+ parts.append(rd.failure_mode)
966
+ lines.append(f"- {' | '.join(parts)}")
967
+ if len(blocking) > 5:
968
+ lines.append(f" ... and {len(blocking) - 5} more")
969
+
970
+ if warnings:
971
+ lines.append("")
972
+ lines.append(f"## New Warnings ({len(warnings)})")
973
+ for rd in warnings[:5]:
974
+ parts = [rd.rule_id]
975
+ if rd.after_count > 0:
976
+ count_str = f"{rd.after_count:,}" if rd.after_count < 1000000 else f"{rd.after_count/1000000:.1f}M"
977
+ parts.append(f"+{count_str}")
978
+ if rd.failure_mode:
979
+ parts.append(rd.failure_mode)
980
+ lines.append(f"- {' | '.join(parts)}")
981
+ if len(warnings) > 5:
982
+ lines.append(f" ... and {len(warnings) - 5} more")
983
+
984
+ if infos:
985
+ lines.append("")
986
+ lines.append(f"## New Info Issues ({len(infos)})")
987
+ for rd in infos[:5]:
988
+ parts = [rd.rule_id]
989
+ if rd.after_count > 0:
990
+ count_str = f"{rd.after_count:,}" if rd.after_count < 1000000 else f"{rd.after_count/1000000:.1f}M"
991
+ parts.append(f"+{count_str}")
992
+ if rd.failure_mode:
993
+ parts.append(rd.failure_mode)
994
+ lines.append(f"- {' | '.join(parts)}")
995
+ if len(infos) > 5:
996
+ lines.append(f" ... and {len(infos) - 5} more")
997
+
998
+ # Regressions (count increased) - group by severity
999
+ if self.regressions:
1000
+ blocking_reg = [rd for rd in self.regressions if rd.severity == "blocking"]
1001
+ warning_reg = [rd for rd in self.regressions if rd.severity == "warning"]
1002
+ info_reg = [rd for rd in self.regressions if rd.severity == "info"]
1003
+
1004
+ def fmt_regression(rd):
1005
+ before_str = f"{rd.before_count:,}" if rd.before_count < 1000000 else f"{rd.before_count/1000000:.1f}M"
1006
+ after_str = f"{rd.after_count:,}" if rd.after_count < 1000000 else f"{rd.after_count/1000000:.1f}M"
1007
+ mode = f" | {rd.failure_mode}" if rd.failure_mode else ""
1008
+ return f"- {rd.rule_id}: {before_str} → {after_str} (+{rd.delta:,}){mode}"
1009
+
1010
+ if blocking_reg:
1011
+ lines.append("")
1012
+ lines.append(f"## Blocking Regressions ({len(blocking_reg)})")
1013
+ for rd in blocking_reg[:5]:
1014
+ lines.append(fmt_regression(rd))
1015
+ if len(blocking_reg) > 5:
1016
+ lines.append(f" ... and {len(blocking_reg) - 5} more")
1017
+
1018
+ if warning_reg:
1019
+ lines.append("")
1020
+ lines.append(f"## Warning Regressions ({len(warning_reg)})")
1021
+ for rd in warning_reg[:5]:
1022
+ lines.append(fmt_regression(rd))
1023
+ if len(warning_reg) > 5:
1024
+ lines.append(f" ... and {len(warning_reg) - 5} more")
1025
+
1026
+ if info_reg:
1027
+ lines.append("")
1028
+ lines.append(f"## Info Regressions ({len(info_reg)})")
1029
+ for rd in info_reg[:5]:
1030
+ lines.append(fmt_regression(rd))
1031
+ if len(info_reg) > 5:
1032
+ lines.append(f" ... and {len(info_reg) - 5} more")
1033
+
1034
+ # Resolved
1035
+ if self.resolved:
1036
+ lines.append("")
1037
+ lines.append(f"## Resolved ({len(self.resolved)})")
1038
+ for rd in self.resolved[:5]:
1039
+ lines.append(f"- {rd.rule_id}")
1040
+ if len(self.resolved) > 5:
1041
+ lines.append(f" ... and {len(self.resolved) - 5} more")
1042
+
1043
+ # Improvements (count decreased)
1044
+ if self.improvements:
1045
+ lines.append("")
1046
+ lines.append(f"## Improvements ({len(self.improvements)})")
1047
+ for rd in self.improvements[:5]:
1048
+ lines.append(f"- {rd.rule_id}: {rd.before_count:,} → {rd.after_count:,} ({rd.delta:,})")
1049
+ if len(self.improvements) > 5:
1050
+ lines.append(f" ... and {len(self.improvements) - 5} more")
1051
+
1052
+ # No changes
1053
+ if not self.new_failures and not self.regressions and not self.resolved and not self.improvements:
1054
+ lines.append("")
1055
+ lines.append("No changes detected.")
1056
+
1057
+ # Footer
1058
+ lines.append("")
1059
+ lines.append(f"fingerprint: {self.after.contract_fingerprint}")
1060
+
1061
+ return "\n".join(lines)