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,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