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.
- cbpr_rules/__init__.py +21 -0
- cbpr_rules/cli.py +176 -0
- cbpr_rules/engine.py +100 -0
- cbpr_rules/helpers.py +420 -0
- cbpr_rules/loader.py +77 -0
- cbpr_rules/message.py +170 -0
- cbpr_rules/models.py +83 -0
- cbpr_rules/py.typed +0 -0
- cbpr_rules/reference/__init__.py +9 -0
- cbpr_rules/reference/countries.py +28 -0
- cbpr_rules/reference/currencies.py +25 -0
- cbpr_rules/registry.py +107 -0
- cbpr_rules/rules/__init__.py +1 -0
- cbpr_rules/rules/y2025/__init__.py +1 -0
- cbpr_rules/rules/y2025/camt_052.py +224 -0
- cbpr_rules/rules/y2025/camt_054.py +176 -0
- cbpr_rules/rules/y2025/pacs_002.py +212 -0
- cbpr_rules/rules/y2025/pacs_004.py +831 -0
- cbpr_rules/rules/y2025/pacs_008.py +375 -0
- cbpr_rules/rules/y2025/pacs_008_stp.py +367 -0
- cbpr_rules/rules/y2025/pacs_009.py +273 -0
- cbpr_rules/rules/y2025/pacs_009_adv.py +255 -0
- cbpr_rules/rules/y2025/pacs_009_cov.py +358 -0
- cbpr_rules/rules/y2025/pain_001.py +306 -0
- cbpr_rules/rules/y2026/__init__.py +1 -0
- cbpr_rules/rules/y2026/camt_052.py +191 -0
- cbpr_rules/rules/y2026/camt_054.py +182 -0
- cbpr_rules/rules/y2026/pacs_002.py +208 -0
- cbpr_rules/rules/y2026/pacs_004.py +491 -0
- cbpr_rules/rules/y2026/pacs_008.py +377 -0
- cbpr_rules/rules/y2026/pacs_008_stp.py +369 -0
- cbpr_rules/rules/y2026/pacs_009.py +260 -0
- cbpr_rules/rules/y2026/pacs_009_adv.py +256 -0
- cbpr_rules/rules/y2026/pacs_009_cov.py +324 -0
- cbpr_rules/rules/y2026/pain_001.py +272 -0
- cbpr_rules/schema.py +97 -0
- cbpr_rules/validators/__init__.py +16 -0
- cbpr_rules/validators/bic.py +21 -0
- cbpr_rules/validators/country.py +11 -0
- cbpr_rules/validators/currency.py +11 -0
- cbpr_rules/validators/iban.py +26 -0
- cbpr_rules/validators/lei.py +17 -0
- cbpr_usage_rules-0.1.0.dist-info/METADATA +335 -0
- cbpr_usage_rules-0.1.0.dist-info/RECORD +48 -0
- cbpr_usage_rules-0.1.0.dist-info/WHEEL +5 -0
- cbpr_usage_rules-0.1.0.dist-info/entry_points.txt +2 -0
- cbpr_usage_rules-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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)
|