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,321 @@
|
|
|
1
|
+
"""SDMX source connector for statcatlib.
|
|
2
|
+
|
|
3
|
+
This is the only statcatlib module that imports sdmxlib beyond InternationalString.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from sdmxlib import InternationalString
|
|
12
|
+
from sdmxlib.model.dataflow import Dataflow
|
|
13
|
+
from sdmxlib.model.datastructure import DataStructure
|
|
14
|
+
|
|
15
|
+
from statcatlib.model.agents import Agent
|
|
16
|
+
from statcatlib.model.dataset import AggregateDataset
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from sdmxlib.api.registry import RestRegistry
|
|
20
|
+
from sdmxlib.model.in_memory_registry import InMemoryRegistry
|
|
21
|
+
|
|
22
|
+
from statcatlib.model.skos import ConceptRef
|
|
23
|
+
from statcatlib.store.store import Catalog
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"dataset_from_dataflow",
|
|
27
|
+
"ingest_data",
|
|
28
|
+
"ingest_sdmx",
|
|
29
|
+
"ingest_sdmx_registry",
|
|
30
|
+
"prepare_sdmx",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
_IDENTIFIER_RE = re.compile(r"^([^:]+):([^(]+)\(([^)]+)\)$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_latest(store: Catalog, identifier: str) -> str:
|
|
37
|
+
"""Resolve ``(latest)`` to the highest stored version for that agency:id."""
|
|
38
|
+
if not identifier.endswith("(latest)"):
|
|
39
|
+
return identifier
|
|
40
|
+
m = _IDENTIFIER_RE.match(identifier)
|
|
41
|
+
if m is None:
|
|
42
|
+
return identifier
|
|
43
|
+
agency, flow_id = m.group(1), m.group(2)
|
|
44
|
+
row = store._c.execute( # noqa: SLF001
|
|
45
|
+
"""
|
|
46
|
+
SELECT identifier FROM dataset
|
|
47
|
+
WHERE identifier LIKE ?
|
|
48
|
+
ORDER BY identifier DESC
|
|
49
|
+
LIMIT 1
|
|
50
|
+
""",
|
|
51
|
+
[f"{agency}:{flow_id}(%"],
|
|
52
|
+
).fetchone()
|
|
53
|
+
return row[0] if row else identifier
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Public bridge function
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _build_urn(flow: Dataflow) -> str:
|
|
62
|
+
"""Build a URN string for a dataflow using sdmxlib's SdmxUrn."""
|
|
63
|
+
from sdmxlib.model.urn import SdmxUrn as _SdmxUrn # noqa: PLC0415
|
|
64
|
+
|
|
65
|
+
return str(
|
|
66
|
+
_SdmxUrn(
|
|
67
|
+
package=flow.sdmx_package,
|
|
68
|
+
artefact_class=flow.sdmx_class,
|
|
69
|
+
agency=flow.agency_id,
|
|
70
|
+
id=flow.id,
|
|
71
|
+
version=flow.version,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def dataset_from_dataflow(
|
|
77
|
+
flow: Dataflow,
|
|
78
|
+
dsd: DataStructure,
|
|
79
|
+
*,
|
|
80
|
+
publisher: Agent | None = None,
|
|
81
|
+
themes: list[ConceptRef] | None = None,
|
|
82
|
+
) -> AggregateDataset:
|
|
83
|
+
"""Create an AggregateDataset from sdmxlib Dataflow and DataStructure objects.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
flow: The sdmxlib Dataflow to convert.
|
|
87
|
+
dsd: The resolved DataStructure for this dataflow.
|
|
88
|
+
publisher: Optional Agent override. When omitted, the flow's maintainer agency is used.
|
|
89
|
+
themes: Optional list of ConceptRef objects to tag the dataset with.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The catalog dataset. Code and codelist information is resolved at
|
|
93
|
+
query time via the SDMX structural tables (``sdmx.dsd_component``,
|
|
94
|
+
``sdmx.code``) using the ``sdmx_dataflow_urn`` foreign key.
|
|
95
|
+
"""
|
|
96
|
+
agency = flow.maintainer
|
|
97
|
+
identifier = f"{agency.id}:{flow.id}({flow.version})"
|
|
98
|
+
urn = _build_urn(flow)
|
|
99
|
+
|
|
100
|
+
if publisher is None:
|
|
101
|
+
name = agency.name if (agency.name.get("en") or agency.name.get("fr")) else InternationalString(en=agency.id)
|
|
102
|
+
publisher = Agent(id=agency.id, name=name)
|
|
103
|
+
|
|
104
|
+
dimension_names: list[InternationalString] = []
|
|
105
|
+
|
|
106
|
+
for dim in dsd.dimensions:
|
|
107
|
+
# Use the concept name if resolved; fall back to dimension ID.
|
|
108
|
+
concept_ref = dim.concept
|
|
109
|
+
if concept_ref is not None and concept_ref.resolved:
|
|
110
|
+
resolved_concept = concept_ref()
|
|
111
|
+
if hasattr(resolved_concept, "name"):
|
|
112
|
+
label = resolved_concept.name
|
|
113
|
+
else:
|
|
114
|
+
label = InternationalString(en=dim.id, fr=dim.id)
|
|
115
|
+
else:
|
|
116
|
+
label = InternationalString(en=dim.id, fr=dim.id)
|
|
117
|
+
dimension_names.append(label)
|
|
118
|
+
|
|
119
|
+
return AggregateDataset(
|
|
120
|
+
identifier=identifier,
|
|
121
|
+
title=flow.name,
|
|
122
|
+
description=flow.description,
|
|
123
|
+
publisher=publisher,
|
|
124
|
+
sdmx_dataflow_urn=urn,
|
|
125
|
+
dimension_names=dimension_names,
|
|
126
|
+
themes=themes or [],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Bulk ingestion
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def prepare_sdmx(
|
|
136
|
+
registry: InMemoryRegistry,
|
|
137
|
+
*,
|
|
138
|
+
publisher: Agent | None = None,
|
|
139
|
+
themes: list[ConceptRef] | None = None,
|
|
140
|
+
) -> list[AggregateDataset]:
|
|
141
|
+
"""Build AggregateDataset objects from a registry without storing.
|
|
142
|
+
|
|
143
|
+
Callers can enrich each dataset before storing::
|
|
144
|
+
|
|
145
|
+
datasets = prepare_sdmx(registry)
|
|
146
|
+
ds = attrs.evolve(datasets[0], num_series=42)
|
|
147
|
+
store.put(ds)
|
|
148
|
+
|
|
149
|
+
Code and codelist information is resolved at query time via the SDMX
|
|
150
|
+
structural tables once ``local.ingest(registry)`` has been called.
|
|
151
|
+
"""
|
|
152
|
+
results: list[AggregateDataset] = []
|
|
153
|
+
for flow in registry.get_all(Dataflow):
|
|
154
|
+
structure_ref = flow.structure
|
|
155
|
+
if structure_ref is None or not structure_ref.resolved:
|
|
156
|
+
continue
|
|
157
|
+
dsd = structure_ref()
|
|
158
|
+
if not isinstance(dsd, DataStructure):
|
|
159
|
+
continue
|
|
160
|
+
ds = dataset_from_dataflow(flow, dsd, publisher=publisher, themes=themes)
|
|
161
|
+
results.append(ds)
|
|
162
|
+
return results
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def ingest_sdmx(store: Catalog, registry: InMemoryRegistry) -> list[AggregateDataset]:
|
|
166
|
+
"""Ingest all SDMX artefacts from a registry into the catalog.
|
|
167
|
+
|
|
168
|
+
Step 1: Write all structural artefacts (codelists, DSDs, dataflows) into
|
|
169
|
+
the shared DuckDB connection via sdmxlib's LocalRegistry.
|
|
170
|
+
|
|
171
|
+
Step 2: For each Dataflow in the registry, create an AggregateDataset via
|
|
172
|
+
``dataset_from_dataflow()`` and store it. Code and codelist queries use
|
|
173
|
+
the SDMX structural tables via ``sdmx_dataflow_urn``.
|
|
174
|
+
|
|
175
|
+
Returns the list of AggregateDataset objects created.
|
|
176
|
+
"""
|
|
177
|
+
local = store.registry
|
|
178
|
+
local.ingest(registry)
|
|
179
|
+
|
|
180
|
+
datasets = prepare_sdmx(registry)
|
|
181
|
+
with store.bulk_load():
|
|
182
|
+
store.put(datasets)
|
|
183
|
+
return datasets
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def ingest_sdmx_registry(
|
|
187
|
+
store: Catalog,
|
|
188
|
+
registry: InMemoryRegistry,
|
|
189
|
+
*,
|
|
190
|
+
publisher: Agent | None = None,
|
|
191
|
+
themes: list[ConceptRef] | None = None,
|
|
192
|
+
) -> list[AggregateDataset]:
|
|
193
|
+
"""Bulk ingest all dataflows from an sdmxlib LocalRegistry.
|
|
194
|
+
|
|
195
|
+
Like ``ingest_sdmx()`` but exposes ``publisher`` and ``themes`` overrides
|
|
196
|
+
that apply to every ingested dataset.
|
|
197
|
+
"""
|
|
198
|
+
local = store.registry
|
|
199
|
+
local.ingest(registry)
|
|
200
|
+
|
|
201
|
+
datasets = prepare_sdmx(registry, publisher=publisher, themes=themes)
|
|
202
|
+
with store.bulk_load():
|
|
203
|
+
store.put(datasets)
|
|
204
|
+
return datasets
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _query_dimension_names(conn: object, dataflow_urn: str) -> list[InternationalString]:
|
|
208
|
+
"""Query dimension names for a dataflow from the shared sdmx tables."""
|
|
209
|
+
import duckdb # noqa: PLC0415
|
|
210
|
+
|
|
211
|
+
assert isinstance(conn, duckdb.DuckDBPyConnection) # noqa: S101
|
|
212
|
+
rows = conn.execute(
|
|
213
|
+
"""
|
|
214
|
+
SELECT dc.component_id,
|
|
215
|
+
con.name['en'],
|
|
216
|
+
con.name['fr']
|
|
217
|
+
FROM sdmx.dataflow df
|
|
218
|
+
JOIN sdmx.dsd_component dc ON dc.dsd_urn = df.dsd_urn
|
|
219
|
+
LEFT JOIN sdmx.concept con ON con.concept_urn = dc.concept_urn
|
|
220
|
+
WHERE df.urn = ?
|
|
221
|
+
AND dc.component_type = 'Dimension'
|
|
222
|
+
ORDER BY dc.position
|
|
223
|
+
""",
|
|
224
|
+
[dataflow_urn],
|
|
225
|
+
).fetchall()
|
|
226
|
+
result = []
|
|
227
|
+
for component_id, name_en, name_fr in rows:
|
|
228
|
+
en = name_en or component_id
|
|
229
|
+
fr = name_fr or component_id
|
|
230
|
+
result.append(InternationalString(en=en, fr=fr))
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def ingest_data(
|
|
235
|
+
store: Catalog,
|
|
236
|
+
reg: RestRegistry,
|
|
237
|
+
identifier: str,
|
|
238
|
+
*,
|
|
239
|
+
measure_dim: str | None = None,
|
|
240
|
+
) -> str:
|
|
241
|
+
"""Materialise observation data for one dataflow into the bundle's lake.
|
|
242
|
+
|
|
243
|
+
Fetches the (wide) observations via ``reg.data(...).pivot()``, writes
|
|
244
|
+
them into ``lake.<agency>.df_<id>_<version>`` through the bundle's
|
|
245
|
+
DuckLake-backed data store, and registers a
|
|
246
|
+
`sdmxlib.DataflowBinding` so the dataflow is queryable through
|
|
247
|
+
``cat.observations(...)``.
|
|
248
|
+
|
|
249
|
+
Requires that ``ingest_sdmx`` has already been called for this
|
|
250
|
+
identifier so the catalog record exists, and that ``store`` is a
|
|
251
|
+
folder-mode `Catalog` (a DuckLake is ATTACHed on ``open()``).
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
store: The catalog holding the `AggregateDataset` for *identifier*.
|
|
255
|
+
Must be folder-mode — a DuckLake is ATTACHed on ``open()``.
|
|
256
|
+
reg: REST registry to fetch observations from.
|
|
257
|
+
identifier: Catalog identifier in ``AGENCY:ID(VERSION)`` form.
|
|
258
|
+
``(latest)`` resolves to the highest stored version.
|
|
259
|
+
measure_dim: Dimension to pivot into measure columns. ``None``
|
|
260
|
+
(default) leaves the data long.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
The fully-qualified table location (``<schema>.<table>``).
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
KeyError: No `AggregateDataset` is stored under *identifier* — run
|
|
267
|
+
``ingest_sdmx`` first.
|
|
268
|
+
ValueError: The dataset carries no ``sdmx_dataflow_urn``, so there is
|
|
269
|
+
nothing to fetch observations for; or *identifier* is not in
|
|
270
|
+
``AGENCY:ID(VERSION)`` form.
|
|
271
|
+
"""
|
|
272
|
+
identifier = _resolve_latest(store, identifier)
|
|
273
|
+
|
|
274
|
+
ds = store.get(AggregateDataset, identifier)
|
|
275
|
+
if ds is None:
|
|
276
|
+
msg = f"No AggregateDataset found for {identifier!r} — run ingest_sdmx first"
|
|
277
|
+
raise KeyError(msg)
|
|
278
|
+
if ds.sdmx_dataflow_urn is None:
|
|
279
|
+
msg = f"{identifier!r} has no sdmx_dataflow_urn — cannot fetch observation data"
|
|
280
|
+
raise ValueError(msg)
|
|
281
|
+
|
|
282
|
+
m = _IDENTIFIER_RE.match(identifier)
|
|
283
|
+
if m is None:
|
|
284
|
+
msg = f"Cannot parse identifier {identifier!r} — expected 'AGENCY:ID(VERSION)'"
|
|
285
|
+
raise ValueError(msg)
|
|
286
|
+
agency, flow_id, version = m.group(1), m.group(2), m.group(3)
|
|
287
|
+
|
|
288
|
+
schema = agency.lower()
|
|
289
|
+
table = f"df_{flow_id}_{version.replace('.', '_')}".lower()
|
|
290
|
+
|
|
291
|
+
# Pull the wide dataframe and write through the bundle's lake. ``pivot()``
|
|
292
|
+
# returns a LazyFrame; the data store collects it via Arrow under the hood.
|
|
293
|
+
lf = reg.data(
|
|
294
|
+
Dataflow,
|
|
295
|
+
agency=agency,
|
|
296
|
+
id=flow_id,
|
|
297
|
+
version=version,
|
|
298
|
+
measure_dim=measure_dim,
|
|
299
|
+
).pivot()
|
|
300
|
+
store.data.write_observations(schema=schema, table=table, source=lf)
|
|
301
|
+
|
|
302
|
+
# Register a DataflowBinding so ``cat.observations(flow_urn)`` can find
|
|
303
|
+
# the table. Derive identity column maps from the DSD when resolvable.
|
|
304
|
+
import sdmxlib as sl # noqa: PLC0415
|
|
305
|
+
|
|
306
|
+
flow_urn = Dataflow.parse_urn(ds.sdmx_dataflow_urn)
|
|
307
|
+
flow = store.registry.get(flow_urn)
|
|
308
|
+
if isinstance(flow, Dataflow) and isinstance(flow.structure, sl.Ref):
|
|
309
|
+
dsd = store.registry.get(flow.structure.urn)
|
|
310
|
+
if isinstance(dsd, DataStructure):
|
|
311
|
+
binding = sl.DataflowBinding.from_dsd(
|
|
312
|
+
dsd,
|
|
313
|
+
dataflow=sl.Ref[Dataflow].unresolved(flow_urn),
|
|
314
|
+
schema=schema,
|
|
315
|
+
table=table,
|
|
316
|
+
)
|
|
317
|
+
store.registry.add(binding)
|
|
318
|
+
|
|
319
|
+
ds.measure_dim = measure_dim
|
|
320
|
+
store.put(ds)
|
|
321
|
+
return f"{schema}.{table}"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Store backends for statcatlib."""
|
|
2
|
+
|
|
3
|
+
from statcatlib.store.scopes import (
|
|
4
|
+
CodelistScope,
|
|
5
|
+
CodelistsScope,
|
|
6
|
+
CodeScope,
|
|
7
|
+
ConceptScope,
|
|
8
|
+
ConceptSchemeScope,
|
|
9
|
+
ConceptSchemesScope,
|
|
10
|
+
DatasetScope,
|
|
11
|
+
DimensionInfo,
|
|
12
|
+
DimensionScope,
|
|
13
|
+
DimensionsScope,
|
|
14
|
+
SchemeScope,
|
|
15
|
+
ThemesScope,
|
|
16
|
+
)
|
|
17
|
+
from statcatlib.store.row import Row
|
|
18
|
+
from statcatlib.store.store import (
|
|
19
|
+
VALID_INCLUDE_RELS,
|
|
20
|
+
Catalog,
|
|
21
|
+
CodeHit,
|
|
22
|
+
CodelistIndexPolicy,
|
|
23
|
+
CodesScope,
|
|
24
|
+
ConceptHit,
|
|
25
|
+
ConceptsScope,
|
|
26
|
+
DatasetsScope,
|
|
27
|
+
EmbeddingMetadata,
|
|
28
|
+
Hit,
|
|
29
|
+
SEARCH_CONTEXT_ANNOTATION_TYPE,
|
|
30
|
+
SEARCH_EXCLUDE_ANNOTATION_TYPE,
|
|
31
|
+
IncompatibleIndexError,
|
|
32
|
+
QueryBuilder,
|
|
33
|
+
ReferenceMetadataBlend,
|
|
34
|
+
ReferenceMetadataHit,
|
|
35
|
+
ReferenceMetadataScope,
|
|
36
|
+
SearchResult,
|
|
37
|
+
)
|
|
38
|
+
from statcatlib.store.xhtml import xhtml_to_markdown
|