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/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """cbpr_rules - validate ISO 20022 CBPR+ XML messages against SWIFT usage rules.
2
+
3
+ This package is AI generated. See the README for details and caveats.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ from .models import Rule, Severity, Violation
10
+ from .engine import validate_file, validate_string, list_rules, available
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "Rule",
15
+ "Severity",
16
+ "Violation",
17
+ "validate_file",
18
+ "validate_string",
19
+ "list_rules",
20
+ "available",
21
+ ]
cbpr_rules/cli.py ADDED
@@ -0,0 +1,176 @@
1
+ """Command-line interface: ``cbpr-validate``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import shutil
7
+ import sys
8
+ import textwrap
9
+ from typing import List, Optional
10
+
11
+ from . import __version__
12
+ from .engine import ValidationError, available, list_rules, validate_file, validate_string
13
+
14
+
15
+ def _build_parser() -> argparse.ArgumentParser:
16
+ p = argparse.ArgumentParser(
17
+ prog="cbpr-validate",
18
+ description="Validate ISO 20022 CBPR+ XML against SWIFT usage rules.",
19
+ )
20
+ p.add_argument("file", nargs="?", help="XML file to validate (reads stdin if omitted).")
21
+ p.add_argument("-y", "--year", type=int, required=False, help="Rule year, e.g. 2025 or 2026.")
22
+ p.add_argument(
23
+ "-t",
24
+ "--type",
25
+ dest="msgtype",
26
+ help="Message type override, e.g. pacs.008 or pacs.008_stp "
27
+ "(auto-detected from the Document namespace if omitted).",
28
+ )
29
+ p.add_argument("--json", action="store_true", help="Emit JSON instead of text.")
30
+ p.add_argument(
31
+ "--xsd",
32
+ action="append",
33
+ metavar="PATH",
34
+ help="Also schema-validate against this XSD (repeatable). Results are "
35
+ "shown separately from usage rules. Auto-matched to the Document or AppHdr "
36
+ "by the XSD's targetNamespace.",
37
+ )
38
+ p.add_argument(
39
+ "--advisory",
40
+ action="store_true",
41
+ help="Also list the advisory (non-enforced) rules in full.",
42
+ )
43
+ p.add_argument(
44
+ "--list",
45
+ action="store_true",
46
+ help="List the rules for --year/--type instead of validating.",
47
+ )
48
+ p.add_argument(
49
+ "--list-types", action="store_true", help="List message types available for --year."
50
+ )
51
+ p.add_argument("--version", action="version", version=f"cbpr-validate {__version__}")
52
+ return p
53
+
54
+
55
+ def _wrap(text: str, indent: str, width: int) -> List[str]:
56
+ text = " ".join((text or "").split())
57
+ if not text:
58
+ return []
59
+ return textwrap.wrap(
60
+ text, width=width, initial_indent=indent, subsequent_indent=indent
61
+ ) or [indent + text]
62
+
63
+
64
+ def _format_text(result: dict, show_advisory: bool = False) -> str:
65
+ width = max(60, min(shutil.get_terminal_size((100, 24)).columns, 100))
66
+ rule = "─" * width
67
+ lines: List[str] = []
68
+ status = "✓ VALID" if result["valid"] else "✗ INVALID"
69
+ lines.append(
70
+ f"{status} {result['message_type']} (rules year {result['year']}, "
71
+ f"{result['rules_evaluated']} enforced rules evaluated)"
72
+ )
73
+
74
+ violations = result["violations"]
75
+ if violations:
76
+ lines.append("")
77
+ lines.append(f"VIOLATIONS ({len(violations)})")
78
+ lines.append(rule)
79
+ for i, v in enumerate(violations, 1):
80
+ if i > 1:
81
+ lines.append("")
82
+ sev = v["severity"].upper()
83
+ loc = f"line {v['line']}" if v["line"] is not None else "line ?"
84
+ lines.append(f"{i}. [{sev}] {v['rule_number']} {v['name']}")
85
+ lines.extend(_wrap(v["description"], " ", width))
86
+ if v.get("detail"):
87
+ lines.extend(_wrap(f"Problem: {v['detail']}", " ", width))
88
+ if v.get("found"):
89
+ lines.extend(_wrap(f"Found: {v['found']}", " ", width))
90
+ lines.append(f" At: {loc} {v['xpath']}")
91
+ elif result["valid"]:
92
+ lines.append("No violations found.")
93
+
94
+ advisory = result.get("advisory") or []
95
+ if advisory:
96
+ lines.append("")
97
+ if show_advisory:
98
+ lines.append(f"ADVISORY — not enforced ({len(advisory)})")
99
+ lines.append(rule)
100
+ for a in advisory:
101
+ lines.append(f"• {a['rule_number']} {a['name']}")
102
+ lines.extend(_wrap(a["description"], " ", width))
103
+ else:
104
+ lines.append(
105
+ f"{len(advisory)} advisory rule(s) not enforced "
106
+ f"(run with --advisory to list them)."
107
+ )
108
+
109
+ xsd = result.get("xsd")
110
+ if xsd is not None:
111
+ lines.append("")
112
+ overall = "✓ SCHEMA-VALID" if xsd["schema_valid"] else "✗ SCHEMA-INVALID"
113
+ lines.append(f"XSD SCHEMA VALIDATION {overall}")
114
+ lines.append(rule)
115
+ for s in xsd["schemas"]:
116
+ mark = "✓" if s["valid"] else "✗"
117
+ tgt = s.get("validated_element") or "?"
118
+ note = " [namespace mismatch]" if s.get("namespace_mismatch") else ""
119
+ lines.append(f"{mark} {s['file']} (validated {tgt}{note})")
120
+ if s.get("load_error"):
121
+ lines.extend(_wrap(f"Could not load schema: {s['load_error']}", " ", width))
122
+ for e in s["errors"]:
123
+ loc = f"line {e['line']}" if e.get("line") is not None else "line ?"
124
+ lines.extend(_wrap(f"{loc}: {e['message']}", " ", width))
125
+ return "\n".join(lines)
126
+
127
+
128
+ def main(argv: Optional[List[str]] = None) -> int:
129
+ args = _build_parser().parse_args(argv)
130
+
131
+ if args.list_types:
132
+ if args.year is None:
133
+ print("error: --year is required for --list-types", file=sys.stderr)
134
+ return 2
135
+ types = available(args.year)
136
+ print(json.dumps(types) if args.json else "\n".join(types))
137
+ return 0
138
+
139
+ if args.list:
140
+ if args.year is None or args.msgtype is None:
141
+ print("error: --year and --type are required for --list", file=sys.stderr)
142
+ return 2
143
+ rules = list_rules(args.year, args.msgtype)
144
+ if args.json:
145
+ print(json.dumps(rules, indent=2))
146
+ else:
147
+ for r in rules:
148
+ flag = "" if r["enforced"] else " (advisory)"
149
+ print(f"{r['rule_number']} - {r['name']}{flag}")
150
+ return 0
151
+
152
+ if args.year is None:
153
+ print("error: --year is required to validate", file=sys.stderr)
154
+ return 2
155
+
156
+ try:
157
+ if args.file:
158
+ result = validate_file(args.file, args.year, args.msgtype, xsd=args.xsd)
159
+ else:
160
+ data = sys.stdin.read()
161
+ result = validate_string(data, args.year, args.msgtype, xsd=args.xsd)
162
+ except ValidationError as exc:
163
+ print(f"error: {exc}", file=sys.stderr)
164
+ return 2
165
+
166
+ if args.json:
167
+ print(json.dumps(result, indent=2))
168
+ else:
169
+ print(_format_text(result, show_advisory=args.advisory))
170
+
171
+ schema_ok = "xsd" not in result or result["xsd"]["schema_valid"]
172
+ return 0 if (result["valid"] and schema_ok) else 1
173
+
174
+
175
+ if __name__ == "__main__": # pragma: no cover
176
+ raise SystemExit(main())
cbpr_rules/engine.py ADDED
@@ -0,0 +1,100 @@
1
+ """Validation engine and public API."""
2
+ from __future__ import annotations
3
+
4
+ from typing import List, Optional
5
+
6
+ from . import loader
7
+ from . import schema as _schema
8
+ from .message import ParsedMessage
9
+ from .models import Rule, Severity
10
+ from .registry import available_message_types, load_rules
11
+
12
+
13
+ class ValidationError(Exception):
14
+ """Raised when a message cannot be validated (parse error, unknown type)."""
15
+
16
+
17
+ def _normalise_xsd(xsd) -> List[str]:
18
+ if xsd is None:
19
+ return []
20
+ return [xsd] if isinstance(xsd, str) else list(xsd)
21
+
22
+
23
+ def _validate_tree(tree, year: int, msgtype: Optional[str], xsd=None) -> dict:
24
+ bah, doc = loader.locate(tree)
25
+ if doc is None and bah is None:
26
+ raise ValidationError("No <Document> or <AppHdr> element found in the input.")
27
+
28
+ detected = loader.detect_message_type(doc)
29
+ if msgtype is None:
30
+ msgtype = detected
31
+ if msgtype is None:
32
+ raise ValidationError(
33
+ "Could not detect the message type; pass msgtype explicitly."
34
+ )
35
+
36
+ msg = ParsedMessage(tree, bah, doc, message_type=msgtype, year=year)
37
+ rules: List[Rule] = load_rules(year, msgtype)
38
+
39
+ violations = []
40
+ advisory = []
41
+ for r in rules:
42
+ if r.enforced:
43
+ violations.extend(v.to_dict() for v in r.run(msg))
44
+ else:
45
+ advisory.append(
46
+ {
47
+ "rule_number": r.rule_number,
48
+ "name": r.name,
49
+ "description": r.description,
50
+ }
51
+ )
52
+
53
+ has_hard = any(v["severity"] == Severity.VIOLATION.value for v in violations)
54
+ result = {
55
+ "valid": not has_hard,
56
+ "message_type": msgtype,
57
+ "detected_message_type": detected,
58
+ "year": int(year),
59
+ "rules_evaluated": sum(1 for r in rules if r.enforced),
60
+ "violations": violations,
61
+ "advisory": advisory,
62
+ }
63
+
64
+ xsd_paths = _normalise_xsd(xsd)
65
+ if xsd_paths:
66
+ result["xsd"] = _schema.validate_with_xsds(tree, bah, doc, xsd_paths)
67
+ return result
68
+
69
+
70
+ def validate_file(path: str, year: int, msgtype: Optional[str] = None, xsd=None) -> dict:
71
+ """Validate an XML file against usage rules (and optionally one or more XSDs).
72
+
73
+ ``xsd`` may be a path or a list of paths to XSD files (not bundled with the
74
+ package). When supplied, schema results are returned in a separate ``"xsd"``
75
+ key; when omitted, no ``"xsd"`` key is present.
76
+ """
77
+ try:
78
+ tree = loader.parse_file(path)
79
+ except Exception as exc: # lxml.etree.XMLSyntaxError and friends
80
+ raise ValidationError(f"Could not parse XML: {exc}") from exc
81
+ return _validate_tree(tree, year, msgtype, xsd)
82
+
83
+
84
+ def validate_string(xml: str, year: int, msgtype: Optional[str] = None, xsd=None) -> dict:
85
+ """Validate an XML string against usage rules (and optionally one or more XSDs)."""
86
+ try:
87
+ tree = loader.parse_string(xml)
88
+ except Exception as exc:
89
+ raise ValidationError(f"Could not parse XML: {exc}") from exc
90
+ return _validate_tree(tree, year, msgtype, xsd)
91
+
92
+
93
+ def list_rules(year: int, msgtype: str) -> List[dict]:
94
+ """Return metadata for every rule registered for a (year, message type)."""
95
+ return [r.to_dict() for r in load_rules(year, msgtype)]
96
+
97
+
98
+ def available(year: int) -> List[str]:
99
+ """Message types with rules available for a given year."""
100
+ return available_message_types(year)