statcatlib 0.8.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.
@@ -0,0 +1,206 @@
1
+ """Dependency-free XHTML → markdown for reference-metadata indexing.
2
+
3
+ SDMX reference metadata stores document sections as XHTML fragments
4
+ (``text_type=XHTML``) — the standards-native, lossless form served by a
5
+ REST/query view. For the *search* corpus that form is wrong: HTML markup
6
+ is low-semantic-value noise that dilutes an embedding and wastes the
7
+ encoder's token window. This module down-converts the XHTML to compact
8
+ markdown at index time, so the retrieval representation is token-efficient
9
+ and LLM-friendly while the canonical store stays XHTML.
10
+
11
+ Deliberately tiny and dependency-free (stdlib `html.parser`, no
12
+ BeautifulSoup): it targets the controlled tag subset producers emit
13
+ (headings, paragraphs, lists, tables with captions, definition lists,
14
+ blockquotes, inline emphasis/links). Unknown tags degrade to their text.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from html.parser import HTMLParser
21
+ from typing import TYPE_CHECKING, NotRequired, TypedDict, override
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable
25
+
26
+
27
+ class _Node(TypedDict):
28
+ """One node of the parsed fragment.
29
+
30
+ A `TypedDict` rather than `_Node`: the shape is known and small,
31
+ and `Any` here would erase the tree's type through every renderer below.
32
+ ``children`` is always present — a ``#text`` node carries an empty list —
33
+ so the ten direct ``node["children"]`` accesses stay checked. ``text`` is
34
+ only on ``#text`` nodes, hence `NotRequired` and the ``.get`` at its two
35
+ call sites.
36
+ """
37
+
38
+ tag: str
39
+ children: list[_Node]
40
+ text: NotRequired[str]
41
+
42
+
43
+ _VOID = {"br"}
44
+ _HEADING = {"h1": "#", "h2": "##", "h3": "###", "h4": "####", "h5": "#####", "h6": "######"}
45
+ _ROW_GROUPS = {"thead", "tbody", "tfoot"}
46
+
47
+
48
+ class _TreeBuilder(HTMLParser):
49
+ """Build a minimal ``{tag, attrs, children}`` tree from an XHTML fragment.
50
+
51
+ Lenient by design (``html.parser`` tolerates the unclosed/void tags and
52
+ entities real-world fragments carry). ``convert_charrefs`` decodes
53
+ entities into text so the render stage sees plain characters.
54
+ """
55
+
56
+ def __init__(self) -> None:
57
+ super().__init__(convert_charrefs=True)
58
+ self.root: _Node = {"tag": "root", "children": []}
59
+ self._stack: list[_Node] = [self.root]
60
+
61
+ @override
62
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
63
+ node: _Node = {"tag": tag, "children": []}
64
+ self._stack[-1]["children"].append(node)
65
+ if tag not in _VOID:
66
+ self._stack.append(node)
67
+
68
+ @override
69
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
70
+ self._stack[-1]["children"].append({"tag": tag, "children": []})
71
+
72
+ @override
73
+ def handle_endtag(self, tag: str) -> None:
74
+ for i in range(len(self._stack) - 1, 0, -1):
75
+ if self._stack[i]["tag"] == tag:
76
+ del self._stack[i:]
77
+ return
78
+
79
+ @override
80
+ def handle_data(self, data: str) -> None:
81
+ self._stack[-1]["children"].append({"tag": "#text", "children": [], "text": data})
82
+
83
+
84
+ def _text(node: _Node) -> str:
85
+ """Collapsed inline text of a node (``<br>`` → space)."""
86
+ out: list[str] = []
87
+ for c in node.get("children", ()):
88
+ if c["tag"] == "#text":
89
+ out.append(c.get("text", ""))
90
+ elif c["tag"] == "br":
91
+ out.append(" ")
92
+ else:
93
+ out.append(_text(c))
94
+ return re.sub(r"\s+", " ", "".join(out)).strip()
95
+
96
+
97
+ def _render_list(node: _Node, depth: int = 0) -> list[str]:
98
+ ordered = node["tag"] == "ol"
99
+ indent = " " * depth
100
+ lines: list[str] = []
101
+ n = 0
102
+ for li in node["children"]:
103
+ if li["tag"] != "li":
104
+ continue
105
+ n += 1
106
+ direct: list[str] = []
107
+ sublists: list[_Node] = []
108
+ for c in li["children"]:
109
+ if c["tag"] in ("ul", "ol"):
110
+ sublists.append(c)
111
+ elif c["tag"] == "#text":
112
+ direct.append(c.get("text", ""))
113
+ elif c["tag"] == "br":
114
+ direct.append(" ")
115
+ else:
116
+ direct.append(_text(c))
117
+ text = re.sub(r"\s+", " ", "".join(direct)).strip()
118
+ marker = f"{n}." if ordered else "-"
119
+ if text:
120
+ lines.append(f"{indent}{marker} {text}")
121
+ for sub in sublists:
122
+ lines.extend(_render_list(sub, depth + 1))
123
+ return lines
124
+
125
+
126
+ def _collect_rows(node: _Node, rows: list[str]) -> None:
127
+ for c in node["children"]:
128
+ if c["tag"] == "tr":
129
+ cells = [_text(cell) for cell in c["children"] if cell["tag"] in ("th", "td")]
130
+ if any(cells):
131
+ rows.append("| " + " | ".join(cells) + " |")
132
+ elif c["tag"] in _ROW_GROUPS:
133
+ _collect_rows(c, rows)
134
+
135
+
136
+ def _render_table(node: _Node) -> str:
137
+ parts: list[str] = []
138
+ for c in node["children"]:
139
+ if c["tag"] == "caption":
140
+ cap = _text(c)
141
+ if cap:
142
+ parts.append(f"**{cap}**")
143
+ rows: list[str] = []
144
+ _collect_rows(node, rows)
145
+ if rows:
146
+ ncol = max(rows[0].count("|") - 1, 1)
147
+ rows.insert(1, "| " + " | ".join(["---"] * ncol) + " |")
148
+ parts.append("\n".join(rows))
149
+ return "\n\n".join(parts)
150
+
151
+
152
+ def _render_definition_list(node: _Node) -> str:
153
+ parts: list[str] = []
154
+ for c in node["children"]:
155
+ text = _text(c)
156
+ if not text:
157
+ continue
158
+ parts.append(f"**{text}**" if c["tag"] == "dt" else text)
159
+ return "\n\n".join(parts)
160
+
161
+
162
+ def _render_blockquote(node: _Node) -> str:
163
+ text = _text(node)
164
+ return f"> {text}" if text else ""
165
+
166
+
167
+ # tag → block renderer. Headings and bare text are handled inline in
168
+ # ``_render``; everything else that isn't a plain paragraph dispatches here.
169
+ _BLOCK_RENDERERS: dict[str, Callable[[_Node], str]] = {
170
+ "ul": lambda n: "\n".join(_render_list(n)),
171
+ "ol": lambda n: "\n".join(_render_list(n)),
172
+ "table": _render_table,
173
+ "dl": _render_definition_list,
174
+ "blockquote": _render_blockquote,
175
+ }
176
+
177
+
178
+ def _render_block(node: _Node) -> str:
179
+ tag = node["tag"]
180
+ if tag in _HEADING:
181
+ text = _text(node)
182
+ return f"{_HEADING[tag]} {text}" if text else ""
183
+ if tag in _BLOCK_RENDERERS:
184
+ return _BLOCK_RENDERERS[tag](node)
185
+ if tag == "#text":
186
+ return re.sub(r"\s+", " ", node.get("text", "")).strip()
187
+ return _text(node) # p and any other wrapper — inline text as a paragraph
188
+
189
+
190
+ def _render(nodes: list[_Node]) -> str:
191
+ blocks = (_render_block(node) for node in nodes)
192
+ return "\n\n".join(b for b in blocks if b)
193
+
194
+
195
+ def xhtml_to_markdown(html: str) -> str:
196
+ """Convert an XHTML fragment to compact markdown.
197
+
198
+ A string with no ``<`` is returned trimmed (already plain text), so the
199
+ call is a safe no-op on non-XHTML values.
200
+ """
201
+ if "<" not in html:
202
+ return html.strip()
203
+ builder = _TreeBuilder()
204
+ builder.feed(html)
205
+ builder.close()
206
+ return _render(builder.root["children"])
statcatlib/urn.py ADDED
@@ -0,0 +1,108 @@
1
+ """Convenience URN builders for SDMX artefact references.
2
+
3
+ Instead of hand-typing long URN strings, use these helpers::
4
+
5
+ import statcatlib as sc
6
+
7
+ store.codelist(sc.urn.codelist("SDMX", "CL_GEO", "1.0"))
8
+ store.codelist(sc.urn.codelist("ESTAT", "CL_GEO")) # version defaults to "1.0"
9
+
10
+ # Parse a URN back into components
11
+ parsed = sc.urn.parse("urn:sdmx:org.sdmx.infomodel.codelist.Codelist=SDMX:CL_GEO(1.0)")
12
+ parsed.agency # "SDMX"
13
+ parsed.id # "CL_GEO"
14
+ parsed.version # "1.0"
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from sdmxlib.model.urn import SdmxUrn
20
+
21
+ __all__ = [
22
+ "codelist",
23
+ "concept_scheme",
24
+ "data_structure",
25
+ "dataflow",
26
+ "parse",
27
+ ]
28
+
29
+ # SDMX info model package/class mappings
30
+ _CODELIST = ("codelist", "Codelist")
31
+ _DATAFLOW = ("datastructure", "Dataflow")
32
+ _DATA_STRUCTURE = ("datastructure", "DataStructure")
33
+ _CONCEPT_SCHEME = ("conceptscheme", "ConceptScheme")
34
+
35
+
36
+ def _build(package: str, artefact_class: str, agency: str, artefact_id: str, version: str) -> str:
37
+ return str(
38
+ SdmxUrn(
39
+ package=package,
40
+ artefact_class=artefact_class,
41
+ agency=agency,
42
+ id=artefact_id,
43
+ version=version,
44
+ )
45
+ )
46
+
47
+
48
+ def codelist(agency: str, id: str, version: str = "1.0") -> str: # noqa: A002
49
+ """Build a codelist URN string.
50
+
51
+ Example::
52
+
53
+ sc.urn.codelist("SDMX", "CL_GEO", "1.0")
54
+ # → "urn:sdmx:org.sdmx.infomodel.codelist.Codelist=SDMX:CL_GEO(1.0)"
55
+ """
56
+ return _build(*_CODELIST, agency, id, version)
57
+
58
+
59
+ def dataflow(agency: str, id: str, version: str = "1.0") -> str: # noqa: A002
60
+ """Build a dataflow URN string.
61
+
62
+ Example::
63
+
64
+ sc.urn.dataflow("ESTAT", "NAMA_10_GDP", "1.0")
65
+ # → "urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=ESTAT:NAMA_10_GDP(1.0)"
66
+ """
67
+ return _build(*_DATAFLOW, agency, id, version)
68
+
69
+
70
+ def data_structure(agency: str, id: str, version: str = "1.0") -> str: # noqa: A002
71
+ """Build a data structure definition URN string.
72
+
73
+ Example::
74
+
75
+ sc.urn.data_structure("ESTAT", "DSD_NAMA_10_GDP", "1.0")
76
+ # → "urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=ESTAT:DSD_NAMA_10_GDP(1.0)"
77
+ """
78
+ return _build(*_DATA_STRUCTURE, agency, id, version)
79
+
80
+
81
+ def concept_scheme(agency: str, id: str, version: str = "1.0") -> str: # noqa: A002
82
+ """Build a concept scheme URN string.
83
+
84
+ Example::
85
+
86
+ sc.urn.concept_scheme("SDMX", "CS_FREQ", "1.0")
87
+ # → "urn:sdmx:org.sdmx.infomodel.conceptscheme.ConceptScheme=SDMX:CS_FREQ(1.0)"
88
+ """
89
+ return _build(*_CONCEPT_SCHEME, agency, id, version)
90
+
91
+
92
+ def parse(urn: str) -> SdmxUrn[object]:
93
+ """Parse a URN string into its components.
94
+
95
+ Returns an ``SdmxUrn`` object with ``.agency``, ``.id``, ``.version``,
96
+ ``.package``, and ``.artefact_class`` attributes.
97
+
98
+ Example::
99
+
100
+ parsed = sc.urn.parse("urn:sdmx:org.sdmx.infomodel.codelist.Codelist=SDMX:CL_GEO(1.0)")
101
+ parsed.agency # "SDMX"
102
+ parsed.id # "CL_GEO"
103
+ parsed.version # "1.0"
104
+ str(parsed) # round-trips to original URN string
105
+
106
+ Raises ``ValueError`` if the string is not a valid SDMX URN.
107
+ """
108
+ return SdmxUrn.parse(urn)