sdmxlib 0.41.2__tar.gz → 0.41.3__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 (91) hide show
  1. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/PKG-INFO +2 -2
  2. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/pyproject.toml +6 -2
  3. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/__init__.py +2 -1
  4. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/local/data_store.py +54 -7
  5. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/polars.py +21 -49
  6. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/storage/lazy.py +30 -4
  7. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/README.md +0 -0
  8. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/_duckdb.py +0 -0
  9. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/api/__init__.py +0 -0
  10. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/api/client.py +0 -0
  11. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/api/federated.py +0 -0
  12. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/api/filters.py +0 -0
  13. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/api/policy.py +0 -0
  14. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/api/providers.py +0 -0
  15. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/api/query.py +0 -0
  16. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/api/registry.py +0 -0
  17. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/api/session.py +0 -0
  18. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/builders/__init__.py +0 -0
  19. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/builders/contentconstraint.py +0 -0
  20. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/catalog.py +0 -0
  21. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/data_store.py +0 -0
  22. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/__init__.py +0 -0
  23. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  24. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  25. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  26. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  27. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_json/_data_common.py +0 -0
  28. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_json/data_v1.py +0 -0
  29. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_json/data_v2.py +0 -0
  30. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  31. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  32. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_json/structure_values.py +0 -0
  33. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  34. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
  35. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -0
  36. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
  37. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml/_common.py +0 -0
  38. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  39. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  40. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  41. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  42. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  43. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  44. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  45. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  46. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  47. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
  48. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
  49. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml31/reader.py +0 -0
  50. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/formats/sdmx_ml31/writer.py +0 -0
  51. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/local/__init__.py +0 -0
  52. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/local/registry.py +0 -0
  53. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/__init__.py +0 -0
  54. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/annotations.py +0 -0
  55. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/base.py +0 -0
  56. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/binding.py +0 -0
  57. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/category.py +0 -0
  58. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/code_query.py +0 -0
  59. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/codelist.py +0 -0
  60. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/collections.py +0 -0
  61. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/concept.py +0 -0
  62. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/constraint.py +0 -0
  63. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/convert.py +0 -0
  64. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/dataflow.py +0 -0
  65. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/dataset.py +0 -0
  66. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/datastructure.py +0 -0
  67. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/expr.py +0 -0
  68. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/hierarchy.py +0 -0
  69. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/hierarchy_query.py +0 -0
  70. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/in_memory_registry.py +0 -0
  71. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/istring.py +0 -0
  72. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/mapping.py +0 -0
  73. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/message.py +0 -0
  74. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/metadataflow.py +0 -0
  75. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/metadataset.py +0 -0
  76. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/metadatastructure.py +0 -0
  77. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/organisation.py +0 -0
  78. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/provision.py +0 -0
  79. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/ref.py +0 -0
  80. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/registry.py +0 -0
  81. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/representation.py +0 -0
  82. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/urn.py +0 -0
  83. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/model/validation.py +0 -0
  84. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/py.typed +0 -0
  85. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/rest.py +0 -0
  86. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/sql.py +0 -0
  87. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/storage/__init__.py +0 -0
  88. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/storage/_kinds.py +0 -0
  89. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/storage/readers.py +0 -0
  90. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/storage/schema.py +0 -0
  91. {sdmxlib-0.41.2 → sdmxlib-0.41.3}/src/sdmxlib/storage/writers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.41.2
3
+ Version: 0.41.3
4
4
  Summary: SDMX structural metadata library for Python
5
5
  Keywords: sdmx,statistics,metadata,datastructure
6
6
  Author: gabrielgellner
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Topic :: Scientific/Engineering :: Information Analysis
15
15
  Requires-Dist: attrs>=26.1.0
16
- Requires-Dist: duckdb>=1.0.0
16
+ Requires-Dist: duckdb>=1.5.3
17
17
  Requires-Dist: httpx>=0.28.1
18
18
  Requires-Dist: lxml>=6.0.2
19
19
  Requires-Dist: orjson>=3.9
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.41.2"
3
+ version = "0.41.3"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -17,7 +17,11 @@ classifiers = [
17
17
  ]
18
18
  dependencies = [
19
19
  "attrs>=26.1.0",
20
- "duckdb>=1.0.0",
20
+ # >=1.5.3: ducklake extension must support DuckLake catalog format 1.0,
21
+ # which is what statcan-catalog-builder (duckdb 1.5.3) writes into
22
+ # published bundles. duckdb 1.5.1's ducklake tops out at format 0.4 and
23
+ # fails to ATTACH such bundles with "DuckLake catalog version mismatch".
24
+ "duckdb>=1.5.3",
21
25
  "httpx>=0.28.1",
22
26
  "lxml>=6.0.2",
23
27
  "orjson>=3.9",
@@ -11,7 +11,7 @@ from sdmxlib.data_store import DataStore
11
11
  from sdmxlib.sql import CompileContext, Predicate, SqlRelation
12
12
  from sdmxlib.formats import parse, read, serialize, to_json, to_xml
13
13
  from sdmxlib.local import LazyCodelist, LazyHierarchy, Registry
14
- from sdmxlib.local.data_store import DuckDBDataStore
14
+ from sdmxlib.local.data_store import DuckDBDataStore, DuckLakeVersionError
15
15
  from sdmxlib.rest import ComponentFilter, parse_components
16
16
  from sdmxlib.model.convert import from_dict, to_dict
17
17
  from sdmxlib.model.validation import (
@@ -167,6 +167,7 @@ __all__ = [
167
167
  "DriftError",
168
168
  "DriftIssue",
169
169
  "DuckDBDataStore",
170
+ "DuckLakeVersionError",
170
171
  "Facet",
171
172
  "FailurePolicy",
172
173
  "FederatedCatalog",
@@ -37,6 +37,29 @@ if TYPE_CHECKING:
37
37
  WriteSource = pl.DataFrame | pl.LazyFrame | pa.Table | duckdb.DuckDBPyRelation
38
38
 
39
39
 
40
+ class DuckLakeVersionError(Exception): # mirrors RegistryUnavailable etc.
41
+ """A DuckLake catalog's format is newer than the installed extension.
42
+
43
+ Raised when ATTACH-ing a bundle whose DuckLake catalog format exceeds
44
+ what the loaded ``ducklake`` extension supports. The fix is to align
45
+ versions (upgrade ``duckdb`` >= 1.5.3 and/or the ``ducklake``
46
+ extension), not to migrate the bundle in place. See issue #156.
47
+ """
48
+
49
+
50
+ def _is_ducklake_version_mismatch(exc: Exception) -> bool:
51
+ """True if ``exc`` is a DuckDB error about an unsupported DuckLake format.
52
+
53
+ Matches the two shapes DuckDB raises depending on the skew:
54
+
55
+ - ``DuckLake catalog version mismatch: catalog version is X, but the
56
+ extension requires version Y``
57
+ - ``Only DuckLake versions ... are supported``
58
+ """
59
+ msg = str(exc).lower()
60
+ return "ducklake" in msg and ("catalog version mismatch" in msg or "are supported" in msg)
61
+
62
+
40
63
  class DuckDBDataStore:
41
64
  """A `DataStore` backed by DuckDB.
42
65
 
@@ -215,13 +238,37 @@ class DuckDBDataStore:
215
238
  sql = _azure_create_secret_sql(self._azure_credentials)
216
239
  self._conn.execute(sql)
217
240
  if self._attach:
218
- for alias, spec in self._attach.items():
219
- url, options = _split_attach_spec(spec)
220
- sql = f"ATTACH {url} AS {_sql_identifier(alias)}"
221
- if options:
222
- sql += f" {options}"
223
- self._conn.execute(sql)
224
- self._conn.execute("SET search_path = 'sdmx'")
241
+ self._run_attaches()
242
+
243
+ def _run_attaches(self) -> None:
244
+ """ATTACH each configured catalog, translating DuckLake skew errors.
245
+
246
+ A DuckLake catalog whose format is newer than the installed
247
+ ``ducklake`` extension surfaces as an opaque DuckDB exception; we
248
+ re-raise it as `DuckLakeVersionError` with the fix. Any other
249
+ ATTACH failure propagates unchanged. See issue #156.
250
+ """
251
+ conn = self._conn
252
+ if conn is None: # guarded by caller; narrows the type here too
253
+ return
254
+ for alias, spec in (self._attach or {}).items():
255
+ url, options = _split_attach_spec(spec)
256
+ sql = f"ATTACH {url} AS {_sql_identifier(alias)}"
257
+ if options:
258
+ sql += f" {options}"
259
+ try:
260
+ conn.execute(sql)
261
+ except duckdb.Error as exc:
262
+ if _is_ducklake_version_mismatch(exc):
263
+ msg = (
264
+ f"cannot attach DuckLake catalog {alias!r}: its catalog format is "
265
+ "newer than the installed ducklake extension supports. Upgrade "
266
+ "duckdb (>=1.5.3) and/or the ducklake extension, then retry. "
267
+ f"(underlying: {exc})"
268
+ )
269
+ raise DuckLakeVersionError(msg) from exc
270
+ raise
271
+ conn.execute("SET search_path = 'sdmx'")
225
272
 
226
273
  def _needs_azure(self) -> bool:
227
274
  """True if Azure secret/extension setup is required for this session.
@@ -19,7 +19,6 @@ directly for standalone use::
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
- import weakref
23
22
  from typing import TYPE_CHECKING, Any
24
23
 
25
24
  import polars as pl
@@ -175,25 +174,15 @@ def validation_report(
175
174
  # slpl.label() and _label_view() are hot paths. Building the id→label
176
175
  # dict by iterating ``codelist.codes`` materialises a Code object per
177
176
  # row, which dominates on large codelists (CL_GEOGRAPHY: 78k codes →
178
- # ~16s per call). We cache the dict per (codelist instance, lang) pair.
177
+ # ~16s per call).
179
178
  #
180
- # Identity is the codelist *object* (weakref so the cache doesn't pin
181
- # instances). When the codelist is garbage-collected the cache entry
182
- # goes with it. A fresh ingest produces a new instance and a fresh
183
- # cache slot, so re-ingestion is automatically correct without a
184
- # manual invalidation call.
185
-
186
- _LABEL_MAP_CACHE: weakref.WeakKeyDictionary[object, dict[str, dict[str, str]]] = weakref.WeakKeyDictionary()
187
-
188
-
189
- def clear_label_cache() -> None:
190
- """Drop the cached id→label maps used by ``label`` / ``_label_view``.
191
-
192
- The cache is normally invalidated automatically when the underlying
193
- codelist instance is garbage-collected. Call this explicitly in
194
- tests or other contexts that want to force a rebuild.
195
- """
196
- _LABEL_MAP_CACHE.clear()
179
+ # Caching lives at the *source*, not here (#155): `LazyCodelist.code_map`
180
+ # memoises per instance per lang with a single-flight build lock, so every
181
+ # code_map caller not just label() dedups, and concurrent first-time
182
+ # callers share one build instead of stampeding the DuckDB connection.
183
+ # Plain in-memory `Codelist` has no code_map and is rebuilt each call, but
184
+ # that path is pure-Python dict construction (no SQL) — cheap enough not to
185
+ # warrant a second cache. See docs/design/caching.md.
197
186
 
198
187
 
199
188
  def _component_label_map(component: object, lang: str) -> dict[str, str] | None:
@@ -202,8 +191,9 @@ def _component_label_map(component: object, lang: str) -> dict[str, str] | None:
202
191
  Fast path: when ``component.representation()`` is a
203
192
  `LazyCodelist` (or anything with a
204
193
  ``code_map(*, lang)`` method), use direct-SQL projection — single
205
- DuckDB query, no Code/InternationalString construction. Falls back
206
- to in-memory iteration for plain `Codelist` instances.
194
+ DuckDB query (cached + single-flight at the source), no
195
+ Code/InternationalString construction. Falls back to in-memory
196
+ iteration for plain `Codelist` instances.
207
197
 
208
198
  Returns ``None`` when the component is not enumerated or its
209
199
  representation Ref is unresolved.
@@ -214,33 +204,13 @@ def _component_label_map(component: object, lang: str) -> dict[str, str] | None:
214
204
  if not (isinstance(rep, Ref) and rep.resolved):
215
205
  return None
216
206
  cl: object = rep()
217
- try:
218
- per_lang = _LABEL_MAP_CACHE.get(cl)
219
- except TypeError:
220
- # Some codelist instances (e.g. attrs frozen) aren't weakly
221
- # referenceable. Skip caching for those.
222
- per_lang = None
223
- if per_lang is not None and lang in per_lang:
224
- return per_lang[lang]
225
207
  code_map = getattr(cl, "code_map", None)
226
- mapping: dict[str, str]
227
208
  if callable(code_map):
228
- # Fast path: direct SQL projection (LazyCodelist).
229
- result = cast("dict[str, str]", code_map(lang=lang))
230
- mapping = dict(result)
231
- else:
232
- # Fallback: in-memory codelist iteration.
233
- codes = cast("Any", cl).codes
234
- mapping = {c.id: c.name.get(lang) or c.id for c in codes}
235
- try:
236
- if per_lang is None:
237
- _LABEL_MAP_CACHE[cl] = {lang: mapping}
238
- else:
239
- per_lang[lang] = mapping
240
- except TypeError:
241
- # Codelist isn't weakly referenceable — return uncached.
242
- pass
243
- return mapping
209
+ # Fast path: direct SQL projection (LazyCodelist), cached at source.
210
+ return cast("dict[str, str]", code_map(lang=lang))
211
+ # Fallback: in-memory codelist iteration (no SQL, cheap).
212
+ codes = cast("Any", cl).codes
213
+ return {c.id: c.name.get(lang) or c.id for c in codes}
244
214
 
245
215
 
246
216
  def _label_view(
@@ -298,9 +268,11 @@ def label(
298
268
  appends a ``{col}{suffix}`` column with the code label in ``lang``.
299
269
  Codes with no label fall back to the code id.
300
270
 
301
- Label maps are cached per ``(codelist_urn, lang)`` after the first
302
- call — call `clear_label_cache` after re-ingesting a codelist
303
- in long-running processes.
271
+ Label maps are cached per (codelist instance, lang) at the source
272
+ (`LazyCodelist.code_map`) with a single-flight build lock, so the
273
+ first caller builds the map and concurrent callers share it rather
274
+ than each re-querying. A fresh ingest produces a new codelist
275
+ instance with a fresh cache, so re-ingestion is automatically correct.
304
276
 
305
277
  Example:
306
278
  labelled = slpl.label(pl.scan_csv("data.csv"), dsd, lang="en").collect()
@@ -92,8 +92,10 @@ class LazyCodelist:
92
92
  """
93
93
 
94
94
  __slots__ = (
95
- "__weakref__", # required so slpl.label's per-codelist cache can hold a weakref
95
+ "__weakref__",
96
96
  "_agency",
97
+ "_code_map_cache",
98
+ "_code_map_lock",
97
99
  "_conn",
98
100
  "_description",
99
101
  "_id",
@@ -123,6 +125,13 @@ class LazyCodelist:
123
125
  self._name = name
124
126
  self._description = description
125
127
  self._is_final = is_final
128
+ # Per-instance, per-language label-map cache + single-flight build
129
+ # lock (#155). The lock collapses the thundering herd: N threads
130
+ # asking the same instance for the same lang run the SQL once, not
131
+ # N times. Cache lifetime is the instance's — a fresh ingest yields
132
+ # a new LazyCodelist, so re-ingestion is automatically correct.
133
+ self._code_map_cache: dict[str, dict[str, str]] = {}
134
+ self._code_map_lock = threading.Lock()
126
135
 
127
136
  # ── MaintainableArtefact-compatible properties ────────────────────────
128
137
 
@@ -393,10 +402,27 @@ class LazyCodelist:
393
402
  # ── Direct SQL helpers ───────────────────────────────────────────────
394
403
 
395
404
  def code_map(self, *, lang: str) -> dict[str, str]:
396
- """Direct SQL: ``{code_id: label}`` without creating Code objects."""
397
- from sdmxlib.storage.readers import code_map # noqa: PLC0415
405
+ """Direct SQL: ``{code_id: label}`` without creating Code objects.
398
406
 
399
- return code_map(self._conn, self._urn, lang=lang)
407
+ Memoised per instance per ``lang`` with a single-flight build lock
408
+ (#155): the first caller runs the SQL projection, concurrent
409
+ callers for the same ``lang`` block on the lock and then return the
410
+ hot cache rather than each re-running the query. The returned dict
411
+ is the cached instance — treat it as read-only.
412
+ """
413
+ cached = self._code_map_cache.get(lang)
414
+ if cached is not None:
415
+ return cached
416
+ with self._code_map_lock:
417
+ # Double-checked: a prior holder may have built it while we waited.
418
+ cached = self._code_map_cache.get(lang)
419
+ if cached is not None:
420
+ return cached
421
+ from sdmxlib.storage.readers import code_map # noqa: PLC0415
422
+
423
+ mapping = code_map(self._conn, self._urn, lang=lang)
424
+ self._code_map_cache[lang] = mapping
425
+ return mapping
400
426
 
401
427
  def materialize(self) -> Codelist:
402
428
  """Load all codes and return a real `Codelist`."""
File without changes
File without changes
File without changes
File without changes