tieout 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.
- tieout/__init__.py +39 -0
- tieout/cli.py +121 -0
- tieout/conditions.py +226 -0
- tieout/connectors/__init__.py +122 -0
- tieout/connectors/camt.py +546 -0
- tieout/connectors/csv.py +337 -0
- tieout/connectors/stripe.py +265 -0
- tieout/engine.py +332 -0
- tieout/errors.py +41 -0
- tieout/matching.py +249 -0
- tieout/money.py +204 -0
- tieout/py.typed +0 -0
- tieout/records.py +190 -0
- tieout/report.py +249 -0
- tieout/result.py +104 -0
- tieout/spec.py +217 -0
- tieout/subset_search.py +230 -0
- tieout-0.1.0.dist-info/METADATA +177 -0
- tieout-0.1.0.dist-info/RECORD +22 -0
- tieout-0.1.0.dist-info/WHEEL +4 -0
- tieout-0.1.0.dist-info/entry_points.txt +2 -0
- tieout-0.1.0.dist-info/licenses/LICENSE +202 -0
tieout/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""tieout: an open-source reconciliation engine.
|
|
2
|
+
|
|
3
|
+
from tieout import load_spec, reconcile
|
|
4
|
+
from tieout.connectors import read_source
|
|
5
|
+
|
|
6
|
+
spec = load_spec("recon.yaml")
|
|
7
|
+
bank = read_source("bank", "statement.xml", spec.sources["bank"])
|
|
8
|
+
ledger = read_source("ledger", "ledger.csv", spec.sources["ledger"])
|
|
9
|
+
result = reconcile([bank, ledger], spec)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from tieout.engine import ENGINE_VERSION as __version__
|
|
13
|
+
from tieout.engine import reconcile
|
|
14
|
+
from tieout.errors import ConnectorError, InputError, MoneyError, SpecError, TieoutError
|
|
15
|
+
from tieout.records import Record, Source
|
|
16
|
+
from tieout.result import Break, BreakReason, Match, Result, RunInfo
|
|
17
|
+
from tieout.spec import Rule, RuleKind, Spec, load_spec, parse_spec
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Break",
|
|
21
|
+
"BreakReason",
|
|
22
|
+
"ConnectorError",
|
|
23
|
+
"InputError",
|
|
24
|
+
"Match",
|
|
25
|
+
"MoneyError",
|
|
26
|
+
"Record",
|
|
27
|
+
"Result",
|
|
28
|
+
"Rule",
|
|
29
|
+
"RuleKind",
|
|
30
|
+
"RunInfo",
|
|
31
|
+
"Source",
|
|
32
|
+
"Spec",
|
|
33
|
+
"SpecError",
|
|
34
|
+
"TieoutError",
|
|
35
|
+
"__version__",
|
|
36
|
+
"load_spec",
|
|
37
|
+
"parse_spec",
|
|
38
|
+
"reconcile",
|
|
39
|
+
]
|
tieout/cli.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Command-line interface for tieout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from tieout.connectors import read_source, validate_spec
|
|
11
|
+
from tieout.engine import ENGINE_VERSION, reconcile
|
|
12
|
+
from tieout.errors import TieoutError
|
|
13
|
+
from tieout.report import format_summary, to_json, write_breaks_csv, write_matches_csv
|
|
14
|
+
from tieout.spec import load_spec
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _CliError(click.ClickException):
|
|
18
|
+
"""Configuration or input error; exits with code 2 to stay distinct from breaks (1)."""
|
|
19
|
+
|
|
20
|
+
exit_code = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
@click.version_option(ENGINE_VERSION, prog_name="tieout")
|
|
25
|
+
def main() -> None:
|
|
26
|
+
"""Reconcile money records across banks, payment providers, ledgers, and invoices."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@main.command()
|
|
30
|
+
@click.argument("spec_path", type=click.Path(dir_okay=False, path_type=Path))
|
|
31
|
+
def validate(spec_path: Path) -> None:
|
|
32
|
+
"""Validate a YAML reconciliation spec and its source options."""
|
|
33
|
+
try:
|
|
34
|
+
spec = load_spec(spec_path)
|
|
35
|
+
validate_spec(spec)
|
|
36
|
+
except TieoutError as exc:
|
|
37
|
+
raise _CliError(str(exc)) from exc
|
|
38
|
+
|
|
39
|
+
click.echo(
|
|
40
|
+
f"Valid spec: {len(spec.sources)} source(s), {len(spec.rules)} rule(s) ({spec.digest()})"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@main.command()
|
|
45
|
+
@click.argument("spec_path", type=click.Path(dir_okay=False, path_type=Path))
|
|
46
|
+
@click.option(
|
|
47
|
+
"--input",
|
|
48
|
+
"-i",
|
|
49
|
+
"inputs",
|
|
50
|
+
multiple=True,
|
|
51
|
+
required=True,
|
|
52
|
+
metavar="NAME=PATH",
|
|
53
|
+
help="Bind a declared source name to an input file (repeat for each source).",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--output-dir",
|
|
57
|
+
"-o",
|
|
58
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
59
|
+
default=None,
|
|
60
|
+
help="Write result.json, matches.csv, and breaks.csv into this directory.",
|
|
61
|
+
)
|
|
62
|
+
@click.option("--json", "as_json", is_flag=True, help="Print full result JSON to stdout.")
|
|
63
|
+
@click.option(
|
|
64
|
+
"--fail-on-breaks",
|
|
65
|
+
is_flag=True,
|
|
66
|
+
help="Exit with status 1 if any breaks remain after running all rules.",
|
|
67
|
+
)
|
|
68
|
+
def run(
|
|
69
|
+
spec_path: Path,
|
|
70
|
+
inputs: tuple[str, ...],
|
|
71
|
+
output_dir: Path | None,
|
|
72
|
+
as_json: bool,
|
|
73
|
+
fail_on_breaks: bool,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Run a reconciliation spec against input files."""
|
|
76
|
+
bindings = _parse_inputs(inputs)
|
|
77
|
+
try:
|
|
78
|
+
spec = load_spec(spec_path)
|
|
79
|
+
validate_spec(spec)
|
|
80
|
+
for name in bindings:
|
|
81
|
+
if name not in spec.sources:
|
|
82
|
+
declared = ", ".join(sorted(spec.sources)) or "(none)"
|
|
83
|
+
raise _CliError(
|
|
84
|
+
f"input {name!r} is not declared in spec.sources (declared: {declared})"
|
|
85
|
+
)
|
|
86
|
+
sources = [read_source(name, path, spec.sources[name]) for name, path in bindings.items()]
|
|
87
|
+
result = reconcile(sources, spec)
|
|
88
|
+
except TieoutError as exc:
|
|
89
|
+
raise _CliError(str(exc)) from exc
|
|
90
|
+
|
|
91
|
+
if output_dir is not None:
|
|
92
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
(output_dir / "result.json").write_text(to_json(result) + "\n", encoding="utf-8")
|
|
94
|
+
write_matches_csv(result, output_dir / "matches.csv")
|
|
95
|
+
write_breaks_csv(result, output_dir / "breaks.csv")
|
|
96
|
+
|
|
97
|
+
if as_json:
|
|
98
|
+
click.echo(to_json(result))
|
|
99
|
+
if output_dir is not None:
|
|
100
|
+
click.echo(f"Wrote reports to {output_dir}", err=True)
|
|
101
|
+
else:
|
|
102
|
+
click.echo(format_summary(result))
|
|
103
|
+
if output_dir is not None:
|
|
104
|
+
click.echo(f"\nWrote reports to {output_dir}")
|
|
105
|
+
|
|
106
|
+
if fail_on_breaks and result.breaks:
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_inputs(raw_inputs: tuple[str, ...]) -> dict[str, Path]:
|
|
111
|
+
bindings: dict[str, Path] = {}
|
|
112
|
+
for item in raw_inputs:
|
|
113
|
+
name, sep, path_str = item.partition("=")
|
|
114
|
+
name = name.strip()
|
|
115
|
+
path_str = path_str.strip()
|
|
116
|
+
if not sep or not name or not path_str:
|
|
117
|
+
raise _CliError(f"invalid --input {item!r}: expected NAME=PATH")
|
|
118
|
+
if name in bindings:
|
|
119
|
+
raise _CliError(f"duplicate --input for source {name!r}")
|
|
120
|
+
bindings[name] = Path(path_str)
|
|
121
|
+
return bindings
|
tieout/conditions.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""How a rule judges a candidate pairing, and how it explains the verdict.
|
|
2
|
+
|
|
3
|
+
A pairing is a group of left records against a group of right records. For a
|
|
4
|
+
one-to-one rule both groups hold one record; for grouping rules one side holds
|
|
5
|
+
several. Every check is defined over groups so all rule kinds share one meaning:
|
|
6
|
+
|
|
7
|
+
- amount: right total must equal the expected total (left total, or its negation
|
|
8
|
+
for ``sign: opposite``) within the tolerance;
|
|
9
|
+
- date: *every* right date minus *every* left date must fall in the window;
|
|
10
|
+
- references: the condition must hold for *every* left/right record pair.
|
|
11
|
+
|
|
12
|
+
Containment checks (``left_in_right``/``right_in_left``) match whole tokens only:
|
|
13
|
+
the needle must start and end where the haystack has a token boundary (a
|
|
14
|
+
separator, or a switch between letters and digits). ``"INV-0042"`` is found in
|
|
15
|
+
``"Payment for inv 0042"``, but ``"7712"`` is not found in ``"Payment #77712"``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Iterable, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from functools import lru_cache
|
|
24
|
+
|
|
25
|
+
from tieout.money import format_amount
|
|
26
|
+
from tieout.records import Record
|
|
27
|
+
from tieout.spec import ReferenceCondition, Rule
|
|
28
|
+
|
|
29
|
+
_NON_ALPHANUMERIC = re.compile(r"[^0-9A-Z]+")
|
|
30
|
+
_TOKEN = re.compile(r"[A-Z]+|[0-9]+")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class DateSpan:
|
|
35
|
+
"""Earliest and latest date of a group of records, as proleptic ordinals."""
|
|
36
|
+
|
|
37
|
+
first: int
|
|
38
|
+
last: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def date_span(records: Sequence[Record], rule: Rule) -> DateSpan | None:
|
|
42
|
+
"""Date span used by the rule's date check, or None if any record lacks the date.
|
|
43
|
+
|
|
44
|
+
With a date condition, only the field it names counts. Without one, dates only
|
|
45
|
+
rank otherwise equal candidates, so a record's value date is used if it has
|
|
46
|
+
one and its booking date if not.
|
|
47
|
+
"""
|
|
48
|
+
ordinals = []
|
|
49
|
+
for record in records:
|
|
50
|
+
if rule.date is not None:
|
|
51
|
+
value = record.date_of(rule.date.field)
|
|
52
|
+
else:
|
|
53
|
+
value = record.value_date or record.booking_date
|
|
54
|
+
if value is None:
|
|
55
|
+
return None
|
|
56
|
+
ordinals.append(value.toordinal())
|
|
57
|
+
return DateSpan(min(ordinals), max(ordinals))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def dates_fit(rule: Rule, left: DateSpan, right: DateSpan) -> bool:
|
|
61
|
+
if rule.date is None:
|
|
62
|
+
return True
|
|
63
|
+
return (
|
|
64
|
+
right.first - left.last >= rule.date.min_days
|
|
65
|
+
and right.last - left.first <= rule.date.max_days
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def date_distance(left: DateSpan, right: DateSpan) -> int:
|
|
70
|
+
"""Largest absolute gap between any left and any right date; used to rank candidates."""
|
|
71
|
+
return max(abs(right.last - left.first), abs(left.last - right.first))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def expected_right_total(rule: Rule, left_total: int) -> int:
|
|
75
|
+
return left_total if rule.amount.sign == "same" else -left_total
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def amount_difference(rule: Rule, left_total: int, right_total: int) -> int:
|
|
79
|
+
return right_total - expected_right_total(rule, left_total)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@lru_cache(maxsize=524288)
|
|
83
|
+
def normalize_reference(text: str) -> str:
|
|
84
|
+
return _NON_ALPHANUMERIC.sub("", text.upper())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@lru_cache(maxsize=262144)
|
|
88
|
+
def _token_boundaries(text: str) -> frozenset[int]:
|
|
89
|
+
"""Offsets in the normalised text where a token starts or ends."""
|
|
90
|
+
boundaries = {0}
|
|
91
|
+
offset = 0
|
|
92
|
+
for token in _TOKEN.findall(text.upper()):
|
|
93
|
+
offset += len(token)
|
|
94
|
+
boundaries.add(offset)
|
|
95
|
+
return frozenset(boundaries)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def contains_token_sequence(haystack: str, needle: str) -> bool:
|
|
99
|
+
"""Whether normalised ``needle`` occurs in ``haystack`` aligned to token boundaries."""
|
|
100
|
+
normalized_haystack = normalize_reference(haystack)
|
|
101
|
+
normalized_needle = normalize_reference(needle)
|
|
102
|
+
if not normalized_needle:
|
|
103
|
+
return False
|
|
104
|
+
boundaries = _token_boundaries(haystack)
|
|
105
|
+
start = normalized_haystack.find(normalized_needle)
|
|
106
|
+
while start != -1:
|
|
107
|
+
if start in boundaries and start + len(normalized_needle) in boundaries:
|
|
108
|
+
return True
|
|
109
|
+
start = normalized_haystack.find(normalized_needle, start + 1)
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def reference_holds(condition: ReferenceCondition, left: Record, right: Record) -> bool:
|
|
114
|
+
raw_left, raw_right = left.value_of(condition.left), right.value_of(condition.right)
|
|
115
|
+
left_value, right_value = normalize_reference(raw_left), normalize_reference(raw_right)
|
|
116
|
+
if not left_value or not right_value:
|
|
117
|
+
return False # an absent reference never counts as agreement
|
|
118
|
+
if condition.op == "equals":
|
|
119
|
+
return left_value == right_value
|
|
120
|
+
needle, haystack = (
|
|
121
|
+
(raw_left, raw_right)
|
|
122
|
+
if condition.op == "left_in_right"
|
|
123
|
+
else (
|
|
124
|
+
raw_right,
|
|
125
|
+
raw_left,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
if len(normalize_reference(needle)) < condition.min_length:
|
|
129
|
+
return False
|
|
130
|
+
return contains_token_sequence(haystack, needle)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def references_hold(rule: Rule, lefts: Sequence[Record], rights: Sequence[Record]) -> bool:
|
|
134
|
+
return all(
|
|
135
|
+
reference_holds(condition, left, right)
|
|
136
|
+
for condition in rule.references
|
|
137
|
+
for left in lefts
|
|
138
|
+
for right in rights
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def explain(
|
|
143
|
+
rule: Rule,
|
|
144
|
+
lefts: Sequence[Record],
|
|
145
|
+
rights: Sequence[Record],
|
|
146
|
+
*,
|
|
147
|
+
grouping_note: str | None = None,
|
|
148
|
+
) -> tuple[str, ...]:
|
|
149
|
+
"""Plain-language account of why this pairing satisfies the rule."""
|
|
150
|
+
currency = lefts[0].currency
|
|
151
|
+
left_total = sum(record.amount for record in lefts)
|
|
152
|
+
right_total = sum(record.amount for record in rights)
|
|
153
|
+
difference = amount_difference(rule, left_total, right_total)
|
|
154
|
+
|
|
155
|
+
lines = [
|
|
156
|
+
f"rule {rule.id} ({rule.kind.value}): "
|
|
157
|
+
f"{_count(len(lefts), 'left record')} against {_count(len(rights), 'right record')}",
|
|
158
|
+
_explain_amount(rule, left_total, right_total, difference, currency),
|
|
159
|
+
]
|
|
160
|
+
if rule.date is not None:
|
|
161
|
+
lines.append(_explain_dates(rule, lefts, rights))
|
|
162
|
+
lines.extend(_explain_reference(condition, lefts, rights) for condition in rule.references)
|
|
163
|
+
if grouping_note:
|
|
164
|
+
lines.append(grouping_note)
|
|
165
|
+
return tuple(lines)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_OP_WORDS = {
|
|
169
|
+
"equals": "equals",
|
|
170
|
+
"left_in_right": "is contained in",
|
|
171
|
+
"right_in_left": "contains",
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _explain_amount(
|
|
176
|
+
rule: Rule, left_total: int, right_total: int, difference: int, currency: str
|
|
177
|
+
) -> str:
|
|
178
|
+
sign_note = " (opposite signs)" if rule.amount.sign == "opposite" else ""
|
|
179
|
+
totals = (
|
|
180
|
+
f"amount: left {format_amount(left_total, currency)} {currency}, "
|
|
181
|
+
f"right {format_amount(right_total, currency)} {currency}{sign_note}"
|
|
182
|
+
)
|
|
183
|
+
if difference == 0:
|
|
184
|
+
return f"{totals}, exact"
|
|
185
|
+
return (
|
|
186
|
+
f"{totals}, difference {format_amount(difference, currency)} {currency} "
|
|
187
|
+
f"within tolerance {format_amount(rule.amount.tolerance, currency)}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _explain_reference(
|
|
192
|
+
condition: ReferenceCondition, lefts: Sequence[Record], rights: Sequence[Record]
|
|
193
|
+
) -> str:
|
|
194
|
+
left_values = _distinct(record.value_of(condition.left) for record in lefts)
|
|
195
|
+
right_values = _distinct(record.value_of(condition.right) for record in rights)
|
|
196
|
+
return (
|
|
197
|
+
f"reference: left.{condition.left} {_quote(left_values)} {_OP_WORDS[condition.op]} "
|
|
198
|
+
f"right.{condition.right} {_quote(right_values)}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _distinct(values: Iterable[str]) -> list[str]:
|
|
203
|
+
return sorted(set(values))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _quote(values: list[str]) -> str:
|
|
207
|
+
shown = ", ".join(repr(value) for value in values[:3])
|
|
208
|
+
more = f" and {len(values) - 3} more" if len(values) > 3 else ""
|
|
209
|
+
return shown + more
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _explain_dates(rule: Rule, lefts: Sequence[Record], rights: Sequence[Record]) -> str:
|
|
213
|
+
assert rule.date is not None
|
|
214
|
+
left, right = date_span(lefts, rule), date_span(rights, rule)
|
|
215
|
+
assert left is not None
|
|
216
|
+
assert right is not None
|
|
217
|
+
low, high = right.first - left.last, right.last - left.first
|
|
218
|
+
gap = f"{low} days" if low == high else f"{low} to {high} days"
|
|
219
|
+
return (
|
|
220
|
+
f"date: right {rule.date.field} minus left is {gap} "
|
|
221
|
+
f"(allowed {rule.date.min_days} to {rule.date.max_days})"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _count(n: int, noun: str) -> str:
|
|
226
|
+
return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Source connectors: parse external files into canonical `Source` objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from tieout.connectors.camt import CamtOptions, parse_camt
|
|
13
|
+
from tieout.connectors.csv import CsvOptions, parse_csv
|
|
14
|
+
from tieout.connectors.stripe import StripeOptions, parse_stripe
|
|
15
|
+
from tieout.errors import ConnectorError, SpecError
|
|
16
|
+
from tieout.records import Exclusion, Record, Source
|
|
17
|
+
from tieout.spec import RuleKind, SourceSpec, Spec
|
|
18
|
+
|
|
19
|
+
_Parser = Callable[[str, Path, bytes, dict[str, Any]], tuple[list[Record], list[Exclusion]]]
|
|
20
|
+
|
|
21
|
+
_CONNECTORS: dict[str, tuple[_Parser, type[CamtOptions | CsvOptions | StripeOptions]]] = {
|
|
22
|
+
"camt": (parse_camt, CamtOptions),
|
|
23
|
+
"csv": (parse_csv, CsvOptions),
|
|
24
|
+
"stripe": (parse_stripe, StripeOptions),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_source_spec(spec: SourceSpec) -> frozenset[str]:
|
|
29
|
+
"""Verify a source's connector format and options without reading a file.
|
|
30
|
+
|
|
31
|
+
Returns the field names records from that source can carry.
|
|
32
|
+
"""
|
|
33
|
+
entry = _CONNECTORS.get(spec.format)
|
|
34
|
+
if entry is None:
|
|
35
|
+
supported = ", ".join(sorted(_CONNECTORS))
|
|
36
|
+
raise SpecError(f"unknown source format {spec.format!r} (expected one of: {supported})")
|
|
37
|
+
_, option_model = entry
|
|
38
|
+
try:
|
|
39
|
+
options = option_model.model_validate(spec.options)
|
|
40
|
+
except ValidationError as exc:
|
|
41
|
+
lines = [
|
|
42
|
+
f"{'.'.join(str(p) for p in err['loc']) or 'options'}: {err['msg']}"
|
|
43
|
+
for err in exc.errors()
|
|
44
|
+
]
|
|
45
|
+
raise SpecError(f"invalid {spec.format} options:\n " + "\n ".join(lines)) from exc
|
|
46
|
+
return options.fields()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_spec(spec: Spec) -> None:
|
|
50
|
+
"""Check every source's options, and that every field a rule names can exist.
|
|
51
|
+
|
|
52
|
+
A misspelt field (``payout_idd``) would otherwise match nothing, silently: an
|
|
53
|
+
absent reference never counts as agreement and an absent group key forms no
|
|
54
|
+
group. Only declared sources are checked.
|
|
55
|
+
"""
|
|
56
|
+
available = {name: validate_source_spec(source) for name, source in spec.sources.items()}
|
|
57
|
+
problems: list[str] = []
|
|
58
|
+
for rule in spec.rules:
|
|
59
|
+
wanted: list[tuple[str, str, str]] = [] # (side, source, field)
|
|
60
|
+
for condition in rule.references:
|
|
61
|
+
wanted.append(("left", rule.left, condition.left))
|
|
62
|
+
wanted.append(("right", rule.right, condition.right))
|
|
63
|
+
if rule.date is not None:
|
|
64
|
+
wanted.append(("left", rule.left, rule.date.field))
|
|
65
|
+
wanted.append(("right", rule.right, rule.date.field))
|
|
66
|
+
if rule.group is not None and rule.group.by is not None:
|
|
67
|
+
side, source = (
|
|
68
|
+
("right", rule.right) if rule.kind is RuleKind.ONE_TO_MANY else ("left", rule.left)
|
|
69
|
+
)
|
|
70
|
+
wanted.append((side, source, rule.group.by))
|
|
71
|
+
for side, source, field in wanted:
|
|
72
|
+
fields = available.get(source)
|
|
73
|
+
if fields is not None and field not in fields:
|
|
74
|
+
problems.append(
|
|
75
|
+
f"rule {rule.id!r}: {side} source {source!r} has no field {field!r} "
|
|
76
|
+
f"(available: {', '.join(sorted(fields))})"
|
|
77
|
+
)
|
|
78
|
+
if problems:
|
|
79
|
+
raise SpecError("invalid spec:\n " + "\n ".join(dict.fromkeys(problems)))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_source(
|
|
83
|
+
name: str,
|
|
84
|
+
path: str | Path,
|
|
85
|
+
spec: SourceSpec | str,
|
|
86
|
+
**options: Any,
|
|
87
|
+
) -> Source:
|
|
88
|
+
"""Read a file into a `Source` using the named connector and record its byte digest."""
|
|
89
|
+
if isinstance(spec, str):
|
|
90
|
+
fmt = spec
|
|
91
|
+
merged_options: dict[str, Any] = dict(options)
|
|
92
|
+
else:
|
|
93
|
+
fmt = spec.format
|
|
94
|
+
merged_options = {**spec.options, **options}
|
|
95
|
+
|
|
96
|
+
entry = _CONNECTORS.get(fmt)
|
|
97
|
+
if entry is None:
|
|
98
|
+
supported = ", ".join(sorted(_CONNECTORS))
|
|
99
|
+
raise SpecError(f"unknown source format {fmt!r} (expected one of: {supported})")
|
|
100
|
+
parser, _ = entry
|
|
101
|
+
|
|
102
|
+
file_path = Path(path)
|
|
103
|
+
try:
|
|
104
|
+
raw_bytes = file_path.read_bytes()
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
detail = exc.strerror or str(exc)
|
|
107
|
+
raise ConnectorError(f"cannot read file: {detail}", path=str(file_path)) from exc
|
|
108
|
+
|
|
109
|
+
digest = f"sha256:{hashlib.sha256(raw_bytes).hexdigest()}"
|
|
110
|
+
try:
|
|
111
|
+
records, excluded = parser(name, file_path, raw_bytes, merged_options)
|
|
112
|
+
except ValidationError as exc:
|
|
113
|
+
lines = [
|
|
114
|
+
f"{'.'.join(str(p) for p in err['loc']) or 'options'}: {err['msg']}"
|
|
115
|
+
for err in exc.errors()
|
|
116
|
+
]
|
|
117
|
+
raise SpecError(f"invalid {fmt} options:\n " + "\n ".join(lines)) from exc
|
|
118
|
+
|
|
119
|
+
return Source(name=name, records=tuple(records), digest=digest, excluded=tuple(excluded))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__all__ = ["read_source", "validate_source_spec", "validate_spec"]
|