sdmxlib 0.16.1__tar.gz → 0.16.2__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.
Files changed (71) hide show
  1. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/PKG-INFO +1 -1
  2. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/pyproject.toml +1 -1
  3. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/code_query.py +64 -17
  4. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/polars.py +88 -9
  5. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/storage/lazy.py +1 -0
  6. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/README.md +0 -0
  7. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/__init__.py +0 -0
  8. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/api/__init__.py +0 -0
  9. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/api/client.py +0 -0
  10. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/api/federated.py +0 -0
  11. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/api/filters.py +0 -0
  12. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/api/policy.py +0 -0
  13. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/api/providers.py +0 -0
  14. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/api/query.py +0 -0
  15. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/api/registry.py +0 -0
  16. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/api/session.py +0 -0
  17. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/catalog.py +0 -0
  18. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/data_store.py +0 -0
  19. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/__init__.py +0 -0
  20. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  21. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  22. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  23. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  24. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  25. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  26. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  27. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  28. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  29. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  30. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  31. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  32. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  33. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  34. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  35. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/local/__init__.py +0 -0
  36. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/local/_data_store.py +0 -0
  37. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/local/_registry.py +0 -0
  38. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/__init__.py +0 -0
  39. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/annotations.py +0 -0
  40. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/base.py +0 -0
  41. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/binding.py +0 -0
  42. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/category.py +0 -0
  43. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/codelist.py +0 -0
  44. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/collections.py +0 -0
  45. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/concept.py +0 -0
  46. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/constraint.py +0 -0
  47. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/convert.py +0 -0
  48. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/dataflow.py +0 -0
  49. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/dataset.py +0 -0
  50. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/datastructure.py +0 -0
  51. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/expr.py +0 -0
  52. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/hierarchy.py +0 -0
  53. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/in_memory_registry.py +0 -0
  54. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/istring.py +0 -0
  55. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/mapping.py +0 -0
  56. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/message.py +0 -0
  57. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/metadataflow.py +0 -0
  58. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/metadataset.py +0 -0
  59. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/metadatastructure.py +0 -0
  60. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/organisation.py +0 -0
  61. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/provision.py +0 -0
  62. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/ref.py +0 -0
  63. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/registry.py +0 -0
  64. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/representation.py +0 -0
  65. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/urn.py +0 -0
  66. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/model/validation.py +0 -0
  67. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/py.typed +0 -0
  68. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/storage/__init__.py +0 -0
  69. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/storage/readers.py +0 -0
  70. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/storage/schema.py +0 -0
  71. {sdmxlib-0.16.1 → sdmxlib-0.16.2}/src/sdmxlib/storage/writers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.16.1
3
+ Version: 0.16.2
4
4
  Summary: SDMX structural metadata library for Python
5
5
  Keywords: sdmx,statistics,metadata,datastructure
6
6
  Author: gabrielgellner
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.16.1"
3
+ version = "0.16.2"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -355,6 +355,7 @@ class CodeQuery:
355
355
  predicates: tuple[Predicate, ...] = ()
356
356
  sort_keys: tuple[_SortKey, ...] = ()
357
357
  limit_n: int | None = None
358
+ annotation_types: tuple[str, ...] = () # () = no annotations loaded
358
359
 
359
360
  # ── Composition ─────────────────────────────────────────────────────
360
361
 
@@ -377,6 +378,21 @@ class CodeQuery:
377
378
  """Return a new query capped at ``n`` rows."""
378
379
  return attrs.evolve(self, limit_n=n)
379
380
 
381
+ def with_annotations(self, *types: str) -> CodeQuery:
382
+ """Return a new query that hydrates the listed annotation types on collect.
383
+
384
+ By default ``.collect()`` returns ``Code`` objects with empty
385
+ annotations — loading them eagerly is expensive on large
386
+ codelists (one round-trip + ``Annotation`` per row). Pass the
387
+ annotation types you actually need:
388
+
389
+ cl.codes.with_annotations("geo_level").collect()
390
+
391
+ Pass no types to clear; pass ``"*"`` (a single asterisk) to load
392
+ every annotation type for matched codes.
393
+ """
394
+ return attrs.evolve(self, annotation_types=types)
395
+
380
396
  # ── Materialisation ─────────────────────────────────────────────────
381
397
 
382
398
  def collect(self) -> list[Code]:
@@ -384,14 +400,23 @@ class CodeQuery:
384
400
 
385
401
  ``Code.name`` and ``Code.description`` carry the full
386
402
  ``InternationalString``; no language is selected here.
403
+
404
+ Annotations are **not** loaded by default. Call
405
+ ``.with_annotations(*types)`` first to hydrate the types you
406
+ need — loading every annotation for every code adds a second
407
+ SQL round-trip and per-row Python object construction that
408
+ dominates on large codelists.
387
409
  """
388
410
  sql, params = _compile(self)
389
411
  rows = self._conn.execute(sql, params).fetchall()
390
412
  if not rows:
391
413
  return []
392
- urns = [r[4] for r in rows]
393
- anns_by_urn = _fetch_annotations_for(self._conn, urns)
394
- return [_row_to_code(row, anns_by_urn.get(row[4], Annotations())) for row in rows]
414
+ if self.annotation_types:
415
+ urns = [r[4] for r in rows]
416
+ anns_by_urn = _fetch_annotations_for(self._conn, urns, types=self.annotation_types)
417
+ return [_row_to_code(row, anns_by_urn.get(row[4], Annotations())) for row in rows]
418
+ empty = Annotations()
419
+ return [_row_to_code(row, empty) for row in rows]
395
420
 
396
421
  def ids(self) -> list[str]:
397
422
  """Compile to SQL, execute, and return just the code ids."""
@@ -427,8 +452,7 @@ class CodeQuery:
427
452
  ).fetchone()
428
453
  if row is None:
429
454
  raise KeyError(key)
430
- anns = _fetch_annotations_for(self._conn, [row[4]]).get(row[4], Annotations())
431
- return _row_to_code(row, anns)
455
+ return _row_to_code(row, self._anns_for_one(row[4]))
432
456
 
433
457
  offset = key if key >= 0 else len(self) + key
434
458
  row = self._conn.execute(
@@ -443,8 +467,21 @@ class CodeQuery:
443
467
  ).fetchone()
444
468
  if row is None:
445
469
  raise IndexError(key)
446
- anns = _fetch_annotations_for(self._conn, [row[4]]).get(row[4], Annotations())
447
- return _row_to_code(row, anns)
470
+ return _row_to_code(row, self._anns_for_one(row[4]))
471
+
472
+ def _anns_for_one(self, code_urn: str) -> Annotations:
473
+ """Return annotations for a single code, honouring ``annotation_types``.
474
+
475
+ Single-code reads (``cl.codes["A"]``) are cheap enough that we
476
+ load whichever types the query is configured for. Default
477
+ ``annotation_types=()`` skips the round-trip entirely.
478
+ """
479
+ if not self.annotation_types:
480
+ return Annotations()
481
+ return _fetch_annotations_for(self._conn, [code_urn], types=self.annotation_types).get(
482
+ code_urn,
483
+ Annotations(),
484
+ )
448
485
 
449
486
  def __contains__(self, key: object) -> bool:
450
487
  if isinstance(key, str):
@@ -807,20 +844,30 @@ def _row_to_code(row: Sequence[Any], annotations: Annotations) -> Code:
807
844
  def _fetch_annotations_for(
808
845
  conn: duckdb.DuckDBPyConnection,
809
846
  code_urns: list[str],
847
+ *,
848
+ types: tuple[str, ...] = ("*",),
810
849
  ) -> dict[str, Annotations]:
811
- """Bulk-load annotations + their text for many code URNs in two queries."""
850
+ """Bulk-load annotations + their text for many code URNs in two queries.
851
+
852
+ ``types`` filters which annotation types to load. ``("*",)`` (default)
853
+ loads every type. Pass an explicit tuple of types to project just
854
+ those, e.g. ``types=("geo_level",)``.
855
+ """
812
856
  if not code_urns:
813
857
  return {}
814
858
  placeholders = ", ".join("?" for _ in code_urns)
815
- ann_rows = conn.execute(
816
- f"""
817
- SELECT id, owner_urn, annotation_id, title, annotation_type, url, ordinal
818
- FROM sdmx.annotation
819
- WHERE owner_urn IN ({placeholders})
820
- ORDER BY owner_urn, ordinal
821
- """,
822
- list(code_urns),
823
- ).fetchall()
859
+ sql_parts = [
860
+ "SELECT id, owner_urn, annotation_id, title, annotation_type, url, ordinal",
861
+ "FROM sdmx.annotation",
862
+ f"WHERE owner_urn IN ({placeholders})",
863
+ ]
864
+ sql_params: list[object] = list(code_urns)
865
+ if types and "*" not in types:
866
+ type_placeholders = ", ".join("?" for _ in types)
867
+ sql_parts.append(f"AND annotation_type IN ({type_placeholders})")
868
+ sql_params.extend(types)
869
+ sql_parts.append("ORDER BY owner_urn, ordinal")
870
+ ann_rows = conn.execute(" ".join(sql_parts), sql_params).fetchall()
824
871
  if not ann_rows:
825
872
  return {}
826
873
 
@@ -19,7 +19,8 @@ directly for standalone use::
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
- from typing import TYPE_CHECKING
22
+ import weakref
23
+ from typing import TYPE_CHECKING, Any
23
24
 
24
25
  import polars as pl
25
26
 
@@ -168,6 +169,79 @@ def validation_report(
168
169
  )
169
170
 
170
171
 
172
+ # ── Label-map cache ─────────────────────────────────────────────────────
173
+ #
174
+ # slpl.label() and _label_view() are hot paths. Building the id→label
175
+ # dict by iterating ``codelist.codes`` materialises a Code object per
176
+ # row, which dominates on large codelists (CL_GEOGRAPHY: 78k codes →
177
+ # ~16s per call). We cache the dict per (codelist instance, lang) pair.
178
+ #
179
+ # Identity is the codelist *object* (weakref so the cache doesn't pin
180
+ # instances). When the codelist is garbage-collected the cache entry
181
+ # goes with it. A fresh ingest produces a new instance and a fresh
182
+ # cache slot, so re-ingestion is automatically correct without a
183
+ # manual invalidation call.
184
+
185
+ _LABEL_MAP_CACHE: weakref.WeakKeyDictionary[object, dict[str, dict[str, str]]] = weakref.WeakKeyDictionary()
186
+
187
+
188
+ def clear_label_cache() -> None:
189
+ """Drop the cached id→label maps used by ``label`` / ``_label_view``.
190
+
191
+ The cache is normally invalidated automatically when the underlying
192
+ codelist instance is garbage-collected. Call this explicitly in
193
+ tests or other contexts that want to force a rebuild.
194
+ """
195
+ _LABEL_MAP_CACHE.clear()
196
+
197
+
198
+ def _component_label_map(component: object, lang: str) -> dict[str, str] | None:
199
+ """Return ``{code_id: label}`` for a resolved enumerated component.
200
+
201
+ Fast path: when ``component.representation()`` is a
202
+ :class:`~sdmxlib.local.LazyCodelist` (or anything with a
203
+ ``code_map(*, lang)`` method), use direct-SQL projection — single
204
+ DuckDB query, no Code/InternationalString construction. Falls back
205
+ to in-memory iteration for plain :class:`Codelist` instances.
206
+
207
+ Returns ``None`` when the component is not enumerated or its
208
+ representation Ref is unresolved.
209
+ """
210
+ from typing import cast # noqa: PLC0415
211
+
212
+ rep = getattr(component, "representation", None)
213
+ if not (isinstance(rep, Ref) and rep.resolved):
214
+ return None
215
+ cl: object = rep()
216
+ try:
217
+ per_lang = _LABEL_MAP_CACHE.get(cl)
218
+ except TypeError:
219
+ # Some codelist instances (e.g. attrs frozen) aren't weakly
220
+ # referenceable. Skip caching for those.
221
+ per_lang = None
222
+ if per_lang is not None and lang in per_lang:
223
+ return per_lang[lang]
224
+ code_map = getattr(cl, "code_map", None)
225
+ mapping: dict[str, str]
226
+ if callable(code_map):
227
+ # Fast path: direct SQL projection (LazyCodelist).
228
+ result = cast("dict[str, str]", code_map(lang=lang))
229
+ mapping = dict(result)
230
+ else:
231
+ # Fallback: in-memory codelist iteration.
232
+ codes = cast("Any", cl).codes
233
+ mapping = {c.id: c.name.get(lang) or c.id for c in codes}
234
+ try:
235
+ if per_lang is None:
236
+ _LABEL_MAP_CACHE[cl] = {lang: mapping}
237
+ else:
238
+ per_lang[lang] = mapping
239
+ except TypeError:
240
+ # Codelist isn't weakly referenceable — return uncached.
241
+ pass
242
+ return mapping
243
+
244
+
171
245
  def _label_view(
172
246
  lf: pl.LazyFrame,
173
247
  dsd: DataStructure,
@@ -182,19 +256,20 @@ def _label_view(
182
256
  ``pl.Enum`` (DSD-defined order preserved). Non-enumerated columns are
183
257
  unchanged.
184
258
  """
185
- enum_map: dict[str, tuple[list[str], list[str]]] = {}
259
+ enum_map: dict[str, dict[str, str]] = {}
186
260
  md = [dsd.measure_dimension] if dsd.measure_dimension is not None else []
187
261
  for comp in [*dsd.dimensions, *md, *dsd.attributes, *dsd.measures]:
188
- if not (isinstance(comp.representation, Ref) and comp.representation.resolved):
262
+ mapping = _component_label_map(comp, lang)
263
+ if mapping is None:
189
264
  continue
190
- codes = [c.id for c in comp.representation().codes] # type: ignore[union-attr]
191
- labels = [c.name.get(lang) or c.id for c in comp.representation().codes] # type: ignore[union-attr]
192
- enum_map[comp.id] = (codes, labels)
265
+ enum_map[comp.id] = mapping
193
266
 
194
267
  select_exprs = []
195
268
  for col_name in lf.collect_schema().names():
196
269
  if col_name in enum_map:
197
- codes, labels = enum_map[col_name]
270
+ mapping = enum_map[col_name]
271
+ codes = list(mapping.keys())
272
+ labels = list(mapping.values())
198
273
  unique_labels = list(dict.fromkeys(labels))
199
274
  select_exprs.append(
200
275
  pl.col(col_name)
@@ -222,6 +297,10 @@ def label(
222
297
  appends a ``{col}{suffix}`` column with the code label in ``lang``.
223
298
  Codes with no label fall back to the code id.
224
299
 
300
+ Label maps are cached per ``(codelist_urn, lang)`` after the first
301
+ call — call :func:`clear_label_cache` after re-ingesting a codelist
302
+ in long-running processes.
303
+
225
304
  Example:
226
305
  labelled = slpl.label(pl.scan_csv("data.csv"), dsd, lang="en").collect()
227
306
  """
@@ -230,9 +309,9 @@ def label(
230
309
  for comp in [*dsd.dimensions, *mds, *dsd.attributes, *dsd.measures]:
231
310
  if comp.id not in schema_cols:
232
311
  continue
233
- if not (isinstance(comp.representation, Ref) and comp.representation.resolved):
312
+ mapping = _component_label_map(comp, lang)
313
+ if mapping is None:
234
314
  continue
235
- mapping = {c.id: c.name.get(lang) or c.id for c in comp.representation().codes} # type: ignore[union-attr]
236
315
  old = list(mapping.keys())
237
316
  new = list(mapping.values())
238
317
  label_col = f"{comp.id}{suffix}"
@@ -35,6 +35,7 @@ class LazyCodelist:
35
35
  """
36
36
 
37
37
  __slots__ = (
38
+ "__weakref__", # required so slpl.label's per-codelist cache can hold a weakref
38
39
  "_agency",
39
40
  "_conn",
40
41
  "_description",
File without changes
File without changes