cbpr-validate 1.0.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 (42) hide show
  1. cbpr_validate/__init__.py +18 -0
  2. cbpr_validate/api/__init__.py +0 -0
  3. cbpr_validate/api/main.py +120 -0
  4. cbpr_validate/cli.py +247 -0
  5. cbpr_validate/codesets/__init__.py +0 -0
  6. cbpr_validate/codesets/data/iso20022_codesets.json +276 -0
  7. cbpr_validate/codesets/loader.py +35 -0
  8. cbpr_validate/config.py +37 -0
  9. cbpr_validate/core.py +132 -0
  10. cbpr_validate/match/__init__.py +20 -0
  11. cbpr_validate/match/matcher.py +449 -0
  12. cbpr_validate/match/result.py +98 -0
  13. cbpr_validate/model/__init__.py +15 -0
  14. cbpr_validate/model/finding.py +21 -0
  15. cbpr_validate/model/payment.py +89 -0
  16. cbpr_validate/model/validation_result.py +21 -0
  17. cbpr_validate/parsers/__init__.py +0 -0
  18. cbpr_validate/parsers/_common.py +97 -0
  19. cbpr_validate/parsers/detect.py +35 -0
  20. cbpr_validate/parsers/pacs002.py +39 -0
  21. cbpr_validate/parsers/pacs004.py +52 -0
  22. cbpr_validate/parsers/pacs008.py +108 -0
  23. cbpr_validate/parsers/pacs009.py +82 -0
  24. cbpr_validate/parsers/parse.py +50 -0
  25. cbpr_validate/report/__init__.py +0 -0
  26. cbpr_validate/report/json_report.py +57 -0
  27. cbpr_validate/report/junit_report.py +89 -0
  28. cbpr_validate/report/text_report.py +103 -0
  29. cbpr_validate/rules/__init__.py +0 -0
  30. cbpr_validate/rules/address.py +99 -0
  31. cbpr_validate/rules/agents.py +142 -0
  32. cbpr_validate/rules/amounts.py +87 -0
  33. cbpr_validate/rules/codes.py +77 -0
  34. cbpr_validate/rules/registry.py +58 -0
  35. cbpr_validate/rules/returns.py +222 -0
  36. cbpr_validate/rules/structural.py +123 -0
  37. cbpr_validate/schema/__init__.py +0 -0
  38. cbpr_validate/schema/xsd.py +104 -0
  39. cbpr_validate-1.0.0.dist-info/METADATA +209 -0
  40. cbpr_validate-1.0.0.dist-info/RECORD +42 -0
  41. cbpr_validate-1.0.0.dist-info/WHEEL +4 -0
  42. cbpr_validate-1.0.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,18 @@
1
+ """cbpr-validate: validate ISO 20022 CBPR+ messages against the usage guidelines.
2
+
3
+ from cbpr_validate import validate_file
4
+
5
+ result = validate_file("payment.xml")
6
+ result.is_compliant, result.errors, result.warnings
7
+ """
8
+
9
+ from cbpr_validate.core import validate_bytes, validate_file, validate_string
10
+
11
+ __version__ = "1.0.0"
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "validate_bytes",
16
+ "validate_file",
17
+ "validate_string",
18
+ ]
File without changes
@@ -0,0 +1,120 @@
1
+ """FastAPI service: ``POST /validate`` and ``POST /correlate``.
2
+
3
+ Like the CLI, this is a shell over the library. Both endpoints build their
4
+ response from the same ``report.json_report`` envelopes the CLI's ``--format
5
+ json`` prints, so the two interfaces cannot disagree about a message. The
6
+ response models below exist to give the OpenAPI document real schemas rather
7
+ than a bare ``object`` - their fields mirror those envelopes exactly, and a test
8
+ pins that correspondence.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from fastapi import FastAPI, HTTPException
14
+ from pydantic import BaseModel, Field
15
+
16
+ from cbpr_validate import __version__
17
+ from cbpr_validate.match.matcher import UnsupportedPairError, correlate
18
+ from cbpr_validate.match.result import Direction, MatchKey, MessageRef, Scenario
19
+ from cbpr_validate.model.finding import Finding
20
+ from cbpr_validate.model.payment import Payment
21
+ from cbpr_validate.parsers.parse import UnsupportedMessageTypeError, parse_message
22
+ from cbpr_validate.report.json_report import match_to_dict, validation_to_dict
23
+ from cbpr_validate.rules.registry import run_all
24
+
25
+ app = FastAPI(
26
+ title="cbpr-validate",
27
+ version=__version__,
28
+ summary="Validate ISO 20022 CBPR+ messages against the usage guidelines, not just the XSD.",
29
+ description=(
30
+ "Two operations. `/validate` runs every registered usage-guideline rule "
31
+ "against a single message. `/correlate` checks whether two specific "
32
+ "messages correctly reference each other (pairwise and stateless - no "
33
+ "message store).\n\n"
34
+ "**XSD-valid is not CBPR+-compliant.** The optional XSD layer is CLI-only: "
35
+ "it needs schemas this project does not redistribute."
36
+ ),
37
+ )
38
+
39
+
40
+ class ValidateRequest(BaseModel):
41
+ message: str = Field(
42
+ ...,
43
+ description="The ISO 20022 message as XML (pacs.008, pacs.009, pacs.002 or pacs.004).",
44
+ )
45
+
46
+
47
+ class CorrelateRequest(BaseModel):
48
+ message_a: str = Field(..., description="First message, as XML.")
49
+ message_b: str = Field(..., description="Second message, as XML. Order does not matter.")
50
+ direction: Direction | None = Field(
51
+ None,
52
+ description=(
53
+ "Required for a pacs.002 <-> pacs.008 pair: whether the caller sent the "
54
+ "pacs.008 (outbound) or received it (inbound). Never inferred from BICs."
55
+ ),
56
+ )
57
+
58
+
59
+ class ValidationResponse(BaseModel):
60
+ """Mirrors ``report.json_report.validation_to_dict``."""
61
+
62
+ is_compliant: bool
63
+ summary: dict[str, int]
64
+ findings: list[Finding]
65
+
66
+
67
+ class MatchResponse(BaseModel):
68
+ """Mirrors ``report.json_report.match_to_dict``."""
69
+
70
+ matched: bool
71
+ is_consistent: bool
72
+ scenario: Scenario
73
+ match_key: MatchKey
74
+ uetr: str | None
75
+ direction: Direction | None
76
+ fallback_used: bool
77
+ linked_fields: list[str]
78
+ message_a: MessageRef
79
+ message_b: MessageRef
80
+ summary: dict[str, int]
81
+ mismatches: list[Finding]
82
+
83
+
84
+ class HealthResponse(BaseModel):
85
+ status: str
86
+ version: str
87
+
88
+
89
+ def _parse(xml: str, label: str) -> Payment:
90
+ try:
91
+ return parse_message(xml.encode())
92
+ except UnsupportedMessageTypeError as exc:
93
+ raise HTTPException(status_code=400, detail=f"{label}: {exc}") from exc
94
+
95
+
96
+ @app.get("/health", response_model=HealthResponse, tags=["meta"])
97
+ def health() -> HealthResponse:
98
+ """Liveness probe."""
99
+ return HealthResponse(status="ok", version=__version__)
100
+
101
+
102
+ @app.post("/validate", response_model=ValidationResponse, tags=["validate"])
103
+ def validate(request: ValidateRequest) -> ValidationResponse:
104
+ """Run every registered CBPR+ usage-guideline rule against one message."""
105
+ payment = _parse(request.message, "message")
106
+ return ValidationResponse(**validation_to_dict(run_all(payment)))
107
+
108
+
109
+ @app.post("/correlate", response_model=MatchResponse, tags=["correlate"])
110
+ def correlate_messages(request: CorrelateRequest) -> MatchResponse:
111
+ """Check whether two specific messages correctly reference each other."""
112
+ a = _parse(request.message_a, "message_a")
113
+ b = _parse(request.message_b, "message_b")
114
+ try:
115
+ result = correlate(a, b, request.direction)
116
+ except UnsupportedPairError as exc:
117
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
118
+ except ValueError as exc: # missing direction for the pacs.002 scenario
119
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
120
+ return MatchResponse(**match_to_dict(result))
cbpr_validate/cli.py ADDED
@@ -0,0 +1,247 @@
1
+ """Typer CLI: ``cbpr-validate check`` and ``cbpr-validate match``.
2
+
3
+ The CLI is a thin shell. Parsing, rule execution, correlation and formatting all
4
+ live in the library, so the CLI and the API cannot drift apart in what they
5
+ report - only in how they are invoked.
6
+
7
+ Exit codes (stable, so this is usable as a CI gate):
8
+
9
+ * ``0`` - clean, or findings below the ``--fail-on`` threshold
10
+ * ``1`` - findings at or above the threshold / the two messages do not correlate
11
+ * ``2`` - the input could not be used (unreadable, unparseable, no XSD, bad pair)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from enum import StrEnum
18
+ from pathlib import Path
19
+ from typing import Annotated
20
+
21
+ import typer
22
+
23
+ from cbpr_validate.core import validate_file as validate_message_file
24
+ from cbpr_validate.match.matcher import UnsupportedPairError, correlate
25
+ from cbpr_validate.match.result import Direction
26
+ from cbpr_validate.model.payment import Payment
27
+ from cbpr_validate.model.validation_result import ValidationResult
28
+ from cbpr_validate.parsers.detect import detect_message_type
29
+ from cbpr_validate.parsers.parse import UnsupportedMessageTypeError, parse_message
30
+ from cbpr_validate.report.json_report import match_to_json, validation_to_json
31
+ from cbpr_validate.report.junit_report import match_to_junit, validation_to_junit
32
+ from cbpr_validate.report.text_report import match_to_text, validation_to_text
33
+ from cbpr_validate.rules.registry import run_all
34
+ from cbpr_validate.schema.xsd import XsdUnavailableError, validate_against_xsd
35
+
36
+ app = typer.Typer(
37
+ help="cbpr-validate: ISO 20022 CBPR+ usage-guideline validator",
38
+ no_args_is_help=True,
39
+ )
40
+
41
+ EXIT_OK = 0
42
+ EXIT_FINDINGS = 1
43
+ EXIT_INPUT_ERROR = 2
44
+
45
+
46
+ class OutputFormat(StrEnum):
47
+ TEXT = "text"
48
+ JSON = "json"
49
+ JUNIT = "junit"
50
+
51
+
52
+ class FailOn(StrEnum):
53
+ ERROR = "error"
54
+ WARN = "warn"
55
+ NEVER = "never"
56
+
57
+
58
+ class FailOnLevel(StrEnum):
59
+ """Threshold for `validate`. Narrower on purpose: a gate wants a floor, not
60
+ an off switch, so there is no "never" here."""
61
+
62
+ ERROR = "error"
63
+ WARNING = "warning"
64
+
65
+
66
+ MessageArgument = Annotated[
67
+ Path,
68
+ typer.Argument(
69
+ exists=True,
70
+ dir_okay=False,
71
+ readable=True,
72
+ help="Path to an ISO 20022 message (XML).",
73
+ ),
74
+ ]
75
+ FormatOption = Annotated[
76
+ OutputFormat, typer.Option("--format", "-f", help="Output format.")
77
+ ]
78
+
79
+
80
+ def _read(path: Path) -> bytes:
81
+ try:
82
+ return path.read_bytes()
83
+ except OSError as exc: # pragma: no cover - argument callback catches most cases
84
+ typer.echo(f"error: could not read {path}: {exc}", err=True)
85
+ raise typer.Exit(EXIT_INPUT_ERROR) from exc
86
+
87
+
88
+ def _parse(path: Path) -> Payment:
89
+ try:
90
+ return parse_message(_read(path))
91
+ except UnsupportedMessageTypeError as exc:
92
+ typer.echo(f"error: {path}: {exc}", err=True)
93
+ raise typer.Exit(EXIT_INPUT_ERROR) from exc
94
+
95
+
96
+ def _should_fail(result: ValidationResult, fail_on: FailOn) -> bool:
97
+ if fail_on is FailOn.NEVER:
98
+ return False
99
+ if fail_on is FailOn.WARN:
100
+ return bool(result.errors or result.warnings)
101
+ return bool(result.errors)
102
+
103
+
104
+ @app.command()
105
+ def validate(
106
+ message: MessageArgument,
107
+ as_json: Annotated[
108
+ bool, typer.Option("--json", help="Emit the result as JSON instead of text.")
109
+ ] = False,
110
+ fail_on: Annotated[
111
+ FailOnLevel,
112
+ typer.Option("--fail-on", help="Lowest severity that makes this command exit 1."),
113
+ ] = FailOnLevel.ERROR,
114
+ ) -> None:
115
+ """Validate a message against the CBPR+ usage guidelines.
116
+
117
+ Goes through ``cbpr_validate.core.validate_file``, so an unreadable,
118
+ malformed or unrecognised document is reported as an ``ORCH-*`` finding and
119
+ still exits 1 - never a traceback.
120
+ """
121
+ result = validate_message_file(message)
122
+
123
+ if as_json:
124
+ typer.echo(validation_to_json(result))
125
+ else:
126
+ for finding in result.findings:
127
+ line = f"{finding.rule_id} | {finding.severity.value} | {finding.message}"
128
+ if finding.location:
129
+ line += f" | {finding.location}"
130
+ typer.echo(line)
131
+ errors = len(result.errors)
132
+ warnings = len(result.warnings)
133
+ infos = len(result.findings) - errors - warnings
134
+ verdict = "COMPLIANT" if result.is_compliant else "NOT COMPLIANT"
135
+ typer.echo(
136
+ f"{len(result.findings)} finding(s): {errors} error, {warnings} warn, "
137
+ f"{infos} info - {verdict}"
138
+ )
139
+
140
+ tripped = result.errors or (fail_on is FailOnLevel.WARNING and result.warnings)
141
+ raise typer.Exit(EXIT_FINDINGS if tripped else EXIT_OK)
142
+
143
+
144
+ @app.command()
145
+ def check(
146
+ message: MessageArgument,
147
+ output_format: FormatOption = OutputFormat.TEXT,
148
+ xsd: Annotated[
149
+ bool,
150
+ typer.Option(
151
+ "--xsd/--no-xsd",
152
+ help="Also run the optional XSD structural layer (requires your own schemas).",
153
+ ),
154
+ ] = False,
155
+ xsd_dir: Annotated[
156
+ Path | None,
157
+ typer.Option(
158
+ "--xsd-dir",
159
+ help="Directory holding your licensed ISO 20022 schemas. "
160
+ "Defaults to $CBPR_VALIDATE_XSD_DIR.",
161
+ ),
162
+ ] = None,
163
+ fail_on: Annotated[
164
+ FailOn, typer.Option("--fail-on", help="Severity that makes this command exit 1.")
165
+ ] = FailOn.ERROR,
166
+ ) -> None:
167
+ """Validate one message against the CBPR+ usage guidelines."""
168
+ raw = _read(message)
169
+ payment = _parse(message)
170
+ result = run_all(payment)
171
+
172
+ if xsd:
173
+ try:
174
+ result.findings.extend(
175
+ validate_against_xsd(raw, detect_message_type(raw), xsd_dir=xsd_dir)
176
+ )
177
+ except XsdUnavailableError as exc:
178
+ typer.echo(f"error: {exc}", err=True)
179
+ raise typer.Exit(EXIT_INPUT_ERROR) from exc
180
+
181
+ source = message.name
182
+ if output_format is OutputFormat.JSON:
183
+ typer.echo(validation_to_json(result))
184
+ elif output_format is OutputFormat.JUNIT:
185
+ typer.echo(validation_to_junit(result, source=source))
186
+ else:
187
+ typer.echo(validation_to_text(result, source=source))
188
+
189
+ raise typer.Exit(EXIT_FINDINGS if _should_fail(result, fail_on) else EXIT_OK)
190
+
191
+
192
+ @app.command()
193
+ def match(
194
+ message_a: MessageArgument,
195
+ message_b: MessageArgument,
196
+ direction: Annotated[
197
+ Direction | None,
198
+ typer.Option(
199
+ "--direction",
200
+ help="Required for pacs.002 <-> pacs.008: whether YOU sent the pacs.008 "
201
+ "(outbound) or received it (inbound). Never inferred from BICs.",
202
+ ),
203
+ ] = None,
204
+ output_format: FormatOption = OutputFormat.TEXT,
205
+ ) -> None:
206
+ """Correlate two related messages (COV<->008, 002<->008, 004<->008)."""
207
+ a = _parse(message_a)
208
+ b = _parse(message_b)
209
+
210
+ try:
211
+ result = correlate(a, b, direction)
212
+ except (UnsupportedPairError, ValueError) as exc:
213
+ typer.echo(f"error: {exc}", err=True)
214
+ raise typer.Exit(EXIT_INPUT_ERROR) from exc
215
+
216
+ if output_format is OutputFormat.JSON:
217
+ typer.echo(match_to_json(result))
218
+ elif output_format is OutputFormat.JUNIT:
219
+ typer.echo(match_to_junit(result))
220
+ else:
221
+ typer.echo(match_to_text(result))
222
+
223
+ raise typer.Exit(EXIT_OK if result.is_consistent else EXIT_FINDINGS)
224
+
225
+
226
+ @app.command()
227
+ def version() -> None:
228
+ """Print the installed version."""
229
+ from cbpr_validate import __version__
230
+
231
+ typer.echo(__version__)
232
+
233
+
234
+ def main() -> None:
235
+ # Findings quote message content verbatim, and a real payment can carry
236
+ # characters the console encoding cannot represent (a non-Latin party name,
237
+ # say). Degrade those to a placeholder rather than dying with
238
+ # UnicodeEncodeError halfway through a report.
239
+ for stream in (sys.stdout, sys.stderr):
240
+ reconfigure = getattr(stream, "reconfigure", None)
241
+ if reconfigure is not None:
242
+ reconfigure(errors="replace")
243
+ app()
244
+
245
+
246
+ if __name__ == "__main__": # pragma: no cover - console-script entry point
247
+ main()
File without changes
@@ -0,0 +1,276 @@
1
+ {
2
+ "ExternalPurpose1Code": [
3
+ "CORT",
4
+ "DVPM",
5
+ "SALA",
6
+ "TREA",
7
+ "CASH"
8
+ ],
9
+ "ExternalCategoryPurpose1Code": [
10
+ "CASH",
11
+ "CCRD",
12
+ "CPRC",
13
+ "SALA"
14
+ ],
15
+ "ExternalStatusReason1Code": [
16
+ "ACCP",
17
+ "RJCT",
18
+ "PDNG"
19
+ ],
20
+ "ExternalReturnReason1Code": [
21
+ "CURR",
22
+ "DNCL",
23
+ "CANC"
24
+ ],
25
+ "ISO3166-1-alpha-2": [
26
+ "AD",
27
+ "AE",
28
+ "AF",
29
+ "AG",
30
+ "AI",
31
+ "AL",
32
+ "AM",
33
+ "AO",
34
+ "AQ",
35
+ "AR",
36
+ "AS",
37
+ "AT",
38
+ "AU",
39
+ "AW",
40
+ "AX",
41
+ "AZ",
42
+ "BA",
43
+ "BB",
44
+ "BD",
45
+ "BE",
46
+ "BF",
47
+ "BG",
48
+ "BH",
49
+ "BI",
50
+ "BJ",
51
+ "BL",
52
+ "BM",
53
+ "BN",
54
+ "BO",
55
+ "BQ",
56
+ "BR",
57
+ "BS",
58
+ "BT",
59
+ "BV",
60
+ "BW",
61
+ "BY",
62
+ "BZ",
63
+ "CA",
64
+ "CC",
65
+ "CD",
66
+ "CF",
67
+ "CG",
68
+ "CH",
69
+ "CI",
70
+ "CK",
71
+ "CL",
72
+ "CM",
73
+ "CN",
74
+ "CO",
75
+ "CR",
76
+ "CU",
77
+ "CV",
78
+ "CW",
79
+ "CX",
80
+ "CY",
81
+ "CZ",
82
+ "DE",
83
+ "DJ",
84
+ "DK",
85
+ "DM",
86
+ "DO",
87
+ "DZ",
88
+ "EC",
89
+ "EE",
90
+ "EG",
91
+ "EH",
92
+ "ER",
93
+ "ES",
94
+ "ET",
95
+ "FI",
96
+ "FJ",
97
+ "FK",
98
+ "FM",
99
+ "FO",
100
+ "FR",
101
+ "GA",
102
+ "GB",
103
+ "GD",
104
+ "GE",
105
+ "GF",
106
+ "GG",
107
+ "GH",
108
+ "GI",
109
+ "GL",
110
+ "GM",
111
+ "GN",
112
+ "GP",
113
+ "GQ",
114
+ "GR",
115
+ "GS",
116
+ "GT",
117
+ "GU",
118
+ "GW",
119
+ "GY",
120
+ "HK",
121
+ "HM",
122
+ "HN",
123
+ "HR",
124
+ "HT",
125
+ "HU",
126
+ "ID",
127
+ "IE",
128
+ "IL",
129
+ "IM",
130
+ "IN",
131
+ "IO",
132
+ "IQ",
133
+ "IR",
134
+ "IS",
135
+ "IT",
136
+ "JE",
137
+ "JM",
138
+ "JO",
139
+ "JP",
140
+ "KE",
141
+ "KG",
142
+ "KH",
143
+ "KI",
144
+ "KM",
145
+ "KN",
146
+ "KP",
147
+ "KR",
148
+ "KW",
149
+ "KY",
150
+ "KZ",
151
+ "LA",
152
+ "LB",
153
+ "LC",
154
+ "LI",
155
+ "LK",
156
+ "LR",
157
+ "LS",
158
+ "LT",
159
+ "LU",
160
+ "LV",
161
+ "LY",
162
+ "MA",
163
+ "MC",
164
+ "MD",
165
+ "ME",
166
+ "MF",
167
+ "MG",
168
+ "MH",
169
+ "MK",
170
+ "ML",
171
+ "MM",
172
+ "MN",
173
+ "MO",
174
+ "MP",
175
+ "MQ",
176
+ "MR",
177
+ "MS",
178
+ "MT",
179
+ "MU",
180
+ "MV",
181
+ "MW",
182
+ "MX",
183
+ "MY",
184
+ "MZ",
185
+ "NA",
186
+ "NC",
187
+ "NE",
188
+ "NF",
189
+ "NG",
190
+ "NI",
191
+ "NL",
192
+ "NO",
193
+ "NP",
194
+ "NR",
195
+ "NU",
196
+ "NZ",
197
+ "OM",
198
+ "PA",
199
+ "PE",
200
+ "PF",
201
+ "PG",
202
+ "PH",
203
+ "PK",
204
+ "PL",
205
+ "PM",
206
+ "PN",
207
+ "PR",
208
+ "PS",
209
+ "PT",
210
+ "PW",
211
+ "PY",
212
+ "QA",
213
+ "RE",
214
+ "RO",
215
+ "RS",
216
+ "RU",
217
+ "RW",
218
+ "SA",
219
+ "SB",
220
+ "SC",
221
+ "SD",
222
+ "SE",
223
+ "SG",
224
+ "SH",
225
+ "SI",
226
+ "SJ",
227
+ "SK",
228
+ "SL",
229
+ "SM",
230
+ "SN",
231
+ "SO",
232
+ "SR",
233
+ "SS",
234
+ "ST",
235
+ "SV",
236
+ "SX",
237
+ "SY",
238
+ "SZ",
239
+ "TC",
240
+ "TD",
241
+ "TF",
242
+ "TG",
243
+ "TH",
244
+ "TJ",
245
+ "TK",
246
+ "TL",
247
+ "TM",
248
+ "TN",
249
+ "TO",
250
+ "TR",
251
+ "TT",
252
+ "TV",
253
+ "TW",
254
+ "TZ",
255
+ "UA",
256
+ "UG",
257
+ "UM",
258
+ "US",
259
+ "UY",
260
+ "UZ",
261
+ "VA",
262
+ "VC",
263
+ "VE",
264
+ "VG",
265
+ "VI",
266
+ "VN",
267
+ "VU",
268
+ "WF",
269
+ "WS",
270
+ "YE",
271
+ "YT",
272
+ "ZA",
273
+ "ZM",
274
+ "ZW"
275
+ ]
276
+ }
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ # Revision marker rather than a new date: the ISO 20022 External Code Set
7
+ # vintage is unchanged: only the ISO 3166-1 alpha-2 list was completed, from a
8
+ # 10-country stub to the full 249 officially assigned codes. Dating it 2026-08
9
+ # would claim a code-set release that did not happen.
10
+ SNAPSHOT_VERSION = "iso20022-codesets-2026-06-r2"
11
+ _DATA_PATH = Path(__file__).resolve().parent / "data" / "iso20022_codesets.json"
12
+
13
+
14
+ def _load_snapshot() -> dict[str, set[str]]:
15
+ if not _DATA_PATH.exists():
16
+ return {}
17
+ with _DATA_PATH.open("r", encoding="utf-8") as handle:
18
+ raw = json.load(handle)
19
+ return {name: set(values) for name, values in raw.items()}
20
+
21
+
22
+ _CODES = _load_snapshot()
23
+
24
+
25
+ def get_snapshot_version() -> str:
26
+ return SNAPSHOT_VERSION
27
+
28
+
29
+ def is_valid(set_name: str, code: str) -> bool:
30
+ values = _CODES.get(set_name, set())
31
+ return code in values
32
+
33
+
34
+ def get_codes(set_name: str) -> set[str]:
35
+ return set(_CODES.get(set_name, set()))