sheets-data-validation-kit 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,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: sheets-data-validation-kit
3
+ Version: 0.1.0
4
+ Summary: Validation, reconciliation and entity-resolution primitives for spreadsheet and CSV pipelines
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/devops-gm88/sheets-data-validation-kit
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8.0; extra == "dev"
23
+ Requires-Dist: ruff>=0.6; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # sheets-data-validation-kit
27
+
28
+ [![CI](https://github.com/devops-gm88/sheets-data-validation-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/devops-gm88/sheets-data-validation-kit/actions/workflows/ci.yml)
29
+ [![Python versions](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](pyproject.toml)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-black.svg)](LICENSE)
31
+
32
+ Validation, reconciliation and entity-resolution primitives for spreadsheet and CSV pipelines.
33
+
34
+ Most data pipelines fail quietly. A row lands, a number looks plausible, and nobody notices it is wrong
35
+ until it has been reported to someone. This kit exists to make that failure mode loud.
36
+
37
+ It is deliberately **standard-library only** — no dependencies, nothing to install, nothing to upgrade.
38
+ Copy the `src/validation_kit` folder into your project, or install the package in one line:
39
+
40
+ ```bash
41
+ pip install git+https://github.com/devops-gm88/sheets-data-validation-kit.git
42
+ ```
43
+
44
+ Not on PyPI yet - the GitHub URL above is the install. From a clone:
45
+ `pip install -e .`.
46
+
47
+ ## The three ideas
48
+
49
+ **1. Rows pass or they are quarantined. They are never silently dropped.**
50
+ A row that fails a contract goes to quarantine with the reasons attached. You can always answer
51
+ "where did this record go?" — which is the question that kills trust in a pipeline.
52
+
53
+ **2. Arithmetic is a second, independent check.**
54
+ Field-level validation confirms each value looks right. Reconciliation confirms the values are
55
+ *consistent with each other*. An extracted balance sheet must balance; a cash flow statement must tie to
56
+ the change in cash. If the arithmetic does not close, the extraction failed regardless of how confident
57
+ each individual field was. This catches the failure nobody sees: clean extraction, quietly wrong numbers.
58
+
59
+ **3. Exact match or human review. Never a fuzzy merge.**
60
+ Duplicates are detected on a canonical key. Suspected duplicates are queued for a person, because a
61
+ silent merge corrupts counts invisibly and is very hard to unpick later.
62
+
63
+ ## Quick start
64
+
65
+ ```python
66
+ from validation_kit import RowContract, Field, Min, Range, Pattern, Reconcile, Assertion
67
+ from validation_kit.entities import resolve
68
+
69
+ contract = RowContract([
70
+ Field("account_id", required=True),
71
+ Field("amount", rules=[Min(0)], coerce=float,
72
+ on_fail="quarantine"),
73
+ Field("period", rules=[Pattern(r"^\d{4}-Q[1-4]$")]),
74
+ ])
75
+
76
+ result = contract.validate_rows(rows)
77
+ print(result.summary())
78
+ for row, reasons in result.quarantined:
79
+ print(row.get("account_id"), reasons)
80
+ ```
81
+
82
+ ```python
83
+ # Arithmetic self-consistency: the check that does not depend on the model
84
+ suite = Reconcile([
85
+ Assertion("balance_sheet_balances",
86
+ lambda r: r["total_assets"], lambda r: r["total_liabilities"] + r["total_equity"],
87
+ tolerance=0.01),
88
+ ])
89
+
90
+ for outcome in suite.check(parsed_statement):
91
+ print(outcome) # PASS / FAIL with both sides of the equation printed
92
+ ```
93
+
94
+ ```python
95
+ # Entity resolution: exact key, review queue, no auto-merge
96
+ outcome = resolve(records, key="child_id")
97
+ print(outcome.unique_count, len(outcome.duplicate_candidates))
98
+ ```
99
+
100
+ ## What this is not
101
+
102
+ - It is not a data-quality *scoring* tool. There is no 0-100 number and no dashboard.
103
+ - It does not fix your source data. It tells you which records are untrustworthy and why.
104
+ - It will not merge duplicates for you. That is the point.
105
+
106
+ ## Why it exists
107
+
108
+ Built from repeatedly solving the same problem on real reporting systems: a monthly pack that was
109
+ *almost* right, and no way to prove which numbers were which. These are the primitives that were
110
+ written more than once, extracted so they are not written again.
111
+
112
+ ## Tests
113
+
114
+ Install first, then run:
115
+
116
+ ```bash
117
+ pip install -e .
118
+ python3 -m unittest discover -s tests -v
119
+ ```
120
+
121
+ Or without installing anything:
122
+
123
+ ```bash
124
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
125
+ ```
126
+
127
+ Both give **24 tests, OK**. Running `unittest` from a fresh clone without one of
128
+ those two steps fails to import the package — that is a packaging path, not a
129
+ test failure.
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,9 @@
1
+ sheets_data_validation_kit-0.1.0.dist-info/licenses/LICENSE,sha256=vIy64iu9eupgwG0r71cAIixKKKiYfb6oIMT6a9X7X-4,1066
2
+ validation_kit/__init__.py,sha256=_evSY2lmY_Fq7p7AoMY9yJ6xkDH7qa5L_tHv6vdSm9g,532
3
+ validation_kit/contract.py,sha256=K7RZ0Owkz2BkFrtJ2_mUESxKTwPTKVDQYDuyYJybuqQ,6393
4
+ validation_kit/entities.py,sha256=PVLP6JTayoWGPZMTtyvcxrWZtydm5NQzTarvEdy4SOA,4402
5
+ validation_kit/reconcile.py,sha256=XIx9h34PY0vM2AfK_lJ8t9CEvnBVpItvqdySp40R7Dg,3526
6
+ sheets_data_validation_kit-0.1.0.dist-info/METADATA,sha256=9PBvUlhnQ0E0Nd8COHPRhoZyRq7jmci4F-hPuw7IMCQ,5154
7
+ sheets_data_validation_kit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ sheets_data_validation_kit-0.1.0.dist-info/top_level.txt,sha256=mTLk3dltJ_9j3CTZaHM9VCt91W8N4gPo9TeTYqqtSck,15
9
+ sheets_data_validation_kit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SK Saimon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ validation_kit
@@ -0,0 +1,16 @@
1
+ """Validation, reconciliation and entity-resolution primitives.
2
+
3
+ Standard library only, deliberately.
4
+ """
5
+ from .contract import Field, Max, Min, NotBlank, OneOf, Pattern, Range, RowContract, ValidationResult
6
+ from .entities import ResolutionOutcome, resolve
7
+ from .reconcile import Assertion, Outcome, Reconcile
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = [
12
+ "Field", "RowContract", "ValidationResult",
13
+ "Min", "Max", "Range", "Pattern", "OneOf", "NotBlank",
14
+ "Assertion", "Reconcile", "Outcome",
15
+ "resolve", "ResolutionOutcome",
16
+ ]
@@ -0,0 +1,201 @@
1
+ """Field-level contracts.
2
+
3
+ A row either satisfies its contract or it is quarantined with the reasons attached.
4
+ Quarantine is the default failure mode: nothing is ever silently dropped.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
10
+
11
+
12
+ class Rule:
13
+ """Base class for a single field rule."""
14
+
15
+ name = "rule"
16
+
17
+ def check(self, value: Any) -> Optional[str]:
18
+ """Return None if the value passes, else a human-readable reason."""
19
+ raise NotImplementedError
20
+
21
+
22
+ @dataclass
23
+ class Min(Rule):
24
+ bound: float
25
+ name = "min"
26
+
27
+ def check(self, value):
28
+ try:
29
+ return None if float(value) >= self.bound else f"below minimum {self.bound}"
30
+ except (TypeError, ValueError):
31
+ return f"not numeric, cannot compare to minimum {self.bound}"
32
+
33
+
34
+ @dataclass
35
+ class Max(Rule):
36
+ bound: float
37
+ name = "max"
38
+
39
+ def check(self, value):
40
+ try:
41
+ return None if float(value) <= self.bound else f"above maximum {self.bound}"
42
+ except (TypeError, ValueError):
43
+ return f"not numeric, cannot compare to maximum {self.bound}"
44
+
45
+
46
+ @dataclass
47
+ class Range(Rule):
48
+ low: float
49
+ high: float
50
+ name = "range"
51
+
52
+ def check(self, value):
53
+ try:
54
+ number = float(value)
55
+ except (TypeError, ValueError):
56
+ return f"not numeric, cannot range-check {self.low}..{self.high}"
57
+ if number < self.low or number > self.high:
58
+ return f"{number} outside plausible range {self.low}..{self.high}"
59
+ return None
60
+
61
+
62
+ @dataclass
63
+ class Pattern(Rule):
64
+ regex: str
65
+ name = "pattern"
66
+
67
+ def check(self, value):
68
+ import re
69
+
70
+ return None if re.match(self.regex, str(value)) else f"does not match {self.regex}"
71
+
72
+
73
+ @dataclass
74
+ class OneOf(Rule):
75
+ allowed: Sequence[Any]
76
+ name = "one_of"
77
+
78
+ def check(self, value):
79
+ return None if value in self.allowed else f"{value!r} not one of {list(self.allowed)}"
80
+
81
+
82
+ class NotBlank(Rule):
83
+ name = "not_blank"
84
+
85
+ def check(self, value):
86
+ if value is None or (isinstance(value, str) and not value.strip()):
87
+ return "blank"
88
+ return None
89
+
90
+
91
+ @dataclass
92
+ class Field:
93
+ """One column in a row contract.
94
+
95
+ on_fail:
96
+ "quarantine" (default) - hold the whole row back for review.
97
+ "flag" - record the problem but keep the row.
98
+ """
99
+
100
+ name: str
101
+ required: bool = False
102
+ rules: List[Rule] = field(default_factory=list)
103
+ coerce: Optional[Callable[[Any], Any]] = None
104
+ on_fail: str = "quarantine"
105
+
106
+
107
+ @dataclass
108
+ class ValidationResult:
109
+ accepted: List[Dict[str, Any]] = field(default_factory=list)
110
+ quarantined: List[Tuple[Dict[str, Any], List[str]]] = field(default_factory=list)
111
+ flagged: List[Tuple[Dict[str, Any], List[str]]] = field(default_factory=list)
112
+ field_error_counts: Dict[str, int] = field(default_factory=dict)
113
+
114
+ @property
115
+ def total(self) -> int:
116
+ return len(self.accepted) + len(self.quarantined)
117
+
118
+ @property
119
+ def pass_rate(self) -> float:
120
+ return 0.0 if self.total == 0 else len(self.accepted) / self.total
121
+
122
+ def summary(self) -> str:
123
+ lines = [
124
+ f"rows in : {self.total}",
125
+ f"accepted : {len(self.accepted)} ({self.pass_rate:.1%})",
126
+ f"quarantined : {len(self.quarantined)}",
127
+ f"flagged : {len(self.flagged)}",
128
+ ]
129
+ if self.field_error_counts:
130
+ lines.append("errors by field:")
131
+ for name, count in sorted(self.field_error_counts.items(), key=lambda kv: -kv[1]):
132
+ lines.append(f" {name:<24} {count}")
133
+ return "\n".join(lines)
134
+
135
+
136
+ class RowContract:
137
+ """A set of field rules applied to every row.
138
+
139
+ Rows that fail a quarantine field are held back. Rows that fail a flag field are
140
+ kept, with the problem recorded, so a pipeline can decide for itself how strict to be
141
+ on fields it does not depend on.
142
+ """
143
+
144
+ def __init__(self, fields: Sequence[Field]):
145
+ self.fields = list(fields)
146
+
147
+ def validate_row(self, row: Dict[str, Any]):
148
+ """Returns (cleaned_row, reasons, verdict) where verdict is pass|quarantine|flag."""
149
+ cleaned: Dict[str, Any] = dict(row)
150
+ reasons: List[str] = []
151
+ severity = "pass"
152
+
153
+ for spec in self.fields:
154
+ value = row.get(spec.name)
155
+
156
+ if spec.required and (value is None or (isinstance(value, str) and not value.strip())):
157
+ reasons.append(f"{spec.name}: required but blank")
158
+ severity = "quarantine"
159
+ continue
160
+
161
+ if value is None or (isinstance(value, str) and not value.strip()):
162
+ continue
163
+
164
+ if spec.coerce is not None:
165
+ try:
166
+ value = spec.coerce(value)
167
+ cleaned[spec.name] = value
168
+ except (TypeError, ValueError) as exc:
169
+ reasons.append(f"{spec.name}: cannot coerce ({exc})")
170
+ severity = "quarantine" if spec.on_fail == "quarantine" else "flag"
171
+ continue
172
+
173
+ for rule in spec.rules:
174
+ problem = rule.check(value)
175
+ if problem:
176
+ reasons.append(f"{spec.name}: {problem}")
177
+ if spec.on_fail == "quarantine":
178
+ severity = "quarantine"
179
+ elif severity == "pass":
180
+ severity = "flag"
181
+
182
+ return cleaned, reasons, severity
183
+
184
+ def validate_rows(self, rows: Iterable[Dict[str, Any]]) -> ValidationResult:
185
+ result = ValidationResult()
186
+ for row in rows:
187
+ cleaned, reasons, verdict = self.validate_row(row)
188
+ if verdict == "pass":
189
+ result.accepted.append(cleaned)
190
+ elif verdict == "quarantine":
191
+ result.quarantined.append((cleaned, reasons))
192
+ _bump(result.field_error_counts, reasons)
193
+ else:
194
+ result.flagged.append((cleaned, reasons))
195
+ _bump(result.field_error_counts, reasons)
196
+ return result
197
+
198
+
199
+ def _bump(counter: Dict[str, int], reasons: Sequence[str]) -> None:
200
+ for reason in reasons:
201
+ counter[reason.split(":", 1)[0]] = counter.get(reason.split(":", 1)[0], 0) + 1
@@ -0,0 +1,113 @@
1
+ """Exact-match entity resolution with a human review queue.
2
+
3
+ The rule this module enforces: **never merge automatically.**
4
+
5
+ Name-based and fuzzy matching is the usual cause of inflated counts in reporting
6
+ pipelines, because common names collide and re-registrations create a second record.
7
+ Suspects are surfaced for a person to decide, not resolved by a heuristic.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from collections import defaultdict
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
14
+
15
+
16
+ @dataclass
17
+ class DuplicateCandidate:
18
+ """Two or more records that look like the same real entity but carry different keys."""
19
+
20
+ reason: str
21
+ records: List[Dict[str, Any]]
22
+
23
+ def keys(self) -> List[Any]:
24
+ return [r.get("_key") for r in self.records]
25
+
26
+
27
+ @dataclass
28
+ class ResolutionOutcome:
29
+ unique: List[Dict[str, Any]] = field(default_factory=list)
30
+ duplicate_keys: List[Tuple[Any, int]] = field(default_factory=list)
31
+ duplicate_candidates: List[DuplicateCandidate] = field(default_factory=list)
32
+ blank_keys: List[Dict[str, Any]] = field(default_factory=list)
33
+
34
+ @property
35
+ def unique_count(self) -> int:
36
+ return len(self.unique)
37
+
38
+ @property
39
+ def input_count(self) -> int:
40
+ return self.unique_count + sum(count - 1 for _, count in self.duplicate_keys)
41
+
42
+ @property
43
+ def inflation(self) -> int:
44
+ """How many phantom entities a naive row count would have reported."""
45
+ return sum(count - 1 for _, count in self.duplicate_keys)
46
+
47
+ def summary(self) -> str:
48
+ return (
49
+ f"records in : {self.input_count}\n"
50
+ f"distinct entities : {self.unique_count}\n"
51
+ f"phantom entities : {self.inflation}\n"
52
+ f"blank keys held back : {len(self.blank_keys)}\n"
53
+ f"suspects for review : {len(self.duplicate_candidates)}"
54
+ )
55
+
56
+
57
+ def resolve(records: Iterable[Dict[str, Any]],
58
+ key: str,
59
+ signature: Optional[Sequence[str]] = None,
60
+ normalise: Optional[Callable[[Any], Any]] = None) -> ResolutionOutcome:
61
+ """Resolve records to distinct entities on an exact key.
62
+
63
+ key the canonical identifier. Rows without one are held back, never counted.
64
+ signature optional tuple of fields used only to *suggest* duplicates. Suggestions
65
+ are queued for review; they are never merged.
66
+ normalise optional callable applied to the key (strip/casefold and similar).
67
+ """
68
+ outcome = ResolutionOutcome()
69
+ norm = normalise or (lambda v: str(v).strip().casefold() if v is not None else None)
70
+
71
+ buckets: Dict[Any, List[Dict[str, Any]]] = defaultdict(list)
72
+ for record in records:
73
+ raw = record.get(key)
74
+ if raw is None or (isinstance(raw, str) and not raw.strip()):
75
+ outcome.blank_keys.append(record)
76
+ continue
77
+ record = dict(record)
78
+ record["_key"] = norm(raw)
79
+ buckets[record["_key"]].append(record)
80
+
81
+ for entity_key, group in buckets.items():
82
+ group_sorted = sorted(group, key=lambda r: str(r.get("_source", "")))
83
+ outcome.unique.append(group_sorted[0])
84
+ if len(group) > 1:
85
+ outcome.duplicate_keys.append((entity_key, len(group)))
86
+
87
+ outcome.duplicate_keys.sort(key=lambda kv: -kv[1])
88
+
89
+ if signature:
90
+ seen: Dict[Tuple[Any, ...], List[Dict[str, Any]]] = defaultdict(list)
91
+ for record in outcome.unique:
92
+ token = tuple(norm(record.get(f)) for f in signature)
93
+ if any(part is None for part in token):
94
+ continue
95
+ seen[token].append(record)
96
+ for token, group in seen.items():
97
+ if len(group) > 1:
98
+ outcome.duplicate_candidates.append(
99
+ DuplicateCandidate(
100
+ reason=f"same {'/'.join(signature)}: {token}",
101
+ records=group,
102
+ )
103
+ )
104
+ outcome.duplicate_candidates.sort(key=lambda c: -len(c.records))
105
+
106
+ return outcome
107
+
108
+
109
+ def count_distinct(records: Iterable[Dict[str, Any]], key: str) -> int:
110
+ """Distinct entity count. Blank keys are excluded rather than counted as one entity."""
111
+ values = {str(r.get(key)).strip().casefold() for r in records
112
+ if r.get(key) is not None and str(r.get(key)).strip()}
113
+ return len(values)
@@ -0,0 +1,97 @@
1
+ """Arithmetic reconciliation.
2
+
3
+ Field-level validation asks "does this value look right?".
4
+ Reconciliation asks "are these values consistent with each other?".
5
+
6
+ It is a genuinely independent check: it needs no model, no confidence score and no
7
+ reference data, only arithmetic that must hold. If a statement extracts cleanly and the
8
+ arithmetic does not close, the extraction failed.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any, Callable, Dict, List, Sequence
14
+
15
+
16
+ @dataclass
17
+ class Outcome:
18
+ name: str
19
+ passed: bool
20
+ left: float
21
+ right: float
22
+ difference: float
23
+ tolerance: float
24
+ note: str = ""
25
+
26
+ def __str__(self) -> str:
27
+ mark = "PASS" if self.passed else "FAIL"
28
+ detail = f"{self.left:,.2f} vs {self.right:,.2f} (diff {self.difference:,.4f}, tol {self.tolerance})"
29
+ return f"[{mark}] {self.name}: {detail}" + (f" -- {self.note}" if self.note else "")
30
+
31
+
32
+ @dataclass
33
+ class Assertion:
34
+ """One equation that must hold for a record.
35
+
36
+ left and right are callables taking the record and returning a number, so the
37
+ assertion reads like the equation it tests:
38
+
39
+ Assertion("assets = liabilities + equity",
40
+ lambda r: r["total_assets"],
41
+ lambda r: r["total_liabilities"] + r["total_equity"])
42
+ """
43
+
44
+ name: str
45
+ left: Callable[[Dict[str, Any]], float]
46
+ right: Callable[[Dict[str, Any]], float]
47
+ tolerance: float = 0.01
48
+ note: str = ""
49
+
50
+
51
+ class Reconcile:
52
+ def __init__(self, assertions: Sequence[Assertion]):
53
+ self.assertions = list(assertions)
54
+
55
+ def check(self, record: Dict[str, Any]) -> List[Outcome]:
56
+ outcomes: List[Outcome] = []
57
+ for assertion in self.assertions:
58
+ try:
59
+ left = float(assertion.left(record))
60
+ right = float(assertion.right(record))
61
+ except (KeyError, TypeError, ValueError) as exc:
62
+ outcomes.append(Outcome(assertion.name, False, float("nan"), float("nan"),
63
+ float("nan"), assertion.tolerance,
64
+ note=f"cannot evaluate: {exc}"))
65
+ continue
66
+ difference = abs(left - right)
67
+ outcomes.append(Outcome(assertion.name, difference <= assertion.tolerance,
68
+ left, right, difference, assertion.tolerance, assertion.note))
69
+ return outcomes
70
+
71
+ def all_pass(self, record: Dict[str, Any]) -> bool:
72
+ return all(o.passed for o in self.check(record))
73
+
74
+ def failures(self, record: Dict[str, Any]) -> List[Outcome]:
75
+ return [o for o in self.check(record) if not o.passed]
76
+
77
+
78
+ def closing_balance_check(opening: str, inflow: str, outflow: str, closing: str,
79
+ tolerance: float = 0.01) -> Assertion:
80
+ """closing == opening + inflow - outflow."""
81
+ return Assertion(
82
+ "closing = opening + inflow - outflow",
83
+ lambda r: r[closing],
84
+ lambda r: r[opening] + r[inflow] - r[outflow],
85
+ tolerance=tolerance,
86
+ )
87
+
88
+
89
+ def rollforward_check(opening: str, additions: str, reductions: str, closing: str,
90
+ tolerance: float = 0.01) -> Assertion:
91
+ """closing == opening + additions - reductions. Used for inventory, PP&E, capital assets."""
92
+ return Assertion(
93
+ "closing = opening + additions - reductions",
94
+ lambda r: r[closing],
95
+ lambda r: r[opening] + r[additions] - r[reductions],
96
+ tolerance=tolerance,
97
+ )