fpml-convert 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.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: fpml-convert
3
+ Version: 0.6.0
4
+ Summary: FpML interest-rate-swap XML <-> JSON, standard library only. One projection mapping, also shipped as a JS interpreter and a C++17 converter.
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Homepage, https://danieltuzes.github.io/fpml-convert/
7
+ Project-URL: Source, https://github.com/danieltuzes/fpml-convert
8
+ Keywords: fpml,xml,json,structile,derivatives,swap
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+
12
+ # fpml-convert (Python)
13
+
14
+ Standard-library-only conversion between **FpML interest-rate-swap XML and
15
+ plain JSON**, in both directions. No `lxml`, no third-party JSON — just
16
+ `xml.etree.ElementTree` and `json`.
17
+
18
+ One projection mapping, also implemented as a JavaScript interpreter (for
19
+ the [Structile](https://danieltuzes.github.io/structile/) viewer) and a
20
+ pure-STL C++17 converter; the three agree byte-for-byte on a shared test
21
+ corpus. Downloads, the full version archive, and the mapping's precise
22
+ rules: <https://danieltuzes.github.io/fpml-convert/>.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ pip install fpml-convert
28
+ ```
29
+
30
+ No runtime dependencies. Python 3.8+.
31
+
32
+ ## Use
33
+
34
+ ```python
35
+ from fpml_convert import fpml_to_json, json_to_fpml
36
+
37
+ value = fpml_to_json(xml_text) # -> dict
38
+ xml_text2 = json_to_fpml(value) # -> schema-ordered FpML, round-trips at the value level
39
+ ```
40
+
41
+ ```sh
42
+ # CLI (direction inferred from the input, or forced with --to)
43
+ python -m fpml_convert swap.xml -o swap.json
44
+ python -m fpml_convert swap.json -o swap.xml
45
+ cat swap.xml | python -m fpml_convert - > swap.json
46
+ ```
47
+
48
+ ## The mapping
49
+
50
+ - Element local name → object key; a tag repeated under one parent → an
51
+ array in document order.
52
+ - Namespace prefixes and ElementTree Clark notation (`{uri}local`) are both
53
+ reduced to the local name, so the result is identical across a browser
54
+ `DOMParser` and a server-side parser.
55
+ - Attributes → `@`-prefixed keys, before any child keys, ordered `@id`,
56
+ `@xsi:type`, `@href`, then the rest. `xsi:type` is kept verbatim.
57
+ - A pure reference element (`href`, no children) → the scalar `«id»`;
58
+ Structile renders that as a link to the element whose `@id` matches.
59
+ - Typed lexical forms are preserved as strings, never coerced
60
+ (`1000000.00` stays exactly that).
61
+ - Every element the projection descends into is checked against a
62
+ supported-subset allowlist — an unknown element raises
63
+ `FpmlUnsupportedError` rather than projecting partially.
64
+ - Serialization re-emits children in schema (`xs:sequence`) order.
65
+
66
+ ## Scope
67
+
68
+ FpML 5.10 confirmation view, interest-rate **swap** `dataDocument` only. A
69
+ non-IR-swap document raises `ValueError`. FRA and other products widen the
70
+ allowlist in later releases. An FpML-AWG interchange-shape mode is planned.
71
+
72
+ ## License
73
+
74
+ Apache-2.0. The FpML example files used for testing are redistributed
75
+ under the [FpML Public License](https://www.fpml.org/the_standard/fpml-public-license/).
@@ -0,0 +1,64 @@
1
+ # fpml-convert (Python)
2
+
3
+ Standard-library-only conversion between **FpML interest-rate-swap XML and
4
+ plain JSON**, in both directions. No `lxml`, no third-party JSON — just
5
+ `xml.etree.ElementTree` and `json`.
6
+
7
+ One projection mapping, also implemented as a JavaScript interpreter (for
8
+ the [Structile](https://danieltuzes.github.io/structile/) viewer) and a
9
+ pure-STL C++17 converter; the three agree byte-for-byte on a shared test
10
+ corpus. Downloads, the full version archive, and the mapping's precise
11
+ rules: <https://danieltuzes.github.io/fpml-convert/>.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pip install fpml-convert
17
+ ```
18
+
19
+ No runtime dependencies. Python 3.8+.
20
+
21
+ ## Use
22
+
23
+ ```python
24
+ from fpml_convert import fpml_to_json, json_to_fpml
25
+
26
+ value = fpml_to_json(xml_text) # -> dict
27
+ xml_text2 = json_to_fpml(value) # -> schema-ordered FpML, round-trips at the value level
28
+ ```
29
+
30
+ ```sh
31
+ # CLI (direction inferred from the input, or forced with --to)
32
+ python -m fpml_convert swap.xml -o swap.json
33
+ python -m fpml_convert swap.json -o swap.xml
34
+ cat swap.xml | python -m fpml_convert - > swap.json
35
+ ```
36
+
37
+ ## The mapping
38
+
39
+ - Element local name → object key; a tag repeated under one parent → an
40
+ array in document order.
41
+ - Namespace prefixes and ElementTree Clark notation (`{uri}local`) are both
42
+ reduced to the local name, so the result is identical across a browser
43
+ `DOMParser` and a server-side parser.
44
+ - Attributes → `@`-prefixed keys, before any child keys, ordered `@id`,
45
+ `@xsi:type`, `@href`, then the rest. `xsi:type` is kept verbatim.
46
+ - A pure reference element (`href`, no children) → the scalar `«id»`;
47
+ Structile renders that as a link to the element whose `@id` matches.
48
+ - Typed lexical forms are preserved as strings, never coerced
49
+ (`1000000.00` stays exactly that).
50
+ - Every element the projection descends into is checked against a
51
+ supported-subset allowlist — an unknown element raises
52
+ `FpmlUnsupportedError` rather than projecting partially.
53
+ - Serialization re-emits children in schema (`xs:sequence`) order.
54
+
55
+ ## Scope
56
+
57
+ FpML 5.10 confirmation view, interest-rate **swap** `dataDocument` only. A
58
+ non-IR-swap document raises `ValueError`. FRA and other products widen the
59
+ allowlist in later releases. An FpML-AWG interchange-shape mode is planned.
60
+
61
+ ## License
62
+
63
+ Apache-2.0. The FpML example files used for testing are redistributed
64
+ under the [FpML Public License](https://www.fpml.org/the_standard/fpml-public-license/).
@@ -0,0 +1,40 @@
1
+ """fpml_convert — FpML interest-rate-swap XML <-> JSON, standard library only.
2
+
3
+ The Python sibling of ``structile-fpml``'s JavaScript interpreter bundle.
4
+ Same projection mapping (the ``@``-prefixed, ``«id»``-reference,
5
+ local-name-reduced one that round-trips without the schema), same
6
+ supported-element subset and ``xs:sequence`` child-order binding — both
7
+ read from the shared ``src/rules.json``.
8
+
9
+ >>> from fpml_convert import fpml_to_json, json_to_fpml
10
+ >>> value = fpml_to_json(xml_text) # -> dict, == the JS bundle's output
11
+ >>> xml_text2 = json_to_fpml(value) # -> schema-ordered FpML
12
+
13
+ A ``--mapping fpml-awg`` interchange mode (the ISDA FpML AWG "Guidelines on
14
+ FpML to JSON Conversion" shape) is planned; this module ships only the
15
+ structile projection for now.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from ._project import (
20
+ FpmlUnsupportedError,
21
+ LINK_CLOSE,
22
+ LINK_ID_FIELD,
23
+ LINK_OPEN,
24
+ fpml_to_json,
25
+ )
26
+ from ._serialize import json_to_fpml
27
+
28
+ #: FpML schema version this converter targets. Mirrors the JS bundle's
29
+ #: ``INTERPRETER_VERSION`` / ``package.json`` ``fpmlVersion``.
30
+ FPML_VERSION = "5.10"
31
+
32
+ __all__ = [
33
+ "fpml_to_json",
34
+ "json_to_fpml",
35
+ "FpmlUnsupportedError",
36
+ "FPML_VERSION",
37
+ "LINK_OPEN",
38
+ "LINK_CLOSE",
39
+ "LINK_ID_FIELD",
40
+ ]
@@ -0,0 +1,49 @@
1
+ """``python -m fpml_convert IN [-o OUT]`` — convert an FpML IR-swap
2
+ ``dataDocument`` between XML and JSON, direction inferred from the input
3
+ (``<`` first non-space char -> XML -> JSON, else JSON -> XML) or forced
4
+ with ``--to xml|json``.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+
12
+ from . import FPML_VERSION, fpml_to_json, json_to_fpml
13
+
14
+
15
+ def _looks_like_xml(text: str) -> bool:
16
+ stripped = text.lstrip()
17
+ return stripped.startswith("<")
18
+
19
+
20
+ def main(argv=None) -> int:
21
+ p = argparse.ArgumentParser(
22
+ prog="python -m fpml_convert",
23
+ description="FpML interest-rate-swap XML <-> JSON (structile projection).",
24
+ )
25
+ p.add_argument("input", help="path to an FpML .xml or a projected .json file, or - for stdin")
26
+ p.add_argument("-o", "--output", help="write here instead of stdout")
27
+ p.add_argument("--to", choices=("xml", "json"), help="force the output direction")
28
+ p.add_argument("--indent", type=int, default=2, help="JSON indent (default 2)")
29
+ p.add_argument("--version", action="version", version=f"fpml_convert (FpML {FPML_VERSION})")
30
+ args = p.parse_args(argv)
31
+
32
+ text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
33
+
34
+ to = args.to or ("json" if _looks_like_xml(text) else "xml")
35
+ if to == "json":
36
+ out_text = json.dumps(fpml_to_json(text), indent=args.indent, ensure_ascii=False) + "\n"
37
+ else:
38
+ out_text = json_to_fpml(json.loads(text))
39
+
40
+ if args.output:
41
+ with open(args.output, "w", encoding="utf-8") as fh:
42
+ fh.write(out_text)
43
+ else:
44
+ sys.stdout.write(out_text)
45
+ return 0
46
+
47
+
48
+ if __name__ == "__main__":
49
+ sys.exit(main())
@@ -0,0 +1,225 @@
1
+ """FpML XML -> plain value projection — a faithful port of the JS bundle's
2
+ ``src/20-ns.js`` + ``src/30-project.js`` + ``src/40-support.js`` +
3
+ ``src/50-swap.js``. The rule tables come from the shared ``src/rules.json``
4
+ (see :mod:`fpml_convert._rules`); only this traversal logic is duplicated
5
+ between the two implementations, and the committed JS snapshots
6
+ (``test/__snapshots__/*.json``) are the cross-language contract.
7
+
8
+ Projection rules (identical to the JS interpreter):
9
+
10
+ * element local name -> key; a repeated tag under one parent -> a list,
11
+ in document order.
12
+ * attributes -> ``@``-prefixed keys: ``@id`` first, then ``@xsi:type``,
13
+ then ``@href``, then any other plain attribute, all BEFORE the child
14
+ keys. ``xmlns*`` and other ``xsi:*`` attributes are dropped.
15
+ * a pure reference element (``href``, nothing else) -> the link scalar
16
+ ``«href»``. One that also carries content stays a dict but still gets
17
+ ``@href = «href»``.
18
+ * ``xsi:type`` -> ``@xsi:type`` verbatim. Typed lexical forms are NEVER
19
+ coerced — every leaf value stays the exact source string.
20
+ * every element descended into is checked against ``FPML_SUPPORTED``
21
+ first; an unknown element is a loud error, not a best-effort guess.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import xml.etree.ElementTree as ET
26
+
27
+ from ._rules import FPML_SUPPORTED
28
+
29
+ LINK_OPEN = "«" # «
30
+ LINK_CLOSE = "»" # »
31
+ LINK_ID_FIELD = "@id"
32
+
33
+ _XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
34
+
35
+
36
+ class FpmlUnsupportedError(Exception):
37
+ """Raised for an element outside the supported IR-swap subset. A
38
+ structural "I don't recognise this" — distinct from the soft rule
39
+ violations Structile's ``__structileDiagnostic`` is for."""
40
+
41
+
42
+ def fpml_local_name(name) -> str:
43
+ """``{uri}local`` -> ``local``; ``prefix:local`` -> ``local``;
44
+ ``local`` -> ``local``. Reduces both DOM providers' spellings."""
45
+ t = "" if name is None else str(name)
46
+ brace = t.rfind("}")
47
+ if brace != -1:
48
+ return t[brace + 1:]
49
+ colon = t.rfind(":")
50
+ return t[colon + 1:] if colon != -1 else t
51
+
52
+
53
+ def fpml_element_local_name(el) -> str:
54
+ return fpml_local_name(getattr(el, "tag", None))
55
+
56
+
57
+ def _attr_items(el):
58
+ """``el.attrib`` items in source order (ElementTree preserves it since
59
+ Python 3.8)."""
60
+ return list(el.attrib.items())
61
+
62
+
63
+ def fpml_xsi_type(el):
64
+ for raw, val in _attr_items(el):
65
+ if raw == "xsi:type":
66
+ return val
67
+ if raw.startswith("{") and "}" in raw:
68
+ uri = raw[1:raw.index("}")]
69
+ if uri == _XSI_NS and fpml_local_name(raw) == "type":
70
+ return val
71
+ return None
72
+
73
+
74
+ def fpml_plain_attributes(el):
75
+ """Non-namespaced "business" attributes as an ordered ``dict``, with the
76
+ structural ones removed: ``xmlns*``, ``xsi:*`` (``xsi:type`` / ``xsi:nil``
77
+ are surfaced separately), and ``id`` / ``href`` (projected explicitly as
78
+ ``@id`` / ``@href``)."""
79
+ out = {}
80
+ for raw, val in _attr_items(el):
81
+ if raw == "xmlns" or raw.startswith("xmlns:"):
82
+ continue
83
+ if raw in ("id", "href"):
84
+ continue
85
+ if raw.startswith("{"):
86
+ uri = raw[1:raw.index("}")]
87
+ if uri == _XSI_NS:
88
+ continue
89
+ elif raw.startswith("xsi:"):
90
+ continue
91
+ out[fpml_local_name(raw)] = val
92
+ return out
93
+
94
+
95
+ def fpml_is_xsi_nil(el) -> bool:
96
+ for raw, val in _attr_items(el):
97
+ is_nil_attr = raw == "xsi:nil" or (
98
+ raw.startswith("{") and "}" in raw
99
+ and fpml_local_name(raw) == "nil" and raw[1:raw.index("}")] == _XSI_NS
100
+ )
101
+ if is_nil_attr:
102
+ return str(val).strip() == "true"
103
+ return False
104
+
105
+
106
+ def fpml_own_text(el) -> str:
107
+ """A leaf element's own trimmed text, or ``""``. Only ever called on a
108
+ childless element, so ``el.text`` IS its whole text."""
109
+ return (el.text or "").strip()
110
+
111
+
112
+ def _wrap_ref(id_: str) -> str:
113
+ return LINK_OPEN + id_ + LINK_CLOSE
114
+
115
+
116
+ def fpml_assert_supported(el) -> None:
117
+ name = fpml_element_local_name(el)
118
+ if name not in FPML_SUPPORTED:
119
+ raise FpmlUnsupportedError(
120
+ "fpml converter (read-only IR-swap subset): element <" + name + "> "
121
+ "is outside the supported subset. This is by design — the subset "
122
+ "is widened only as real FpML example files are added to the test "
123
+ "corpus."
124
+ )
125
+
126
+
127
+ def fpml_project_element(el):
128
+ fpml_assert_supported(el)
129
+
130
+ id_ = el.get("id")
131
+ href = el.get("href")
132
+ xsi_type = fpml_xsi_type(el)
133
+ is_nil = fpml_is_xsi_nil(el)
134
+ plain = fpml_plain_attributes(el)
135
+ kids = list(el)
136
+ has_plain = len(plain) > 0
137
+
138
+ # Pure reference: href, nothing else of substance.
139
+ if href is not None and not kids and not has_plain and xsi_type is None and fpml_own_text(el) == "":
140
+ return _wrap_ref(href)
141
+
142
+ # Childless: a scalar (or a small dict when it has attributes to keep).
143
+ if not kids:
144
+ text = fpml_own_text(el)
145
+ bare = None if is_nil else (None if text == "" else text)
146
+ if id_ is None and href is None and xsi_type is None and not has_plain:
147
+ return bare
148
+ leaf = {}
149
+ if id_ is not None:
150
+ leaf["@id"] = str(id_)
151
+ if xsi_type is not None:
152
+ leaf["@xsi:type"] = str(xsi_type)
153
+ if href is not None:
154
+ leaf["@href"] = _wrap_ref(href)
155
+ for k, v in plain.items():
156
+ leaf["@" + k] = v
157
+ if is_nil:
158
+ leaf["@xsi:nil"] = "true"
159
+ if text != "":
160
+ leaf["#text"] = text
161
+ return leaf
162
+
163
+ # Container: attributes first (fixed order), then grouped children.
164
+ out = {}
165
+ if id_ is not None:
166
+ out["@id"] = str(id_)
167
+ if xsi_type is not None:
168
+ out["@xsi:type"] = str(xsi_type)
169
+ if href is not None:
170
+ out["@href"] = _wrap_ref(href)
171
+ for k, v in plain.items():
172
+ out["@" + k] = v
173
+ if is_nil:
174
+ out["@xsi:nil"] = "true"
175
+
176
+ order = []
177
+ groups = {}
178
+ for kid in kids:
179
+ key = fpml_element_local_name(kid)
180
+ if key not in groups:
181
+ groups[key] = []
182
+ order.append(key)
183
+ groups[key].append(kid)
184
+ for key in order:
185
+ g = groups[key]
186
+ out[key] = fpml_project_element(g[0]) if len(g) == 1 else [fpml_project_element(x) for x in g]
187
+ return out
188
+
189
+
190
+ def _has_descendant(root, local_name: str) -> bool:
191
+ return any(fpml_element_local_name(e) == local_name for e in root.iter())
192
+
193
+
194
+ def fpml_assert_is_ir_swap_data_document(root) -> None:
195
+ """Root gate — the throw that tells interpreter selection FpML apart
196
+ from every other candidate. Rejects anything that isn't an FpML
197
+ interest-rate-swap ``<dataDocument>``."""
198
+ if root is None or fpml_element_local_name(root) != "dataDocument":
199
+ got = fpml_element_local_name(root) if root is not None else "?"
200
+ raise ValueError(
201
+ "fpml converter: expected a root <dataDocument> element, got <" + got + "> "
202
+ "— this doesn't look like an FpML dataDocument."
203
+ )
204
+ ver = root.get("fpmlVersion")
205
+ if ver is not None and not str(ver).startswith("5-"):
206
+ raise ValueError(
207
+ 'fpml converter: fpmlVersion="' + str(ver) + '" is not an FpML 5.x document.'
208
+ )
209
+ if not _has_descendant(root, "swap"):
210
+ raise ValueError(
211
+ "fpml converter: this <dataDocument> contains no <swap> — only the "
212
+ "interest-rate-swap product family is supported in this read-only build."
213
+ )
214
+
215
+
216
+ def fpml_to_json(xml_text: str):
217
+ """FpML interest-rate-swap XML -> the nested value Structile renders.
218
+
219
+ Raises :class:`ValueError` if the document isn't an FpML IR-swap
220
+ ``dataDocument``, or :class:`FpmlUnsupportedError` for an element
221
+ outside the supported subset.
222
+ """
223
+ root = ET.fromstring(xml_text)
224
+ fpml_assert_is_ir_swap_data_document(root)
225
+ return fpml_project_element(root)
@@ -0,0 +1,40 @@
1
+ """Loads the language-neutral projection rule tables from ``src/rules.json``
2
+ — the SAME file ``scripts/build.mjs`` injects into the JS bundle as
3
+ ``const FPML_RULES``. One source of truth for the supported-element set and
4
+ the ``xs:sequence`` child-order binding across both implementations.
5
+
6
+ Lookup order:
7
+ 1. ``rules.json`` sitting next to this package (a packaging step copies
8
+ it there for the wheel — see the FpML interpreter plan Phase 8);
9
+ 2. ``../../src/rules.json`` relative to this file (a repo checkout).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ _HERE = Path(__file__).resolve().parent
17
+
18
+
19
+ def _locate() -> Path:
20
+ packaged = _HERE / "rules.json"
21
+ if packaged.is_file():
22
+ return packaged
23
+ repo_copy = _HERE.parents[1] / "src" / "rules.json"
24
+ if repo_copy.is_file():
25
+ return repo_copy
26
+ raise FileNotFoundError(
27
+ "fpml_convert: rules.json not found next to the package or at "
28
+ f"{repo_copy} — a repo checkout or a packaged copy is required"
29
+ )
30
+
31
+
32
+ _RAW = json.loads(_locate().read_text(encoding="utf-8"))
33
+
34
+ #: ``set`` of every element local-name the projection is allowed to descend
35
+ #: into; anything else is a :class:`~fpml_convert.FpmlUnsupportedError`.
36
+ FPML_SUPPORTED: frozenset = frozenset(_RAW["supported"])
37
+
38
+ #: ``{parent_local_name: [child_local_name, ...]}`` — serialize re-emits a
39
+ #: dict's children in this order, not the (user-editable) key order.
40
+ FPML_CHILD_ORDER: dict = dict(_RAW["childOrder"])
@@ -0,0 +1,124 @@
1
+ """Value -> FpML XML — the inverse of :func:`fpml_convert._project.fpml_to_json`,
2
+ a faithful port of the JS bundle's ``src/70-serialize.js`` (which uses the
3
+ shared ``FPML_CHILD_ORDER`` from ``src/rules.json``). Round-trips at the
4
+ VALUE level: ``fpml_to_json(json_to_fpml(fpml_to_json(x))) == fpml_to_json(x)``.
5
+
6
+ Policies, matching the JS side exactly:
7
+
8
+ * typed lexical forms are written back verbatim (no coercion — ``1000000``
9
+ vs ``1000000.00`` survives);
10
+ * the confirmation-view default namespace + ``xsi:`` are re-declared on the
11
+ root; ``xsi:schemaLocation`` is not reproduced;
12
+ * children are re-emitted in ``FPML_CHILD_ORDER`` order, so a value whose
13
+ keys were reordered still serialises as schema-ordered FpML.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from ._project import LINK_CLOSE, LINK_OPEN
18
+ from ._rules import FPML_CHILD_ORDER
19
+
20
+ _FPML_CONFIRMATION_NS = "http://www.fpml.org/FpML-5/confirmation"
21
+ _FPML_XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
22
+
23
+
24
+ def _esc_attr(s) -> str:
25
+ return (str(s).replace("&", "&amp;").replace("<", "&lt;")
26
+ .replace(">", "&gt;").replace('"', "&quot;"))
27
+
28
+
29
+ def _esc_text(s) -> str:
30
+ return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
31
+
32
+
33
+ def _is_ref(v) -> bool:
34
+ return isinstance(v, str) and v.startswith(LINK_OPEN) and v.endswith(LINK_CLOSE)
35
+
36
+
37
+ def _unwrap_ref(v: str) -> str:
38
+ return v[len(LINK_OPEN):len(v) - len(LINK_CLOSE)]
39
+
40
+
41
+ def _order_child_pairs(parent_local_name, pairs):
42
+ order = FPML_CHILD_ORDER.get(parent_local_name)
43
+ if not order:
44
+ return pairs
45
+ rank = {name: i for i, name in enumerate(order)}
46
+ decorated = [
47
+ (rank[p[0]] if p[0] in rank else len(order) + i, i, p)
48
+ for i, p in enumerate(pairs)
49
+ ]
50
+ decorated.sort(key=lambda t: (t[0], t[1]))
51
+ return [t[2] for t in decorated]
52
+
53
+
54
+ def _serialize_value(key, val, indent, out):
55
+ if isinstance(val, list):
56
+ for item in val:
57
+ _serialize_value(key, item, indent, out)
58
+ return
59
+ if val is None:
60
+ out.append(indent + "<" + key + "/>")
61
+ return
62
+ if not isinstance(val, dict):
63
+ if _is_ref(val):
64
+ out.append(indent + "<" + key + ' href="' + _esc_attr(_unwrap_ref(val)) + '"/>')
65
+ else:
66
+ out.append(indent + "<" + key + ">" + _esc_text(val) + "</" + key + ">")
67
+ return
68
+
69
+ attrs = []
70
+ nil = False
71
+ text = None
72
+ child_pairs = []
73
+ for k in val:
74
+ if k == "@xsi:nil":
75
+ nil = str(val[k]) == "true"
76
+ elif k == "@id":
77
+ attrs.append(("id", val[k]))
78
+ elif k == "@xsi:type":
79
+ attrs.append(("xsi:type", val[k]))
80
+ elif k == "@href":
81
+ attrs.append(("href", _unwrap_ref(val[k]) if _is_ref(val[k]) else val[k]))
82
+ elif k.startswith("@"):
83
+ attrs.append((k[1:], val[k]))
84
+ elif k == "#text":
85
+ text = val[k]
86
+ else:
87
+ child_pairs.append((k, val[k]))
88
+
89
+ attr_str = "".join(' %s="%s"' % (name, _esc_attr(v)) for name, v in attrs)
90
+ if nil:
91
+ attr_str += ' xsi:nil="true"'
92
+ open_tag = "<" + key + attr_str
93
+
94
+ if nil or (not child_pairs and text is None):
95
+ out.append(indent + open_tag + "/>")
96
+ return
97
+ if not child_pairs:
98
+ out.append(indent + open_tag + ">" + _esc_text(text) + "</" + key + ">")
99
+ return
100
+
101
+ out.append(indent + open_tag + ">")
102
+ ci = indent + " "
103
+ if text is not None:
104
+ out.append(ci + _esc_text(text)) # mixed content (not seen in the subset)
105
+ for cp in _order_child_pairs(key, child_pairs):
106
+ _serialize_value(cp[0], cp[1], ci, out)
107
+ out.append(indent + "</" + key + ">")
108
+
109
+
110
+ def json_to_fpml(value) -> str:
111
+ """The projected ``dataDocument`` value -> FpML text."""
112
+ if not isinstance(value, dict):
113
+ raise ValueError("fpml json_to_fpml: expected the projected <dataDocument> object")
114
+ ver = value["@fpmlVersion"] if isinstance(value.get("@fpmlVersion"), str) else "5-10"
115
+ out = [
116
+ '<?xml version="1.0" encoding="UTF-8"?>',
117
+ '<dataDocument xmlns="%s" xmlns:xsi="%s" fpmlVersion="%s">'
118
+ % (_FPML_CONFIRMATION_NS, _FPML_XSI_NS, _esc_attr(ver)),
119
+ ]
120
+ child_pairs = [(k, value[k]) for k in value if not k.startswith("@")]
121
+ for cp in _order_child_pairs("dataDocument", child_pairs):
122
+ _serialize_value(cp[0], cp[1], " ", out)
123
+ out.append("</dataDocument>")
124
+ return "\n".join(out) + "\n"
@@ -0,0 +1,97 @@
1
+ {
2
+ "_comment": "Language-neutral FpML projection rule tables, the single source of truth shared by the JS bundle (injected by scripts/build.mjs as `const FPML_RULES`, consumed by src/40-support.js and src/60-binding.js) and the Python converter (python/fpml_convert/_rules.py). Widen the same way as before: against a real corpus file or the FpML 5.10 XSD, never speculatively. See src/40-support.js and src/60-binding.js headers for the rationale behind each table.",
3
+
4
+ "supported": [
5
+ "dataDocument", "trade", "tradeHeader", "partyTradeIdentifier",
6
+ "partyReference", "tradeId", "tradeDate", "party", "partyId", "partyName",
7
+ "calculationAgent", "calculationAgentPartyReference",
8
+
9
+ "swap", "swapStream",
10
+ "payerPartyReference", "receiverPartyReference",
11
+
12
+ "calculationPeriodDates", "effectiveDate", "terminationDate",
13
+ "unadjustedDate", "adjustedStartDate", "adjustedEndDate",
14
+ "dateAdjustments", "businessDayConvention", "businessCenters",
15
+ "businessCenter", "businessCentersReference",
16
+ "calculationPeriodDatesAdjustments", "calculationPeriodFrequency",
17
+ "periodMultiplier", "period", "rollConvention",
18
+ "firstPeriodStartDate", "firstRegularPeriodStartDate",
19
+ "lastRegularPeriodEndDate", "firstPaymentDate",
20
+ "stubCalculationPeriodAmount", "initialStub", "finalStub", "stubRate",
21
+
22
+ "paymentDates", "calculationPeriodDatesReference", "paymentFrequency",
23
+ "payRelativeTo", "paymentDatesAdjustments", "paymentDaysOffset",
24
+ "adjustedPaymentDate", "paymentDate",
25
+
26
+ "resetDates", "resetRelativeTo", "fixingDates", "dayType", "dateRelativeTo",
27
+ "resetFrequency", "resetDatesAdjustments", "adjustedFixingDate",
28
+
29
+ "calculationPeriodAmount", "calculation", "compoundingMethod",
30
+ "notionalSchedule", "notionalStepSchedule", "notionalAmount",
31
+ "initialValue", "currency", "step", "stepDate", "stepValue",
32
+ "fixedRateSchedule", "fixedRate", "spreadSchedule",
33
+ "floatingRateCalculation", "floatingRateDefinition", "floatingRate",
34
+ "floatingRateIndex", "indexTenor", "finalRateRounding",
35
+ "roundingDirection", "precision", "rateObservation", "observationWeight",
36
+ "dayCountFraction",
37
+
38
+ "calculationPeriod", "paymentCalculationPeriod",
39
+ "cashflows", "cashflowsMatchParameters", "additionalPayment",
40
+ "paymentAmount", "amount",
41
+
42
+ "principalExchanges", "initialExchange", "intermediateExchange",
43
+ "finalExchange", "principalExchange", "principalExchangeAmount",
44
+ "principalExchangeDate", "adjustedPrincipalExchangeDate"
45
+ ],
46
+
47
+ "childOrder": {
48
+ "additionalPayment": ["payerPartyReference", "receiverPartyReference", "paymentAmount", "paymentDate"],
49
+ "businessCenters": ["businessCenter"],
50
+ "calculation": ["notionalSchedule", "fixedRateSchedule", "floatingRateCalculation", "dayCountFraction", "compoundingMethod"],
51
+ "calculationAgent": ["calculationAgentPartyReference"],
52
+ "calculationPeriod": ["adjustedStartDate", "adjustedEndDate", "notionalAmount", "floatingRateDefinition", "fixedRate"],
53
+ "calculationPeriodAmount": ["calculation"],
54
+ "calculationPeriodDates": ["effectiveDate", "terminationDate", "calculationPeriodDatesAdjustments", "firstPeriodStartDate", "firstRegularPeriodStartDate", "lastRegularPeriodEndDate", "calculationPeriodFrequency"],
55
+ "calculationPeriodDatesAdjustments": ["businessDayConvention", "businessCentersReference", "businessCenters"],
56
+ "calculationPeriodFrequency": ["periodMultiplier", "period", "rollConvention"],
57
+ "cashflows": ["cashflowsMatchParameters", "principalExchange", "paymentCalculationPeriod"],
58
+ "dataDocument": ["trade", "party"],
59
+ "dateAdjustments": ["businessDayConvention", "businessCenters", "businessCentersReference"],
60
+ "effectiveDate": ["unadjustedDate", "dateAdjustments"],
61
+ "finalRateRounding": ["roundingDirection", "precision"],
62
+ "finalStub": ["floatingRate"],
63
+ "firstPeriodStartDate": ["unadjustedDate", "dateAdjustments"],
64
+ "fixedRateSchedule": ["initialValue", "step"],
65
+ "fixingDates": ["periodMultiplier", "period", "dayType", "businessDayConvention", "businessCenters", "dateRelativeTo"],
66
+ "floatingRate": ["floatingRateIndex", "indexTenor"],
67
+ "floatingRateCalculation": ["floatingRateIndex", "indexTenor", "finalRateRounding", "spreadSchedule"],
68
+ "floatingRateDefinition": ["rateObservation"],
69
+ "indexTenor": ["periodMultiplier", "period"],
70
+ "initialStub": ["floatingRate", "stubRate"],
71
+ "notionalSchedule": ["notionalStepSchedule"],
72
+ "notionalStepSchedule": ["initialValue", "step", "currency"],
73
+ "party": ["partyId", "partyName"],
74
+ "partyTradeIdentifier": ["partyReference", "tradeId"],
75
+ "paymentAmount": ["currency", "amount"],
76
+ "paymentCalculationPeriod": ["adjustedPaymentDate", "calculationPeriod"],
77
+ "paymentDate": ["unadjustedDate", "dateAdjustments"],
78
+ "paymentDates": ["calculationPeriodDatesReference", "paymentFrequency", "firstPaymentDate", "payRelativeTo", "paymentDaysOffset", "paymentDatesAdjustments"],
79
+ "paymentDatesAdjustments": ["businessDayConvention", "businessCentersReference", "businessCenters"],
80
+ "paymentDaysOffset": ["periodMultiplier", "period", "dayType"],
81
+ "paymentFrequency": ["periodMultiplier", "period"],
82
+ "principalExchange": ["adjustedPrincipalExchangeDate", "principalExchangeAmount"],
83
+ "principalExchanges": ["initialExchange", "finalExchange", "intermediateExchange"],
84
+ "rateObservation": ["adjustedFixingDate", "observationWeight"],
85
+ "resetDates": ["calculationPeriodDatesReference", "resetRelativeTo", "fixingDates", "resetFrequency", "resetDatesAdjustments"],
86
+ "resetDatesAdjustments": ["businessDayConvention", "businessCentersReference", "businessCenters"],
87
+ "resetFrequency": ["periodMultiplier", "period"],
88
+ "spreadSchedule": ["initialValue"],
89
+ "step": ["stepDate", "stepValue"],
90
+ "stubCalculationPeriodAmount": ["calculationPeriodDatesReference", "initialStub", "finalStub"],
91
+ "swap": ["swapStream", "additionalPayment"],
92
+ "swapStream": ["payerPartyReference", "receiverPartyReference", "calculationPeriodDates", "paymentDates", "resetDates", "calculationPeriodAmount", "stubCalculationPeriodAmount", "principalExchanges", "cashflows"],
93
+ "terminationDate": ["unadjustedDate", "dateAdjustments"],
94
+ "trade": ["tradeHeader", "swap", "calculationAgent", "fra"],
95
+ "tradeHeader": ["partyTradeIdentifier", "tradeDate"]
96
+ }
97
+ }
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: fpml-convert
3
+ Version: 0.6.0
4
+ Summary: FpML interest-rate-swap XML <-> JSON, standard library only. One projection mapping, also shipped as a JS interpreter and a C++17 converter.
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Homepage, https://danieltuzes.github.io/fpml-convert/
7
+ Project-URL: Source, https://github.com/danieltuzes/fpml-convert
8
+ Keywords: fpml,xml,json,structile,derivatives,swap
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+
12
+ # fpml-convert (Python)
13
+
14
+ Standard-library-only conversion between **FpML interest-rate-swap XML and
15
+ plain JSON**, in both directions. No `lxml`, no third-party JSON — just
16
+ `xml.etree.ElementTree` and `json`.
17
+
18
+ One projection mapping, also implemented as a JavaScript interpreter (for
19
+ the [Structile](https://danieltuzes.github.io/structile/) viewer) and a
20
+ pure-STL C++17 converter; the three agree byte-for-byte on a shared test
21
+ corpus. Downloads, the full version archive, and the mapping's precise
22
+ rules: <https://danieltuzes.github.io/fpml-convert/>.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ pip install fpml-convert
28
+ ```
29
+
30
+ No runtime dependencies. Python 3.8+.
31
+
32
+ ## Use
33
+
34
+ ```python
35
+ from fpml_convert import fpml_to_json, json_to_fpml
36
+
37
+ value = fpml_to_json(xml_text) # -> dict
38
+ xml_text2 = json_to_fpml(value) # -> schema-ordered FpML, round-trips at the value level
39
+ ```
40
+
41
+ ```sh
42
+ # CLI (direction inferred from the input, or forced with --to)
43
+ python -m fpml_convert swap.xml -o swap.json
44
+ python -m fpml_convert swap.json -o swap.xml
45
+ cat swap.xml | python -m fpml_convert - > swap.json
46
+ ```
47
+
48
+ ## The mapping
49
+
50
+ - Element local name → object key; a tag repeated under one parent → an
51
+ array in document order.
52
+ - Namespace prefixes and ElementTree Clark notation (`{uri}local`) are both
53
+ reduced to the local name, so the result is identical across a browser
54
+ `DOMParser` and a server-side parser.
55
+ - Attributes → `@`-prefixed keys, before any child keys, ordered `@id`,
56
+ `@xsi:type`, `@href`, then the rest. `xsi:type` is kept verbatim.
57
+ - A pure reference element (`href`, no children) → the scalar `«id»`;
58
+ Structile renders that as a link to the element whose `@id` matches.
59
+ - Typed lexical forms are preserved as strings, never coerced
60
+ (`1000000.00` stays exactly that).
61
+ - Every element the projection descends into is checked against a
62
+ supported-subset allowlist — an unknown element raises
63
+ `FpmlUnsupportedError` rather than projecting partially.
64
+ - Serialization re-emits children in schema (`xs:sequence`) order.
65
+
66
+ ## Scope
67
+
68
+ FpML 5.10 confirmation view, interest-rate **swap** `dataDocument` only. A
69
+ non-IR-swap document raises `ValueError`. FRA and other products widen the
70
+ allowlist in later releases. An FpML-AWG interchange-shape mode is planned.
71
+
72
+ ## License
73
+
74
+ Apache-2.0. The FpML example files used for testing are redistributed
75
+ under the [FpML Public License](https://www.fpml.org/the_standard/fpml-public-license/).
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ fpml_convert/__init__.py
4
+ fpml_convert/__main__.py
5
+ fpml_convert/_project.py
6
+ fpml_convert/_rules.py
7
+ fpml_convert/_serialize.py
8
+ fpml_convert/rules.json
9
+ fpml_convert.egg-info/PKG-INFO
10
+ fpml_convert.egg-info/SOURCES.txt
11
+ fpml_convert.egg-info/dependency_links.txt
12
+ fpml_convert.egg-info/entry_points.txt
13
+ fpml_convert.egg-info/top_level.txt
14
+ tests/test_parity.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fpml-convert = fpml_convert.__main__:main
@@ -0,0 +1 @@
1
+ fpml_convert
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ # setuptools ships with every Python — so `python -m build --no-isolation`
3
+ # needs no network (FpML interpreter plan Phase 8: the local artifact
4
+ # assembly must run offline). rules.json is shipped as package data; a
5
+ # repo checkout without a packaged copy falls back to ../../src/rules.json
6
+ # (see fpml_convert/_rules.py).
7
+ requires = ["setuptools>=61.0"]
8
+ build-backend = "setuptools.build_meta"
9
+
10
+ [project]
11
+ name = "fpml-convert"
12
+ # Tracks structile-fpml's package.json `version` (this repo's code semver),
13
+ # NOT the FpML schema version (that's fpml_convert.FPML_VERSION / "5.10").
14
+ version = "0.6.0"
15
+ description = "FpML interest-rate-swap XML <-> JSON, standard library only. One projection mapping, also shipped as a JS interpreter and a C++17 converter."
16
+ readme = "README.md"
17
+ requires-python = ">=3.8"
18
+ license = "Apache-2.0"
19
+ keywords = ["fpml", "xml", "json", "structile", "derivatives", "swap"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://danieltuzes.github.io/fpml-convert/"
23
+ # The projection source lives in a private repo; this public one hosts the
24
+ # built artifacts, the version archive, and a pointer to the source.
25
+ Source = "https://github.com/danieltuzes/fpml-convert"
26
+
27
+ [project.scripts]
28
+ fpml-convert = "fpml_convert.__main__:main"
29
+
30
+ [tool.setuptools]
31
+ packages = ["fpml_convert"]
32
+
33
+ [tool.setuptools.package-data]
34
+ # scripts/build_artifacts.mjs copies src/rules.json here before building
35
+ # the wheel/sdist; it is gitignored (a checkout reads ../../src/rules.json).
36
+ fpml_convert = ["rules.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,136 @@
1
+ """Cross-language parity + round-trip test for the Python converter.
2
+
3
+ The JS bundle's committed snapshots (``test/__snapshots__/*.json``) are the
4
+ contract: ``fpml_to_json`` here must reproduce them BYTE-FOR-BYTE (same key
5
+ order, same ``JSON.stringify(v, null, 2) + "\\n"`` formatting), so the two
6
+ implementations can never silently diverge on the shared corpus.
7
+
8
+ Runs three ways:
9
+ * ``python python/tests/test_parity.py`` (plain script, exits non-zero on failure)
10
+ * ``python -m pytest python/tests`` (each check is a ``test_*``)
11
+ * ``npm run test:py`` (wired in package.json)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ _REPO = Path(__file__).resolve().parents[2]
20
+ sys.path.insert(0, str(_REPO / "python"))
21
+
22
+ from fpml_convert import FpmlUnsupportedError, fpml_to_json, json_to_fpml # noqa: E402
23
+
24
+ _CORPUS = _REPO / "test" / "corpus"
25
+ _FIXTURES = _REPO / "test" / "fixtures"
26
+ _SNAPS = _REPO / "test" / "__snapshots__"
27
+
28
+
29
+ def _samples():
30
+ """(snapshot-name, xml-path) for every file the JS suite snapshots."""
31
+ out = [("ir-swap-minimal", _FIXTURES / "ir-swap-minimal.xml")]
32
+ for x in sorted(_CORPUS.glob("*.xml")):
33
+ out.append(("corpus-" + x.stem, x))
34
+ return out
35
+
36
+
37
+ def test_projection_matches_the_js_snapshots_byte_for_byte():
38
+ for name, xml_path in _samples():
39
+ snap = _SNAPS / (name + ".json")
40
+ assert snap.is_file(), f"missing JS snapshot {snap.name} — run `npm run snapshots` in structile-fpml"
41
+ got = json.dumps(fpml_to_json(xml_path.read_text(encoding="utf-8")), indent=2, ensure_ascii=False) + "\n"
42
+ assert got == snap.read_text(encoding="utf-8"), (
43
+ f"{xml_path.name}: Python projection differs from the JS snapshot {snap.name}"
44
+ )
45
+
46
+
47
+ def test_round_trips_every_sample_at_the_value_level():
48
+ for name, xml_path in _samples():
49
+ v1 = fpml_to_json(xml_path.read_text(encoding="utf-8"))
50
+ v2 = fpml_to_json(json_to_fpml(v1))
51
+ assert v2 == v1, f"{xml_path.name}: value changed across json_to_fpml -> fpml_to_json"
52
+
53
+
54
+ def test_serialize_is_independent_of_key_order():
55
+ def scramble(node):
56
+ if isinstance(node, list):
57
+ return [scramble(n) for n in node]
58
+ if isinstance(node, dict):
59
+ return {k: scramble(node[k]) for k in reversed(list(node))}
60
+ return node
61
+
62
+ checked = 0
63
+ for name, xml_path in _samples():
64
+ original = fpml_to_json(xml_path.read_text(encoding="utf-8"))
65
+ scrambled = scramble(original)
66
+ assert json_to_fpml(scrambled) == json_to_fpml(original), (
67
+ f"{xml_path.name}: serialize output depends on key order — binding not applied"
68
+ )
69
+ assert fpml_to_json(json_to_fpml(scrambled)) == original, f"{xml_path.name}: lost data"
70
+ checked += 1
71
+ assert checked >= 8
72
+
73
+
74
+ def test_rejects_documents_outside_the_subset():
75
+ # non-dataDocument root
76
+ try:
77
+ fpml_to_json("<FpML><foo/></FpML>")
78
+ assert False, "expected a ValueError for a non-dataDocument root"
79
+ except ValueError as e:
80
+ assert "root <dataDocument>" in str(e)
81
+
82
+ # dataDocument with no <swap>
83
+ no_swap = ('<dataDocument xmlns="http://www.fpml.org/FpML-5/confirmation" '
84
+ 'fpmlVersion="5-10"><party id="P"><partyName>X</partyName></party></dataDocument>')
85
+ try:
86
+ fpml_to_json(no_swap)
87
+ assert False, "expected a ValueError for a <dataDocument> with no <swap>"
88
+ except ValueError as e:
89
+ assert "no <swap>" in str(e)
90
+
91
+ # a real FpML FRA example — <fra>, not <swap>, so the root gate rejects it
92
+ fra = _FIXTURES / "ird-ex08-fra.xml"
93
+ if fra.is_file():
94
+ try:
95
+ fpml_to_json(fra.read_text(encoding="utf-8"))
96
+ assert False, "expected the FRA fixture to be rejected"
97
+ except ValueError as e:
98
+ assert "no <swap>" in str(e)
99
+
100
+ # an element outside FPML_SUPPORTED
101
+ alien = _FIXTURES.joinpath("ir-swap-minimal.xml").read_text(encoding="utf-8").replace(
102
+ "<tradeDate>2024-01-15</tradeDate>",
103
+ "<tradeDate>2024-01-15</tradeDate><novationEvent/>",
104
+ )
105
+ try:
106
+ fpml_to_json(alien)
107
+ assert False, "expected FpmlUnsupportedError for <novationEvent>"
108
+ except FpmlUnsupportedError as e:
109
+ assert "<novationEvent>" in str(e)
110
+
111
+
112
+ def test_json_to_fpml_rejects_a_non_datadocument_value():
113
+ for bad in (None, [1, 2, 3], "nope"):
114
+ try:
115
+ json_to_fpml(bad)
116
+ assert False, f"expected ValueError for {bad!r}"
117
+ except ValueError as e:
118
+ assert "expected the projected" in str(e)
119
+
120
+
121
+ def _run():
122
+ checks = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
123
+ failed = 0
124
+ for fn in checks:
125
+ try:
126
+ fn()
127
+ print(f"ok {fn.__name__}")
128
+ except Exception as e: # noqa: BLE001
129
+ failed += 1
130
+ print(f"FAIL {fn.__name__}: {e}")
131
+ print(f"\n{len(checks) - failed}/{len(checks)} passed")
132
+ return 1 if failed else 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(_run())