cbpr-usage-rules 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.
Files changed (48) hide show
  1. cbpr_rules/__init__.py +21 -0
  2. cbpr_rules/cli.py +176 -0
  3. cbpr_rules/engine.py +100 -0
  4. cbpr_rules/helpers.py +420 -0
  5. cbpr_rules/loader.py +77 -0
  6. cbpr_rules/message.py +170 -0
  7. cbpr_rules/models.py +83 -0
  8. cbpr_rules/py.typed +0 -0
  9. cbpr_rules/reference/__init__.py +9 -0
  10. cbpr_rules/reference/countries.py +28 -0
  11. cbpr_rules/reference/currencies.py +25 -0
  12. cbpr_rules/registry.py +107 -0
  13. cbpr_rules/rules/__init__.py +1 -0
  14. cbpr_rules/rules/y2025/__init__.py +1 -0
  15. cbpr_rules/rules/y2025/camt_052.py +224 -0
  16. cbpr_rules/rules/y2025/camt_054.py +176 -0
  17. cbpr_rules/rules/y2025/pacs_002.py +212 -0
  18. cbpr_rules/rules/y2025/pacs_004.py +831 -0
  19. cbpr_rules/rules/y2025/pacs_008.py +375 -0
  20. cbpr_rules/rules/y2025/pacs_008_stp.py +367 -0
  21. cbpr_rules/rules/y2025/pacs_009.py +273 -0
  22. cbpr_rules/rules/y2025/pacs_009_adv.py +255 -0
  23. cbpr_rules/rules/y2025/pacs_009_cov.py +358 -0
  24. cbpr_rules/rules/y2025/pain_001.py +306 -0
  25. cbpr_rules/rules/y2026/__init__.py +1 -0
  26. cbpr_rules/rules/y2026/camt_052.py +191 -0
  27. cbpr_rules/rules/y2026/camt_054.py +182 -0
  28. cbpr_rules/rules/y2026/pacs_002.py +208 -0
  29. cbpr_rules/rules/y2026/pacs_004.py +491 -0
  30. cbpr_rules/rules/y2026/pacs_008.py +377 -0
  31. cbpr_rules/rules/y2026/pacs_008_stp.py +369 -0
  32. cbpr_rules/rules/y2026/pacs_009.py +260 -0
  33. cbpr_rules/rules/y2026/pacs_009_adv.py +256 -0
  34. cbpr_rules/rules/y2026/pacs_009_cov.py +324 -0
  35. cbpr_rules/rules/y2026/pain_001.py +272 -0
  36. cbpr_rules/schema.py +97 -0
  37. cbpr_rules/validators/__init__.py +16 -0
  38. cbpr_rules/validators/bic.py +21 -0
  39. cbpr_rules/validators/country.py +11 -0
  40. cbpr_rules/validators/currency.py +11 -0
  41. cbpr_rules/validators/iban.py +26 -0
  42. cbpr_rules/validators/lei.py +17 -0
  43. cbpr_usage_rules-0.1.0.dist-info/METADATA +335 -0
  44. cbpr_usage_rules-0.1.0.dist-info/RECORD +48 -0
  45. cbpr_usage_rules-0.1.0.dist-info/WHEEL +5 -0
  46. cbpr_usage_rules-0.1.0.dist-info/entry_points.txt +2 -0
  47. cbpr_usage_rules-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. cbpr_usage_rules-0.1.0.dist-info/top_level.txt +1 -0
cbpr_rules/helpers.py ADDED
@@ -0,0 +1,420 @@
1
+ """Reusable check builders for the recurring CBPR+ rule patterns.
2
+
3
+ Each builder returns a ``check(msg, report)`` function, so it can be registered
4
+ directly with the ``@rule`` decorator::
5
+
6
+ rule("pacs.008", 2025, "R20", NAME, DESC)(
7
+ presence_together(BASE, "Nm", "PstlAdr")
8
+ )
9
+
10
+ Hand-authored rules with bespoke logic just define ``fn(msg, report)`` directly
11
+ and use the query methods on ParsedMessage. These builders cover the common
12
+ templated patterns ("if X present then Y", "A must equal B", length/code lists)
13
+ so they need only be written once.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from decimal import Decimal, InvalidOperation
19
+ from typing import Callable, Iterable, Optional, Sequence
20
+
21
+ from lxml import etree
22
+
23
+
24
+ def presence_together(base_path: str, a: str, b: str) -> Callable:
25
+ """``a`` and ``b`` (relative to each ``base_path``) must be present together."""
26
+
27
+ def check(msg, report):
28
+ for ctx in msg.each(base_path):
29
+ has_a = msg.present(a, ctx)
30
+ has_b = msg.present(b, ctx)
31
+ if has_a != has_b:
32
+ report(ctx, detail=f"{a} and {b} must be present together")
33
+
34
+ return check
35
+
36
+
37
+ def requires_if_present(base_path: str, trigger: str, required: str) -> Callable:
38
+ """If ``trigger`` is present (per ``base_path``), ``required`` must be present."""
39
+
40
+ def check(msg, report):
41
+ for ctx in msg.each(base_path):
42
+ if msg.present(trigger, ctx) and msg.absent(required, ctx):
43
+ report(ctx, detail=f"{required} required when {trigger} is present")
44
+
45
+ return check
46
+
47
+
48
+ def required_when_absent(
49
+ base_path: str, absent_path: str, required: Sequence[str], mode: str = "all"
50
+ ) -> Callable:
51
+ """When ``absent_path`` is absent, ``required`` paths must be present.
52
+
53
+ ``mode="all"`` (default): every path in ``required`` must be present.
54
+ ``mode="any"``: at least one of ``required`` must be present.
55
+ """
56
+
57
+ def check(msg, report):
58
+ for ctx in msg.each(base_path):
59
+ if not msg.absent(absent_path, ctx):
60
+ continue
61
+ present = [p for p in required if msg.present(p, ctx)]
62
+ ok = bool(present) if mode == "any" else len(present) == len(required)
63
+ if not ok:
64
+ joiner = " or " if mode == "any" else " and "
65
+ report(ctx, detail=f"{joiner.join(required)} required when {absent_path} is absent")
66
+
67
+ return check
68
+
69
+
70
+ def required_when_present(
71
+ base_path: str, present_path: str, required: Sequence[str], mode: str = "all"
72
+ ) -> Callable:
73
+ """When ``present_path`` is present, ``required`` paths must be present too."""
74
+
75
+ def check(msg, report):
76
+ for ctx in msg.each(base_path):
77
+ if not msg.present(present_path, ctx):
78
+ continue
79
+ found = [p for p in required if msg.present(p, ctx)]
80
+ ok = bool(found) if mode == "any" else len(found) == len(required)
81
+ if not ok:
82
+ joiner = " or " if mode == "any" else " and "
83
+ report(ctx, detail=f"{joiner.join(required)} required when {present_path} is present")
84
+
85
+ return check
86
+
87
+
88
+ def mutually_exclusive(base_path: str, paths: Sequence[str]) -> Callable:
89
+ """At most one of ``paths`` (relative to ``base_path``) may be present."""
90
+
91
+ def check(msg, report):
92
+ for ctx in msg.each(base_path):
93
+ present = [p for p in paths if msg.present(p, ctx)]
94
+ if len(present) > 1:
95
+ report(ctx, detail=f"mutually exclusive: {', '.join(present)}")
96
+
97
+ return check
98
+
99
+
100
+ def forbidden_when_present(base_path: str, forbidden: str, when: str) -> Callable:
101
+ """``forbidden`` must not be present when ``when`` is present."""
102
+
103
+ def check(msg, report):
104
+ for ctx in msg.each(base_path):
105
+ if msg.present(when, ctx) and msg.present(forbidden, ctx):
106
+ report(ctx, detail=f"{forbidden} cannot be present when {when} is present")
107
+
108
+ return check
109
+
110
+
111
+ def value_not_in(path: str, forbidden_values: Iterable[str]) -> Callable:
112
+ """No occurrence of ``path`` may hold a value in ``forbidden_values``."""
113
+ forbidden = set(forbidden_values)
114
+
115
+ def check(msg, report):
116
+ for node in msg.find(path):
117
+ val = msg.text_of(node)
118
+ if val in forbidden:
119
+ report(node, detail=f"'{val}' is not allowed")
120
+
121
+ return check
122
+
123
+
124
+ def not_matching_pattern(path: str, pattern: str) -> Callable:
125
+ """Every occurrence of ``path`` must NOT match ``pattern`` (a forbidden regex)."""
126
+ import re as _re
127
+
128
+ rx = _re.compile(pattern)
129
+
130
+ def check(msg, report):
131
+ for node in msg.find(path):
132
+ val = msg.text_of(node)
133
+ if val and rx.fullmatch(val):
134
+ report(node, detail="value matches a forbidden pattern")
135
+
136
+ return check
137
+
138
+
139
+ # Structured PostalAddress components (short ISO tags), excluding AddressLine.
140
+ ADDRESS_COMPONENTS = (
141
+ "Dept", "SubDept", "StrtNm", "BldgNb", "BldgNm", "Flr", "PstBx", "Room",
142
+ "PstCd", "TwnNm", "TwnLctnNm", "DstrctNm", "CtrySubDvsn", "Ctry",
143
+ )
144
+
145
+
146
+ def address_lines_max_length(postal_path: str, limit: int = 35) -> Callable:
147
+ """CBPR+ "unstructured address" rule.
148
+
149
+ When a postal address uses only AddressLine (every structured component
150
+ absent), each AddressLine must not exceed ``limit`` characters.
151
+ """
152
+
153
+ def check(msg, report):
154
+ for adr in msg.each(postal_path):
155
+ if msg.absent("AdrLine", adr):
156
+ continue
157
+ if any(msg.present(s, adr) for s in ADDRESS_COMPONENTS):
158
+ continue
159
+ for line in msg.find("AdrLine", adr):
160
+ if len(msg.text_of(line)) > limit:
161
+ report(line, detail=f"AddressLine exceeds {limit} characters")
162
+
163
+ return check
164
+
165
+
166
+ def address_hybrid(postal_path: str) -> Callable:
167
+ """CBPR+ "hybrid address" rule.
168
+
169
+ When AddressLine and any structured component are both present, TownName and
170
+ Country are mandatory.
171
+ """
172
+
173
+ def check(msg, report):
174
+ for adr in msg.each(postal_path):
175
+ if msg.absent("AdrLine", adr):
176
+ continue
177
+ if not any(msg.present(s, adr) for s in ADDRESS_COMPONENTS):
178
+ continue
179
+ if not (msg.present("TwnNm", adr) and msg.present("Ctry", adr)):
180
+ report(adr, detail="TownName and Country required for a hybrid address")
181
+
182
+ return check
183
+
184
+
185
+ def same_value(
186
+ path_a: str,
187
+ path_b: str,
188
+ unless_path: Optional[str] = None,
189
+ unless_values: Sequence[str] = (),
190
+ ) -> Callable:
191
+ """Every occurrence of ``path_a`` must equal every occurrence of ``path_b``.
192
+
193
+ Skipped when ``unless_path`` has any value in ``unless_values`` (e.g. a
194
+ CopyDuplicate of COPY/CODU). Both anchored (absolute) paths.
195
+ """
196
+
197
+ def check(msg, report):
198
+ if unless_path is not None and unless_values:
199
+ if any(v in set(unless_values) for v in msg.values(unless_path)):
200
+ return
201
+ a_nodes = msg.find(path_a)
202
+ b_vals = {msg.text_of(n) for n in msg.find(path_b)}
203
+ a_vals = {msg.text_of(n) for n in a_nodes}
204
+ if not a_nodes or not b_vals:
205
+ return
206
+ if a_vals != b_vals:
207
+ target = a_nodes[0]
208
+ report(target, detail=f"{path_a} must equal {path_b}")
209
+
210
+ return check
211
+
212
+
213
+ def max_length(path: str, limit: int) -> Callable:
214
+ """Every occurrence of ``path`` must not exceed ``limit`` characters."""
215
+
216
+ def check(msg, report):
217
+ for node in msg.find(path):
218
+ if len(msg.text_of(node)) > limit:
219
+ report(node, detail=f"exceeds {limit} characters")
220
+
221
+ return check
222
+
223
+
224
+ def code_in(path: str, allowed: Iterable[str]) -> Callable:
225
+ """Every occurrence of ``path`` must hold a value within ``allowed``."""
226
+ allowed_set = set(allowed)
227
+
228
+ def check(msg, report):
229
+ for node in msg.find(path):
230
+ val = msg.text_of(node)
231
+ if val and val not in allowed_set:
232
+ report(node, detail=f"'{val}' not in allowed code list")
233
+
234
+ return check
235
+
236
+
237
+ def must_be_absent(path: str) -> Callable:
238
+ """``path`` must not be present (element removed by the usage guideline)."""
239
+
240
+ def check(msg, report):
241
+ for node in msg.find(path):
242
+ report(node, detail="element must not be used")
243
+
244
+ return check
245
+
246
+
247
+ def each_value_valid(path: str, validator: Callable[[str], bool], label: str) -> Callable:
248
+ """Every non-empty value at ``path`` must satisfy ``validator`` (e.g. IBAN/BIC)."""
249
+
250
+ def check(msg, report):
251
+ for node in msg.find(path):
252
+ val = msg.text_of(node)
253
+ if val and not validator(val):
254
+ report(node, detail=f"invalid {label}: '{val}'")
255
+
256
+ return check
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Cross-cutting checks promoted from advisory rules (Tier A / Tier B).
261
+ # Each is conservative: it skips when its inputs are absent or ambiguous, so a
262
+ # previously-valid message can never be made to fail spuriously.
263
+ # ---------------------------------------------------------------------------
264
+
265
+ def header_msg_def_id_matches() -> Callable:
266
+ """``/AppHdr/MsgDefIdr``, if present, must equal the Document's definition id.
267
+
268
+ The expected id is the namespace suffix of the ``<Document>`` element, e.g.
269
+ ``pacs.009.001.08``. Cross-schema (BAH vs Document); skips if either absent.
270
+ """
271
+
272
+ def check(msg, report):
273
+ if msg.bah is None or msg.document is None:
274
+ return
275
+ nodes = msg.find("/AppHdr/MsgDefIdr")
276
+ if not nodes:
277
+ return
278
+ ns = etree.QName(msg.document).namespace or ""
279
+ marker = "tech:xsd:"
280
+ expected = ns.split(marker, 1)[1] if marker in ns else None
281
+ val = msg.text_of(nodes[0])
282
+ if expected and val and val != expected:
283
+ report(nodes[0], detail=f"MsgDefIdr '{val}' does not match the message definition '{expected}'")
284
+
285
+ return check
286
+
287
+
288
+ def business_msg_id_carries_group_id() -> Callable:
289
+ """``/AppHdr/BizMsgIdr`` must contain the Document's ``GrpHdr/MsgId`` when present."""
290
+
291
+ def check(msg, report):
292
+ if msg.bah is None or msg.document is None:
293
+ return
294
+ bmi = msg.find("/AppHdr/BizMsgIdr")
295
+ if not bmi:
296
+ return
297
+ msgid = None
298
+ for grp in msg.iter_local("GrpHdr"):
299
+ kids = msg.find("MsgId", grp)
300
+ if kids:
301
+ msgid = msg.text_of(kids[0])
302
+ break
303
+ if not msgid:
304
+ return
305
+ if msgid not in msg.text_of(bmi[0]):
306
+ report(bmi[0], detail=f"BizMsgIdr should carry the GroupHeader MsgId '{msgid}'")
307
+
308
+ return check
309
+
310
+
311
+ def _token_contained(value: str, line: str) -> bool:
312
+ """True if ``value`` equals ``line`` or appears token-bounded within it."""
313
+ if value == line:
314
+ return True
315
+ return re.search(r"(?<![A-Za-z0-9])" + re.escape(value) + r"(?![A-Za-z0-9])", line) is not None
316
+
317
+
318
+ def no_postal_address_duplication(min_len: int = 3) -> Callable:
319
+ """No structured Postal Address value may be repeated inside an AddressLine.
320
+
321
+ Scans every ``PstlAdr`` in the message. Conservative: only flags structured
322
+ values of at least ``min_len`` characters that appear as a whole AddressLine
323
+ or as a token-bounded substring of one.
324
+ """
325
+
326
+ def check(msg, report):
327
+ for adr in msg.iter_local("PstlAdr"):
328
+ lines = [msg.text_of(line) for line in msg.find("AdrLine", adr)]
329
+ if not lines:
330
+ continue
331
+ for comp in ADDRESS_COMPONENTS:
332
+ for node in msg.find(comp, adr):
333
+ val = msg.text_of(node)
334
+ if len(val) < min_len:
335
+ continue
336
+ if any(_token_contained(val, line) for line in lines):
337
+ report(node, detail=f"structured value '{val}' is duplicated in AddressLine")
338
+
339
+ return check
340
+
341
+
342
+ def bic_presence_exclusive(party_path: str) -> Callable:
343
+ """For a party: if ``Id/OrgId/AnyBIC`` is present, ``Nm`` and ``PstlAdr`` are not allowed."""
344
+
345
+ def check(msg, report):
346
+ for party in msg.each(party_path):
347
+ if msg.present("Id/OrgId/AnyBIC", party) and (
348
+ msg.present("Nm", party) or msg.present("PstlAdr", party)
349
+ ):
350
+ report(party, detail="Name/PostalAddress not allowed when AnyBIC is present")
351
+
352
+ return check
353
+
354
+
355
+ def structured_remittance_max_total(path: str, limit: int = 9000) -> Callable:
356
+ """Total text (excluding tags) of all Structured Remittance occurrences ≤ ``limit``."""
357
+
358
+ def check(msg, report):
359
+ nodes = msg.find(path)
360
+ if not nodes:
361
+ return
362
+ total = sum(
363
+ len("".join(t.strip() for t in node.itertext())) for node in nodes
364
+ )
365
+ if total > limit:
366
+ report(nodes[0], detail=f"structured remittance total {total} exceeds {limit} characters")
367
+
368
+ return check
369
+
370
+
371
+ def charges_required_when_amounts_differ(
372
+ base_path: str, instructed_amt: str, settlement_amt: str, charges: str
373
+ ) -> Callable:
374
+ """If instructed & settlement amounts share a currency and differ, charges are mandatory.
375
+
376
+ All sub-paths are relative to each ``base_path`` (e.g. the transaction).
377
+ Skips unless both amounts are present with the same ``@Ccy`` and parse cleanly.
378
+ """
379
+
380
+ def check(msg, report):
381
+ for ctx in msg.each(base_path):
382
+ inst = msg.find(instructed_amt, ctx)
383
+ sett = msg.find(settlement_amt, ctx)
384
+ if not inst or not sett:
385
+ continue
386
+ i_ccy, s_ccy = inst[0].get("Ccy"), sett[0].get("Ccy")
387
+ if not i_ccy or i_ccy != s_ccy:
388
+ continue
389
+ try:
390
+ iv = Decimal(msg.text_of(inst[0]))
391
+ sv = Decimal(msg.text_of(sett[0]))
392
+ except (InvalidOperation, ValueError):
393
+ continue
394
+ if iv != sv and msg.absent(charges, ctx):
395
+ report(ctx, detail="ChargesInformation is mandatory when instructed and settlement amounts differ in the same currency")
396
+
397
+ return check
398
+
399
+
400
+ def amount_equals_sum(total_path: str, parts_path: str, tolerance: str = "0.01") -> Callable:
401
+ """``total_path`` amount must equal the sum of ``parts_path`` amounts (same currency)."""
402
+ tol = Decimal(tolerance)
403
+
404
+ def check(msg, report):
405
+ totals = msg.find(total_path)
406
+ parts = msg.find(parts_path)
407
+ if not totals or not parts:
408
+ return
409
+ ccys = {n.get("Ccy") for n in [totals[0], *parts] if n.get("Ccy")}
410
+ if len(ccys) > 1:
411
+ return
412
+ try:
413
+ total = Decimal(msg.text_of(totals[0]))
414
+ summed = sum((Decimal(msg.text_of(p)) for p in parts), Decimal("0"))
415
+ except (InvalidOperation, ValueError):
416
+ return
417
+ if abs(total - summed) > tol:
418
+ report(totals[0], detail=f"total {total} does not equal the sum of records {summed}")
419
+
420
+ return check
cbpr_rules/loader.py ADDED
@@ -0,0 +1,77 @@
1
+ """Parse CBPR+ XML and locate the Business Application Header and Document.
2
+
3
+ The loader is deliberately tolerant of wrapper tags (``<RequestPayload>``,
4
+ ``<DataPDU>``, ``<Saa:Envelope>`` ...): it finds the ``AppHdr`` (head.001) and
5
+ ``Document`` elements wherever they sit in the tree, matching by *local name* so
6
+ namespace prefixes and versions never matter.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional, Tuple
11
+
12
+ from lxml import etree
13
+
14
+
15
+ def local_name(el) -> Optional[str]:
16
+ """Local (namespace-stripped) tag name of an element, or None for comments/PIs."""
17
+ tag = el.tag
18
+ if not isinstance(tag, str):
19
+ return None
20
+ return tag.rsplit("}", 1)[-1]
21
+
22
+
23
+ def _parser() -> "etree.XMLParser":
24
+ # huge_tree for large messages; keep line numbers; never touch the network.
25
+ return etree.XMLParser(
26
+ resolve_entities=False,
27
+ no_network=True,
28
+ huge_tree=True,
29
+ remove_comments=False,
30
+ )
31
+
32
+
33
+ def parse_file(path: str) -> "etree._ElementTree":
34
+ return etree.parse(path, _parser())
35
+
36
+
37
+ def parse_string(xml: str) -> "etree._ElementTree":
38
+ data = xml.encode("utf-8") if isinstance(xml, str) else xml
39
+ root = etree.fromstring(data, _parser())
40
+ return etree.ElementTree(root)
41
+
42
+
43
+ def locate(tree: "etree._ElementTree") -> Tuple[Optional["etree._Element"], Optional["etree._Element"]]:
44
+ """Return (app_header, document) elements, found anywhere under the root."""
45
+ root = tree.getroot()
46
+ bah = None
47
+ doc = None
48
+ for el in root.iter():
49
+ ln = local_name(el)
50
+ if ln == "AppHdr" and bah is None:
51
+ bah = el
52
+ elif ln == "Document" and doc is None:
53
+ doc = el
54
+ if bah is not None and doc is not None:
55
+ break
56
+ return bah, doc
57
+
58
+
59
+ def detect_message_type(doc: Optional["etree._Element"]) -> Optional[str]:
60
+ """Derive the base message type (e.g. ``pacs.008``) from the Document namespace.
61
+
62
+ Note: business variants (STP/COV/ADV) share a base namespace and cannot be
63
+ distinguished from the XML alone - callers select those explicitly.
64
+ """
65
+ if doc is None:
66
+ return None
67
+ qname = etree.QName(doc)
68
+ ns = qname.namespace or ""
69
+ marker = "tech:xsd:"
70
+ if marker in ns:
71
+ ident = ns.split(marker, 1)[1] # e.g. pacs.008.001.08
72
+ else:
73
+ ident = ns.rsplit(":", 1)[-1]
74
+ parts = ident.split(".")
75
+ if len(parts) >= 2:
76
+ return f"{parts[0]}.{parts[1]}" # pacs.008
77
+ return ident or None
cbpr_rules/message.py ADDED
@@ -0,0 +1,170 @@
1
+ """ParsedMessage - namespace-agnostic path access over a CBPR+ message.
2
+
3
+ Rules are authored against the short ISO 20022 XML-tag paths exactly as they
4
+ appear in the published usage guideline's "XML Path" column, e.g.::
5
+
6
+ /AppHdr/Fr/FIId/FinInstnId/BICFI
7
+ /Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtTpInf/InstrPrty
8
+
9
+ Paths are matched segment-by-segment by *local name*, so namespaces, prefixes
10
+ and wrapper tags are irrelevant. A path beginning with ``AppHdr`` is anchored at
11
+ the Business Application Header; one beginning with ``Document`` at the Document.
12
+ Any other path is treated as relative to a ``context`` element (used inside
13
+ "for each" iterations); a leading segment equal to the context's own name is
14
+ consumed so relative paths can restate the context name, matching the source
15
+ pseudo-code convention.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import List, Optional
20
+
21
+ from .loader import local_name
22
+
23
+
24
+ def _split(path: str) -> List[str]:
25
+ return [s for s in (seg.strip() for seg in path.strip().strip("/").split("/")) if s]
26
+
27
+
28
+ def _children_named(el, name: str) -> List["object"]:
29
+ out = []
30
+ for child in el:
31
+ if local_name(child) == name:
32
+ out.append(child)
33
+ return out
34
+
35
+
36
+ def _descend(elements: List["object"], segs: List[str]) -> List["object"]:
37
+ current = list(elements)
38
+ for seg in segs:
39
+ nxt: List[object] = []
40
+ for el in current:
41
+ nxt.extend(_children_named(el, seg))
42
+ current = nxt
43
+ if not current:
44
+ break
45
+ return current
46
+
47
+
48
+ class ParsedMessage:
49
+ """A parsed CBPR+ message exposing the BAH and Document for rule checks."""
50
+
51
+ def __init__(self, tree, bah, document, message_type=None, year=None):
52
+ self._tree = tree
53
+ self.bah = bah
54
+ self.document = document
55
+ self.message_type = message_type
56
+ self.year = year
57
+
58
+ # -- element metadata ------------------------------------------------
59
+ def xpath_of(self, el) -> str:
60
+ """A readable local-name xpath, e.g. ``/Document/.../IntrBkSttlmAmt``.
61
+
62
+ Positional ``[n]`` is added only where same-named siblings exist, so
63
+ paths stay clean but remain unambiguous for repeated elements.
64
+ """
65
+ if el is None:
66
+ return ""
67
+ steps = []
68
+ node = el
69
+ while node is not None and isinstance(node.tag, str):
70
+ name = local_name(node)
71
+ parent = node.getparent()
72
+ if parent is not None:
73
+ same = [c for c in parent if local_name(c) == name]
74
+ if len(same) > 1:
75
+ name = f"{name}[{same.index(node) + 1}]"
76
+ steps.append(name)
77
+ node = parent
78
+ return "/" + "/".join(reversed(steps))
79
+
80
+ def line_of(self, el) -> Optional[int]:
81
+ if el is None:
82
+ return None
83
+ return getattr(el, "sourceline", None)
84
+
85
+ @staticmethod
86
+ def text_of(el) -> str:
87
+ if el is None:
88
+ return ""
89
+ return (el.text or "").strip()
90
+
91
+ def snippet_of(self, el, limit: int = 160) -> str:
92
+ """A short, namespace-stripped view of the offending element.
93
+
94
+ Leaf elements render as ``<Tag attr="v">text</Tag>``; container
95
+ elements render as ``<Tag> containing {Child1, Child2, ...}`` so the
96
+ user can see what the file actually has at that location.
97
+ """
98
+ if el is None or not isinstance(el.tag, str):
99
+ return ""
100
+ name = local_name(el)
101
+ attrs = " ".join(
102
+ f'{k.rsplit("}", 1)[-1]}="{v}"' for k, v in el.attrib.items()
103
+ )
104
+ head = name + (" " + attrs if attrs else "")
105
+ children = [local_name(c) for c in el if isinstance(c.tag, str)]
106
+ if children:
107
+ shown = ", ".join(children[:8]) + (", ..." if len(children) > 8 else "")
108
+ out = f"<{head}> containing {{{shown}}}"
109
+ else:
110
+ text = self.text_of(el)
111
+ out = f"<{head}>{text}</{name}>"
112
+ return out if len(out) <= limit else out[: limit - 1] + "…"
113
+
114
+ # -- path queries ----------------------------------------------------
115
+ def find(self, path: str, context=None) -> List["object"]:
116
+ """Return all elements matching ``path`` (anchored or relative)."""
117
+ segs = _split(path)
118
+ if not segs:
119
+ return [context] if context is not None else []
120
+ head = segs[0]
121
+ if head == "AppHdr" or head.startswith("BusinessApplicationHeader"):
122
+ return _descend([self.bah], segs[1:]) if self.bah is not None else []
123
+ if head == "Document":
124
+ return _descend([self.document], segs[1:]) if self.document is not None else []
125
+ # relative to context
126
+ if context is not None:
127
+ if local_name(context) == head:
128
+ return _descend([context], segs[1:])
129
+ return _descend([context], segs)
130
+ return []
131
+
132
+ def each(self, path: str, context=None) -> List["object"]:
133
+ """Iteration helper - same as find(), named for "for each [path]" rules."""
134
+ return self.find(path, context)
135
+
136
+ def first(self, path: str, context=None):
137
+ matches = self.find(path, context)
138
+ return matches[0] if matches else None
139
+
140
+ def present(self, path: str, context=None) -> bool:
141
+ return bool(self.find(path, context))
142
+
143
+ def absent(self, path: str, context=None) -> bool:
144
+ return not self.present(path, context)
145
+
146
+ def values(self, path: str, context=None) -> List[str]:
147
+ """Stripped text content of every element matching ``path``."""
148
+ return [self.text_of(el) for el in self.find(path, context)]
149
+
150
+ def attr_nodes(self, path: str, attr: str, context=None):
151
+ """(element, attribute value) for every element matching ``path``.
152
+
153
+ Used for XML attributes such as the currency on an amount (``@Ccy``).
154
+ """
155
+ return [(el, el.get(attr)) for el in self.find(path, context)]
156
+
157
+ def iter_local(self, name: str, roots=None):
158
+ """Yield every descendant (at any depth) with local tag ``name``.
159
+
160
+ Scans the BAH and Document by default - used by cross-cutting checks
161
+ that apply wherever an element occurs (e.g. every PostalAddress).
162
+ """
163
+ if roots is None:
164
+ roots = [r for r in (self.bah, self.document) if r is not None]
165
+ for root in roots:
166
+ if root is None:
167
+ continue
168
+ for el in root.iter():
169
+ if local_name(el) == name:
170
+ yield el