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.
- statcatlib/__init__.py +187 -0
- statcatlib/bundle.py +565 -0
- statcatlib/cli/__init__.py +546 -0
- statcatlib/expr.py +167 -0
- statcatlib/model/__init__.py +56 -0
- statcatlib/model/agents.py +32 -0
- statcatlib/model/catalog.py +71 -0
- statcatlib/model/classification.py +19 -0
- statcatlib/model/dataset.py +198 -0
- statcatlib/model/distribution.py +50 -0
- statcatlib/model/licence.py +18 -0
- statcatlib/model/quality.py +31 -0
- statcatlib/model/skos.py +141 -0
- statcatlib/model/spatial.py +19 -0
- statcatlib/model/temporal.py +33 -0
- statcatlib/model/types.py +5 -0
- statcatlib/py.typed +0 -0
- statcatlib/query.py +924 -0
- statcatlib/sources/__init__.py +13 -0
- statcatlib/sources/ckan.py +490 -0
- statcatlib/sources/sdmx.py +321 -0
- statcatlib/store/__init__.py +38 -0
- statcatlib/store/lance.py +1494 -0
- statcatlib/store/row.py +30 -0
- statcatlib/store/schema.py +403 -0
- statcatlib/store/scopes.py +1419 -0
- statcatlib/store/sentinel.py +99 -0
- statcatlib/store/store.py +6836 -0
- statcatlib/store/xhtml.py +206 -0
- statcatlib/urn.py +108 -0
- statcatlib-0.8.0.dist-info/METADATA +429 -0
- statcatlib-0.8.0.dist-info/RECORD +35 -0
- statcatlib-0.8.0.dist-info/WHEEL +4 -0
- statcatlib-0.8.0.dist-info/entry_points.txt +3 -0
- statcatlib-0.8.0.dist-info/licenses/LICENSE +180 -0
|
@@ -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)
|