cbpr-usage-rules 0.5.0__tar.gz → 0.6.0__tar.gz

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 (98) hide show
  1. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/CHANGELOG.md +9 -0
  2. {cbpr_usage_rules-0.5.0/src/cbpr_usage_rules.egg-info → cbpr_usage_rules-0.6.0}/PKG-INFO +20 -1
  3. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/README.md +19 -0
  4. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/__init__.py +1 -1
  5. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/cli.py +5 -1
  6. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/engine.py +51 -2
  7. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/message.py +81 -10
  8. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/registry.py +3 -9
  9. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0/src/cbpr_usage_rules.egg-info}/PKG-INFO +20 -1
  10. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/LICENSE +0 -0
  11. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/MANIFEST.in +0 -0
  12. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/pyproject.toml +0 -0
  13. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/setup.cfg +0 -0
  14. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/camt.052.max.xml +0 -0
  15. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/camt.052.min.xml +0 -0
  16. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/camt.054.max.xml +0 -0
  17. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/camt.054.min.xml +0 -0
  18. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.002.max.xml +0 -0
  19. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.002.min.xml +0 -0
  20. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.004.max.xml +0 -0
  21. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.004.min.xml +0 -0
  22. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.008.max.xml +0 -0
  23. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.008.min.xml +0 -0
  24. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.008_stp.max.xml +0 -0
  25. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.008_stp.min.xml +0 -0
  26. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.009.max.xml +0 -0
  27. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.009.min.xml +0 -0
  28. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.009_adv.max.xml +0 -0
  29. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.009_adv.min.xml +0 -0
  30. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.009_cov.max.xml +0 -0
  31. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pacs.009_cov.min.xml +0 -0
  32. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pain.001.max.xml +0 -0
  33. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2025/pain.001.min.xml +0 -0
  34. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/camt.052.max.xml +0 -0
  35. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/camt.052.min.xml +0 -0
  36. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/camt.054.max.xml +0 -0
  37. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/camt.054.min.xml +0 -0
  38. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.002.max.xml +0 -0
  39. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.002.min.xml +0 -0
  40. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.004.max.xml +0 -0
  41. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.004.min.xml +0 -0
  42. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.008.max.xml +0 -0
  43. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.008.min.xml +0 -0
  44. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.008_stp.max.xml +0 -0
  45. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.008_stp.min.xml +0 -0
  46. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.009.max.xml +0 -0
  47. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.009.min.xml +0 -0
  48. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.009_adv.max.xml +0 -0
  49. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.009_adv.min.xml +0 -0
  50. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.009_cov.max.xml +0 -0
  51. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pacs.009_cov.min.xml +0 -0
  52. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pain.001.max.xml +0 -0
  53. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/examples/y2026/pain.001.min.xml +0 -0
  54. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/helpers.py +0 -0
  55. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/idgen.py +0 -0
  56. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/loader.py +0 -0
  57. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/models.py +0 -0
  58. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/py.typed +0 -0
  59. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/reference/__init__.py +0 -0
  60. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/reference/countries.py +0 -0
  61. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/reference/currencies.py +0 -0
  62. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/__init__.py +0 -0
  63. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2025/__init__.py +0 -0
  64. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2025/camt_052.py +0 -0
  65. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2025/camt_054.py +0 -0
  66. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2025/pacs_002.py +0 -0
  67. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2025/pacs_004.py +0 -0
  68. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2025/pacs_008.py +0 -0
  69. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2025/pacs_008_stp.py +0 -0
  70. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2025/pacs_009.py +0 -0
  71. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2025/pacs_009_adv.py +0 -0
  72. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2025/pacs_009_cov.py +0 -0
  73. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2025/pain_001.py +0 -0
  74. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2026/__init__.py +0 -0
  75. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2026/camt_052.py +0 -0
  76. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2026/camt_054.py +0 -0
  77. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2026/pacs_002.py +0 -0
  78. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2026/pacs_004.py +0 -0
  79. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2026/pacs_008.py +0 -0
  80. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2026/pacs_008_stp.py +0 -0
  81. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2026/pacs_009.py +0 -0
  82. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2026/pacs_009_adv.py +0 -0
  83. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2026/pacs_009_cov.py +0 -0
  84. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/rules/y2026/pain_001.py +0 -0
  85. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/schema.py +0 -0
  86. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/validators/__init__.py +0 -0
  87. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/validators/bic.py +0 -0
  88. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/validators/country.py +0 -0
  89. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/validators/currency.py +0 -0
  90. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/validators/iban.py +0 -0
  91. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_rules/validators/lei.py +0 -0
  92. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_usage_rules.egg-info/SOURCES.txt +0 -0
  93. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_usage_rules.egg-info/dependency_links.txt +0 -0
  94. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_usage_rules.egg-info/entry_points.txt +0 -0
  95. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_usage_rules.egg-info/requires.txt +0 -0
  96. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/src/cbpr_usage_rules.egg-info/top_level.txt +0 -0
  97. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/tests/test_examples.py +0 -0
  98. {cbpr_usage_rules-0.5.0 → cbpr_usage_rules-0.6.0}/tests/test_idgen.py +0 -0
@@ -4,6 +4,15 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
5
5
  to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.0]
8
+
9
+ ### Added
10
+ - Rule listings now show the **affected XPaths** for each rule, so agents can see
11
+ exactly which field(s) a rule constrains (concrete element paths and `@attribute`
12
+ targets, or a `//Name` wildcard). Shown by default in `cbpr-validate --list` /
13
+ `--enforced` (text + JSON) and via `list_rules(..., with_xpaths=True)`. Paths are
14
+ derived by running each rule against the bundled example messages.
15
+
7
16
  ## [0.5.0]
8
17
 
9
18
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cbpr-usage-rules
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Validate ISO 20022 CBPR+ XML messages against SWIFT usage (business) rules.
5
5
  Author: Pete Houghton
6
6
  License: MIT License
@@ -282,6 +282,7 @@ matching is by local name, so namespace prefixes never matter.
282
282
  cbpr_rules.available(2025) # -> ['camt.052', 'pacs.008', ...]
283
283
  cbpr_rules.list_rules(2025, "pacs.008") # -> [{rule_number, name, description, severity, enforced}, ...]
284
284
  cbpr_rules.list_rules(2025, "pacs.008", enforced_only=True) # only the enforceable rules
285
+ cbpr_rules.list_rules(2025, "pacs.008", with_xpaths=True) # + "xpaths" each rule affects
285
286
  ```
286
287
 
287
288
  ```bash
@@ -290,6 +291,24 @@ cbpr-validate --year 2025 --type pacs.008 --list # all rules
290
291
  cbpr-validate --year 2025 --type pacs.008 --enforced # enforceable rules only
291
292
  ```
292
293
 
294
+ The `--list` / `--enforced` output (and `with_xpaths=True`) annotates each rule with
295
+ the **concrete fields it affects**, so you can see exactly which element/attribute a
296
+ rule constrains — handy when several fields share a name (e.g. many `Ctry` fields, but
297
+ a rule targets only one):
298
+
299
+ ```text
300
+ pacs.008:VAL-IBAN - CBPR_Valid_IBAN
301
+ //IBAN
302
+ pacs.008:VAL-BIC - CBPR_Valid_Agent_BIC
303
+ /Document/FIToFICstmrCdtTrf/CdtTrfTxInf/InstgAgt/FinInstnId/BICFI
304
+ pacs.008:R41 - CBPR_Interbank_Settlement_Currency_FormalRule
305
+ /Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy
306
+ ```
307
+
308
+ Paths are derived by running each rule against the bundled example messages
309
+ (best-effort): concrete element paths and `@attribute` targets where present, or a
310
+ `//Name` wildcard for a datatype rule whose element isn't in the examples.
311
+
293
312
  ### Algorithmic field validation
294
313
 
295
314
  Beyond the published rules, the following formats are validated with their
@@ -227,6 +227,7 @@ matching is by local name, so namespace prefixes never matter.
227
227
  cbpr_rules.available(2025) # -> ['camt.052', 'pacs.008', ...]
228
228
  cbpr_rules.list_rules(2025, "pacs.008") # -> [{rule_number, name, description, severity, enforced}, ...]
229
229
  cbpr_rules.list_rules(2025, "pacs.008", enforced_only=True) # only the enforceable rules
230
+ cbpr_rules.list_rules(2025, "pacs.008", with_xpaths=True) # + "xpaths" each rule affects
230
231
  ```
231
232
 
232
233
  ```bash
@@ -235,6 +236,24 @@ cbpr-validate --year 2025 --type pacs.008 --list # all rules
235
236
  cbpr-validate --year 2025 --type pacs.008 --enforced # enforceable rules only
236
237
  ```
237
238
 
239
+ The `--list` / `--enforced` output (and `with_xpaths=True`) annotates each rule with
240
+ the **concrete fields it affects**, so you can see exactly which element/attribute a
241
+ rule constrains — handy when several fields share a name (e.g. many `Ctry` fields, but
242
+ a rule targets only one):
243
+
244
+ ```text
245
+ pacs.008:VAL-IBAN - CBPR_Valid_IBAN
246
+ //IBAN
247
+ pacs.008:VAL-BIC - CBPR_Valid_Agent_BIC
248
+ /Document/FIToFICstmrCdtTrf/CdtTrfTxInf/InstgAgt/FinInstnId/BICFI
249
+ pacs.008:R41 - CBPR_Interbank_Settlement_Currency_FormalRule
250
+ /Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy
251
+ ```
252
+
253
+ Paths are derived by running each rule against the bundled example messages
254
+ (best-effort): concrete element paths and `@attribute` targets where present, or a
255
+ `//Name` wildcard for a datatype rule whose element isn't in the examples.
256
+
238
257
  ### Algorithmic field validation
239
258
 
240
259
  Beyond the published rules, the following formats are validated with their
@@ -4,7 +4,7 @@ This package is AI generated. See the README for details and caveats.
4
4
  """
5
5
  from __future__ import annotations
6
6
 
7
- __version__ = "0.5.0"
7
+ __version__ = "0.6.0"
8
8
 
9
9
  from .models import Rule, Severity, Violation
10
10
  from .engine import (
@@ -270,13 +270,17 @@ def main(argv: Optional[List[str]] = None) -> int:
270
270
  if args.year is None or args.msgtype is None:
271
271
  print("error: --year and --type are required for --list", file=sys.stderr)
272
272
  return 2
273
- rules = list_rules(args.year, args.msgtype, enforced_only=args.enforced)
273
+ rules = list_rules(
274
+ args.year, args.msgtype, enforced_only=args.enforced, with_xpaths=True
275
+ )
274
276
  if args.json:
275
277
  print(json.dumps(rules, indent=2))
276
278
  else:
277
279
  for r in rules:
278
280
  flag = "" if r["enforced"] else " (advisory)"
279
281
  print(f"{r['rule_number']} - {r['name']}{flag}")
282
+ for xp in r.get("xpaths", []):
283
+ print(f" {xp}")
280
284
  return 0
281
285
 
282
286
  if args.year is None:
@@ -95,15 +95,64 @@ def validate_string(xml: str, year: int, msgtype: Optional[str] = None, xsd=None
95
95
  return _validate_tree(tree, year, msgtype, xsd)
96
96
 
97
97
 
98
- def list_rules(year: int, msgtype: str, enforced_only: bool = False) -> List[dict]:
98
+ def _drop_redundant_bare_paths(paths: set) -> List[str]:
99
+ """Tidy captured paths: drop a bare element path when an ``@attribute`` variant
100
+ exists, and a ``//Name`` wildcard when concrete paths for that name exist."""
101
+ attr_bases = {p.rsplit("/@", 1)[0] for p in paths if "/@" in p}
102
+ wild_names = {p[2:] for p in paths if p.startswith("//")}
103
+ concrete = {p.rsplit("/", 1)[-1].lstrip("@") for p in paths if not p.startswith("//")}
104
+ kept = set()
105
+ for p in paths:
106
+ if p.startswith("//") and p[2:] in concrete:
107
+ continue
108
+ if "/@" not in p and p in attr_bases:
109
+ continue
110
+ kept.add(p)
111
+ return sorted(kept)
112
+
113
+
114
+ def rule_xpaths(year: int, msgtype: str) -> dict:
115
+ """Map each rule_number to the concrete xpaths it touches.
116
+
117
+ Derived by running each rule against the bundled min+max example messages
118
+ and recording the fields its queries read (best-effort; a field absent from
119
+ both examples and read only inside a non-firing branch may be missed).
120
+ """
121
+ rules = load_rules(year, msgtype)
122
+ captured = {r.rule_number: set() for r in rules}
123
+ for variant in ("min", "max"):
124
+ try:
125
+ xml = example_message(year, msgtype, variant)
126
+ tree = loader.parse_string(xml)
127
+ except Exception:
128
+ continue
129
+ bah, doc = loader.locate(tree)
130
+ msg = ParsedMessage(tree, bah, doc, message_type=msgtype, year=year)
131
+ for r in rules:
132
+ if not r.enforced:
133
+ continue
134
+ with msg.record() as paths:
135
+ r.run(msg)
136
+ captured[r.rule_number] |= paths
137
+ return {num: _drop_redundant_bare_paths(p) for num, p in captured.items()}
138
+
139
+
140
+ def list_rules(
141
+ year: int, msgtype: str, enforced_only: bool = False, with_xpaths: bool = False
142
+ ) -> List[dict]:
99
143
  """Return metadata for the rules registered for a (year, message type).
100
144
 
101
145
  With ``enforced_only=True`` only the enforceable (mechanically-checked) rules
102
- are returned, omitting advisory/catalogue-only ones.
146
+ are returned. With ``with_xpaths=True`` each rule dict gains an ``"xpaths"``
147
+ list of the concrete fields it affects (see ``rule_xpaths``).
103
148
  """
104
149
  rules = [r.to_dict() for r in load_rules(year, msgtype)]
105
150
  if enforced_only:
106
151
  rules = [r for r in rules if r["enforced"]]
152
+ if with_xpaths:
153
+ paths = rule_xpaths(year, msgtype)
154
+ for r in rules:
155
+ r["xpaths"] = paths.get(r["rule_number"], [])
107
156
  return rules
108
157
 
109
158
 
@@ -16,10 +16,14 @@ pseudo-code convention.
16
16
  """
17
17
  from __future__ import annotations
18
18
 
19
+ import re
20
+ from contextlib import contextmanager
19
21
  from typing import List, Optional
20
22
 
21
23
  from .loader import local_name
22
24
 
25
+ _INDEX_RE = re.compile(r"\[\d+\]")
26
+
23
27
 
24
28
  def _split(path: str) -> List[str]:
25
29
  return [s for s in (seg.strip() for seg in path.strip().strip("/").split("/")) if s]
@@ -54,6 +58,53 @@ class ParsedMessage:
54
58
  self.document = document
55
59
  self.message_type = message_type
56
60
  self.year = year
61
+ self._rec: Optional[set] = None # active path recorder, or None
62
+
63
+ # -- path recording (for "which fields does a rule touch?") ----------
64
+ @contextmanager
65
+ def record(self):
66
+ """Within this block, collect the normalized xpaths the queries touch."""
67
+ prev = self._rec
68
+ self._rec = set()
69
+ try:
70
+ yield self._rec
71
+ finally:
72
+ self._rec = prev
73
+
74
+ def _normalize(self, xpath: str) -> str:
75
+ """Strip positional ``[n]`` and re-root at /Document or /AppHdr."""
76
+ xpath = _INDEX_RE.sub("", xpath)
77
+ segs = xpath.strip("/").split("/")
78
+ for anchor in ("Document", "AppHdr"):
79
+ if anchor in segs:
80
+ return "/" + "/".join(segs[segs.index(anchor):])
81
+ return xpath
82
+
83
+ def _record_elements(self, elements, attr: Optional[str] = None) -> None:
84
+ if self._rec is None:
85
+ return
86
+ for el in elements:
87
+ if not isinstance(el.tag, str):
88
+ continue
89
+ path = self._normalize(self.xpath_of(el))
90
+ self._rec.add(path + "/@" + attr if attr else path)
91
+
92
+ def _record_intent(self, segs: List[str], context) -> None:
93
+ """Record the intended anchored path of a query, even if it matched nothing."""
94
+ if self._rec is None or not segs:
95
+ return
96
+ head = segs[0]
97
+ if head == "AppHdr" or head.startswith("BusinessApplicationHeader"):
98
+ anchored = "/AppHdr/" + "/".join(segs[1:])
99
+ elif head == "Document":
100
+ anchored = "/Document/" + "/".join(segs[1:])
101
+ elif context is not None:
102
+ rest = segs[1:] if local_name(context) == head else segs
103
+ base = self._normalize(self.xpath_of(context))
104
+ anchored = base + ("/" + "/".join(rest) if rest else "")
105
+ else:
106
+ return
107
+ self._rec.add(anchored.rstrip("/"))
57
108
 
58
109
  # -- element metadata ------------------------------------------------
59
110
  def xpath_of(self, el) -> str:
@@ -119,15 +170,17 @@ class ParsedMessage:
119
170
  return [context] if context is not None else []
120
171
  head = segs[0]
121
172
  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 []
173
+ result = _descend([self.bah], segs[1:]) if self.bah is not None else []
174
+ elif head == "Document":
175
+ result = _descend([self.document], segs[1:]) if self.document is not None else []
176
+ elif context is not None: # relative to context
177
+ rest = segs[1:] if local_name(context) == head else segs
178
+ result = _descend([context], rest)
179
+ else:
180
+ result = []
181
+ self._record_elements(result)
182
+ self._record_intent(segs, context)
183
+ return result
131
184
 
132
185
  def each(self, path: str, context=None) -> List["object"]:
133
186
  """Iteration helper - same as find(), named for "for each [path]" rules."""
@@ -152,7 +205,9 @@ class ParsedMessage:
152
205
 
153
206
  Used for XML attributes such as the currency on an amount (``@Ccy``).
154
207
  """
155
- return [(el, el.get(attr)) for el in self.find(path, context)]
208
+ nodes = self.find(path, context)
209
+ self._record_elements(nodes, attr=attr)
210
+ return [(el, el.get(attr)) for el in nodes]
156
211
 
157
212
  def iter_local(self, name: str, roots=None):
158
213
  """Yield every descendant (at any depth) with local tag ``name``.
@@ -160,6 +215,8 @@ class ParsedMessage:
160
215
  Scans the BAH and Document by default - used by cross-cutting checks
161
216
  that apply wherever an element occurs (e.g. every PostalAddress).
162
217
  """
218
+ if self._rec is not None:
219
+ self._rec.add(f"//{name}") # wildcard intent (dropped if concrete paths found)
163
220
  if roots is None:
164
221
  roots = [r for r in (self.bah, self.document) if r is not None]
165
222
  for root in roots:
@@ -167,4 +224,18 @@ class ParsedMessage:
167
224
  continue
168
225
  for el in root.iter():
169
226
  if local_name(el) == name:
227
+ self._record_elements([el])
170
228
  yield el
229
+
230
+ def iter_attr(self, attr: str):
231
+ """Yield (element, value) for every element carrying ``attr`` anywhere.
232
+
233
+ Cross-cutting equivalent of ``attr_nodes`` for attributes like ``@Ccy``.
234
+ """
235
+ for root in (self.bah, self.document):
236
+ if root is None:
237
+ continue
238
+ for el in root.iter():
239
+ if isinstance(el.tag, str) and el.get(attr) is not None:
240
+ self._record_elements([el], attr=attr)
241
+ yield el, el.get(attr)
@@ -171,15 +171,9 @@ def _currency_rule(msgtype: str) -> Rule:
171
171
  )
172
172
 
173
173
  # Every ``Ccy`` attribute, anywhere in the message.
174
- for root in (msg.bah, msg.document):
175
- if root is None:
176
- continue
177
- for el in root.iter():
178
- if not isinstance(el.tag, str):
179
- continue
180
- code = el.get("Ccy")
181
- if code and not is_valid_currency(code):
182
- flag(el, code)
174
+ for el, code in msg.iter_attr("Ccy"):
175
+ if code and not is_valid_currency(code):
176
+ flag(el, code)
183
177
  # Standalone ``<Ccy>`` elements (e.g. account currency).
184
178
  for el in msg.iter_local("Ccy"):
185
179
  code = msg.text_of(el)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cbpr-usage-rules
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Validate ISO 20022 CBPR+ XML messages against SWIFT usage (business) rules.
5
5
  Author: Pete Houghton
6
6
  License: MIT License
@@ -282,6 +282,7 @@ matching is by local name, so namespace prefixes never matter.
282
282
  cbpr_rules.available(2025) # -> ['camt.052', 'pacs.008', ...]
283
283
  cbpr_rules.list_rules(2025, "pacs.008") # -> [{rule_number, name, description, severity, enforced}, ...]
284
284
  cbpr_rules.list_rules(2025, "pacs.008", enforced_only=True) # only the enforceable rules
285
+ cbpr_rules.list_rules(2025, "pacs.008", with_xpaths=True) # + "xpaths" each rule affects
285
286
  ```
286
287
 
287
288
  ```bash
@@ -290,6 +291,24 @@ cbpr-validate --year 2025 --type pacs.008 --list # all rules
290
291
  cbpr-validate --year 2025 --type pacs.008 --enforced # enforceable rules only
291
292
  ```
292
293
 
294
+ The `--list` / `--enforced` output (and `with_xpaths=True`) annotates each rule with
295
+ the **concrete fields it affects**, so you can see exactly which element/attribute a
296
+ rule constrains — handy when several fields share a name (e.g. many `Ctry` fields, but
297
+ a rule targets only one):
298
+
299
+ ```text
300
+ pacs.008:VAL-IBAN - CBPR_Valid_IBAN
301
+ //IBAN
302
+ pacs.008:VAL-BIC - CBPR_Valid_Agent_BIC
303
+ /Document/FIToFICstmrCdtTrf/CdtTrfTxInf/InstgAgt/FinInstnId/BICFI
304
+ pacs.008:R41 - CBPR_Interbank_Settlement_Currency_FormalRule
305
+ /Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy
306
+ ```
307
+
308
+ Paths are derived by running each rule against the bundled example messages
309
+ (best-effort): concrete element paths and `@attribute` targets where present, or a
310
+ `//Name` wildcard for a datatype rule whose element isn't in the examples.
311
+
293
312
  ### Algorithmic field validation
294
313
 
295
314
  Beyond the published rules, the following formats are validated with their