sdmxlib 0.28.0__tar.gz → 0.28.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 (75) hide show
  1. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/PKG-INFO +1 -1
  2. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/pyproject.toml +1 -1
  3. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/local/_data_store.py +31 -2
  4. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/local/_registry.py +65 -54
  5. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/code_query.py +17 -2
  6. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/hierarchy_query.py +17 -2
  7. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/storage/lazy.py +29 -13
  8. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/storage/readers.py +24 -2
  9. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/README.md +0 -0
  10. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/__init__.py +0 -0
  11. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/__init__.py +0 -0
  12. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/client.py +0 -0
  13. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/federated.py +0 -0
  14. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/filters.py +0 -0
  15. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/policy.py +0 -0
  16. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/providers.py +0 -0
  17. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/query.py +0 -0
  18. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/registry.py +0 -0
  19. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/session.py +0 -0
  20. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/catalog.py +0 -0
  21. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/data_store.py +0 -0
  22. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/__init__.py +0 -0
  23. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  24. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  25. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  26. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  27. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  28. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  29. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  30. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  31. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  32. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  33. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  34. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  35. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  36. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  37. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  38. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  39. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/local/__init__.py +0 -0
  40. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/__init__.py +0 -0
  41. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/annotations.py +0 -0
  42. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/base.py +0 -0
  43. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/binding.py +0 -0
  44. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/category.py +0 -0
  45. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/codelist.py +0 -0
  46. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/collections.py +0 -0
  47. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/concept.py +0 -0
  48. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/constraint.py +0 -0
  49. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/convert.py +0 -0
  50. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/dataflow.py +0 -0
  51. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/dataset.py +0 -0
  52. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/datastructure.py +0 -0
  53. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/expr.py +0 -0
  54. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/hierarchy.py +0 -0
  55. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/in_memory_registry.py +0 -0
  56. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/istring.py +0 -0
  57. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/mapping.py +0 -0
  58. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/message.py +0 -0
  59. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/metadataflow.py +0 -0
  60. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/metadataset.py +0 -0
  61. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/metadatastructure.py +0 -0
  62. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/organisation.py +0 -0
  63. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/provision.py +0 -0
  64. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/ref.py +0 -0
  65. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/registry.py +0 -0
  66. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/representation.py +0 -0
  67. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/urn.py +0 -0
  68. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/validation.py +0 -0
  69. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/polars.py +0 -0
  70. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/py.typed +0 -0
  71. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/rest.py +0 -0
  72. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/sql.py +0 -0
  73. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/storage/__init__.py +0 -0
  74. {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/storage/schema.py +0 -0
  75. {sdmxlib-0.28.0 → sdmxlib-0.28.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.28.0
3
+ Version: 0.28.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.28.0"
3
+ version = "0.28.2"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -15,6 +15,7 @@ file-handle juggling.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import threading
18
19
  from collections.abc import Iterator
19
20
  from pathlib import Path
20
21
  from typing import TYPE_CHECKING, Literal, Self
@@ -98,6 +99,8 @@ class DuckDBDataStore:
98
99
  self._registry: Registry | None = None
99
100
  self._conn: duckdb.DuckDBPyConnection | None = None
100
101
  self._owns_connection = True
102
+ # Per-thread read cursor (#80); see Registry._read_cursor.
103
+ self._thread_local: threading.local = threading.local()
101
104
 
102
105
  @property
103
106
  def uri(self) -> Path:
@@ -171,6 +174,7 @@ class DuckDBDataStore:
171
174
  instance._registry = registry # noqa: SLF001
172
175
  instance._conn = registry.connection # noqa: SLF001
173
176
  instance._owns_connection = False # noqa: SLF001
177
+ instance._thread_local = threading.local() # noqa: SLF001
174
178
  instance._configure_session() # noqa: SLF001
175
179
  return instance
176
180
 
@@ -191,6 +195,15 @@ class DuckDBDataStore:
191
195
  # Loading extensions can change the search_path; reset to
192
196
  # ``sdmx`` so any colocated Registry's internal queries stay clean.
193
197
  self._conn.execute("SET search_path = 'sdmx'")
198
+ if self._needs_azure():
199
+ # Force the named curl transport adapter. The Azure SDK's
200
+ # default transport on Linux uses a statically-linked HTTP
201
+ # stack that doesn't honour the standard ``SSL_CERT_FILE`` /
202
+ # ``CURL_CA_BUNDLE`` environment variables, so it fails on
203
+ # any image whose CA store is at a non-default path. The
204
+ # ``curl`` adapter delegates to libcurl, which reads those
205
+ # env vars at handshake time.
206
+ self._conn.execute("SET azure_transport_option_type = 'curl'")
194
207
  if self._azure_credentials:
195
208
  sql = _azure_create_secret_sql(self._azure_credentials)
196
209
  self._conn.execute(sql)
@@ -227,6 +240,22 @@ class DuckDBDataStore:
227
240
  if self._conn is not None and self._owns_connection:
228
241
  self._conn.close()
229
242
  self._conn = None
243
+ self._thread_local = threading.local()
244
+
245
+ def _read_cursor(self) -> duckdb.DuckDBPyConnection:
246
+ """Return this thread's read cursor for ``query`` / ``query_stream``.
247
+
248
+ Mirror of :meth:`Registry._read_cursor` (#80). Lazily created on
249
+ first call per thread, reused for the thread's lifetime.
250
+ """
251
+ cur = getattr(self._thread_local, "cur", None)
252
+ if cur is not None:
253
+ return cur
254
+ conn = self.connection
255
+ cur = conn.cursor()
256
+ cur.execute("SET search_path = 'sdmx'")
257
+ self._thread_local.cur = cur
258
+ return cur
230
259
 
231
260
  # ── DataStore protocol ───────────────────────────────────────────────
232
261
 
@@ -267,8 +296,8 @@ class DuckDBDataStore:
267
296
  order_by=order_by,
268
297
  limit=limit,
269
298
  )
270
- # Per-call cursor for issue #75 (concurrent reads).
271
- cur = conn.cursor().execute(sql, sql_params)
299
+ # Thread-local cursor for issue #75 / #80 (concurrent reads).
300
+ cur = self._read_cursor().execute(sql, sql_params)
272
301
  col_names = [d[0] for d in cur.description] if cur.description else []
273
302
  rows = cur.fetchall()
274
303
  if not rows:
@@ -31,8 +31,7 @@ stashes the URI; the DuckDB connection is opened by ``open()`` /
31
31
  """
32
32
 
33
33
  import threading
34
- from collections.abc import Iterable, Iterator
35
- from contextlib import contextmanager
34
+ from collections.abc import Iterable
36
35
  from pathlib import Path
37
36
  from typing import Any, Self
38
37
 
@@ -220,6 +219,10 @@ class Registry:
220
219
  self._cursor: duckdb.DuckDBPyConnection | None = None
221
220
  self._owns_connection = True
222
221
  self._resolve_cache: _FIFOCache = _FIFOCache(resolve_cache_size)
222
+ # Per-thread read cursor (#80). Each thread that reads from this
223
+ # Registry lazily gets its own cursor on first call; subsequent
224
+ # calls on the same thread reuse it. Cleared on close().
225
+ self._thread_local: threading.local = threading.local()
223
226
 
224
227
  @property
225
228
  def uri(self) -> Path:
@@ -284,6 +287,7 @@ class Registry:
284
287
  instance._cursor = None # noqa: SLF001
285
288
  instance._owns_connection = False # noqa: SLF001
286
289
  instance._resolve_cache = _FIFOCache(resolve_cache_size) # noqa: SLF001
290
+ instance._thread_local = threading.local() # noqa: SLF001
287
291
  instance._initialise_schema() # noqa: SLF001
288
292
  return instance
289
293
 
@@ -317,11 +321,19 @@ class Registry:
317
321
  """Close the database connection (only if we own it).
318
322
 
319
323
  After ``close()``, the registry can be re-opened with ``open()``.
324
+ Drops the per-thread cursor cache — reopen starts fresh.
320
325
  """
321
326
  if self._conn is not None and self._owns_connection:
322
327
  self._conn.close()
323
328
  self._conn = None
324
329
  self._cursor = None
330
+ # Drop per-thread cursors. The cursor objects become orphaned —
331
+ # DuckDB's close() invalidates them; Python GC reclaims the
332
+ # handles. We can't iterate other threads' attributes on the
333
+ # threadlocal, but reassigning replaces the storage for *this*
334
+ # thread and lets the prior thread-local mapping get GC'd
335
+ # (other threads' attrs become unreachable too).
336
+ self._thread_local = threading.local()
325
337
 
326
338
  @property
327
339
  def _c(self) -> duckdb.DuckDBPyConnection:
@@ -354,22 +366,25 @@ class Registry:
354
366
  raise RuntimeError(msg)
355
367
  return self._conn
356
368
 
357
- @contextmanager
358
- def _read_cursor(self) -> Iterator[duckdb.DuckDBPyConnection]:
359
- """Open a fresh DuckDB cursor for the duration of one read call.
360
-
361
- DuckDB serialises ``execute()`` calls on a single connection but
362
- each connection has *one* default cursor whose row buffer is
363
- overwritten by every ``execute()``. Reads that fan out across
364
- threads must therefore each hold their own cursor — otherwise
365
- thread A's ``fetchone()`` can race thread B's ``execute()`` and
366
- return rows belonging to B's query. Fixed in #75/#78.
367
-
368
- Cursors on a read-only DuckDB connection are cheap (no
369
- transaction state); per-call create/close has negligible
370
- overhead at typical request rates. Sharing the connection not
371
- the cursor — keeps DuckLake / cataloglib federation working.
369
+ def _read_cursor(self) -> duckdb.DuckDBPyConnection:
370
+ """Return this thread's read cursor, creating it on first call.
371
+
372
+ Each thread gets its own cursor lazily; subsequent calls on the
373
+ same thread reuse it. DuckDB requires per-thread cursors (the
374
+ connection's default-cursor row buffer is overwritten by every
375
+ ``execute()`` see #75), but it does *not* require recreating
376
+ the cursor per call. Thread-local reuse amortises the
377
+ ``cursor()`` + ``SET search_path`` cost across the thread's
378
+ lifetime typically the entire process for a long-lived
379
+ FastAPI/uvicorn threadpool.
380
+
381
+ Cleanup: the thread-local storage is dropped by :meth:`close`;
382
+ otherwise it lives until the thread dies (Python GC closes the
383
+ cursor object).
372
384
  """
385
+ cur = getattr(self._thread_local, "cur", None)
386
+ if cur is not None:
387
+ return cur
373
388
  if self._conn is None:
374
389
  msg = (
375
390
  f"Registry({self._uri!r}) is not open. Use it as a context manager "
@@ -377,11 +392,9 @@ class Registry:
377
392
  )
378
393
  raise RuntimeError(msg)
379
394
  cur = self._conn.cursor()
380
- try:
381
- cur.execute("SET search_path = 'sdmx'")
382
- yield cur
383
- finally:
384
- cur.close()
395
+ cur.execute("SET search_path = 'sdmx'")
396
+ self._thread_local.cur = cur
397
+ return cur
385
398
 
386
399
  # ── Add ───────────────────────────────────────────────────────────────
387
400
 
@@ -449,15 +462,15 @@ class Registry:
449
462
  dsd.dimensions["FREQ"].codelist() # -> LazyCodelist
450
463
  dsd.code_map(lang="en") # works
451
464
  """
452
- with self._read_cursor() as cur:
453
- result = readers.get_artefact(cur, str(urn), eager=eager, lazy_conn=self._conn)
454
- if result is None:
455
- return None
456
- if not isinstance(result, (LazyCodelist, LazyHierarchy)):
457
- memo = _CallScopedMemo(self._resolve_cache)
458
- self._resolve_refs(cur, result, memo=memo)
459
- memo.promote()
460
- return result # type: ignore[return-value]
465
+ cur = self._read_cursor()
466
+ result = readers.get_artefact(cur, str(urn), eager=eager, lazy_conn=self._conn)
467
+ if result is None:
468
+ return None
469
+ if not isinstance(result, (LazyCodelist, LazyHierarchy)):
470
+ memo = _CallScopedMemo(self._resolve_cache)
471
+ self._resolve_refs(cur, result, memo=memo)
472
+ memo.promote()
473
+ return result # type: ignore[return-value]
461
474
 
462
475
  def get_all[T](self, artefact_type: "type[T]") -> "list[T]":
463
476
  """All artefacts of the given Python type.
@@ -467,14 +480,14 @@ class Registry:
467
480
  parent scheme referenced by multiple artefacts is loaded once,
468
481
  not once per artefact.
469
482
  """
470
- with self._read_cursor() as cur:
471
- results = readers.get_all(cur, artefact_type, lazy_conn=self._conn)
472
- memo = _CallScopedMemo(self._resolve_cache)
473
- for r in results:
474
- if not isinstance(r, (LazyCodelist, LazyHierarchy)):
475
- self._resolve_refs(cur, r, memo=memo)
476
- memo.promote()
477
- return results # type: ignore[return-value]
483
+ cur = self._read_cursor()
484
+ results = readers.get_all(cur, artefact_type, lazy_conn=self._conn)
485
+ memo = _CallScopedMemo(self._resolve_cache)
486
+ for r in results:
487
+ if not isinstance(r, (LazyCodelist, LazyHierarchy)):
488
+ self._resolve_refs(cur, r, memo=memo)
489
+ memo.promote()
490
+ return results # type: ignore[return-value]
478
491
 
479
492
  # ── Ref resolution ─────────────────────────────────────────────────
480
493
 
@@ -684,22 +697,22 @@ class Registry:
684
697
 
685
698
  def __contains__(self, urn: object) -> bool:
686
699
  if isinstance(urn, SdmxUrn):
687
- with self._read_cursor() as cur:
688
- row = cur.execute(
689
- "SELECT 1 FROM artefact WHERE urn = ?",
690
- [str(urn)],
691
- ).fetchone()
692
- return row is not None
700
+ cur = self._read_cursor()
701
+ row = cur.execute(
702
+ "SELECT 1 FROM artefact WHERE urn = ?",
703
+ [str(urn)],
704
+ ).fetchone()
705
+ return row is not None
693
706
  return False
694
707
 
695
708
  def __len__(self) -> int:
696
- with self._read_cursor() as cur:
697
- row = cur.execute("SELECT COUNT(*) FROM artefact").fetchone()
698
- return row[0] # type: ignore[index]
709
+ cur = self._read_cursor()
710
+ row = cur.execute("SELECT COUNT(*) FROM artefact").fetchone()
711
+ return row[0] # type: ignore[index]
699
712
 
700
713
  def __repr__(self) -> str:
701
- with self._read_cursor() as cur:
702
- rows = cur.execute("SELECT artefact_type, COUNT(*) FROM artefact GROUP BY artefact_type").fetchall()
714
+ cur = self._read_cursor()
715
+ rows = cur.execute("SELECT artefact_type, COUNT(*) FROM artefact GROUP BY artefact_type").fetchall()
703
716
  counts = ", ".join(f"{name}={cnt}" for name, cnt in rows)
704
717
  return f"Registry({counts or 'empty'})"
705
718
 
@@ -759,13 +772,11 @@ class Registry:
759
772
 
760
773
  def code_map(self, codelist_urn: "str | SdmxUrn[Any]", *, lang: str) -> dict[str, str]:
761
774
  """Return {code_id: label} for a codelist without creating Code objects."""
762
- with self._read_cursor() as cur:
763
- return readers.code_map(cur, str(codelist_urn), lang=lang)
775
+ return readers.code_map(self._read_cursor(), str(codelist_urn), lang=lang)
764
776
 
765
777
  def dsd_code_map(self, dsd_urn: "str | SdmxUrn[Any]", *, lang: str) -> dict[str, dict[str, str]]:
766
778
  """Return {component_id: {code_id: label}} for all enumerated dimensions."""
767
- with self._read_cursor() as cur:
768
- return readers.code_map_for_dsd(cur, str(dsd_urn), lang=lang)
779
+ return readers.code_map_for_dsd(self._read_cursor(), str(dsd_urn), lang=lang)
769
780
 
770
781
  # ── Bulk ──────────────────────────────────────────────────────────────
771
782
 
@@ -35,9 +35,24 @@ if TYPE_CHECKING:
35
35
  import duckdb
36
36
 
37
37
 
38
+ import threading as _threading
39
+ import weakref as _weakref
40
+
41
+ # Per-thread per-connection cursor store (#80). See ``sdmxlib.storage.lazy._cur``.
42
+ _thread_local = _threading.local()
43
+
44
+
38
45
  def _cur(conn: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConnection:
39
- """Open a fresh per-call cursor (issue #75). SQL here is sdmx.-qualified."""
40
- return conn.cursor()
46
+ """Return this thread's cursor for ``conn`` (issue #80)."""
47
+ table = getattr(_thread_local, "table", None)
48
+ if table is None:
49
+ table = _weakref.WeakKeyDictionary()
50
+ _thread_local.table = table
51
+ cur = table.get(conn)
52
+ if cur is None:
53
+ cur = conn.cursor()
54
+ table[conn] = cur
55
+ return cur
41
56
 
42
57
 
43
58
  # ── Field expressions (AST nodes) ───────────────────────────────────────
@@ -30,9 +30,24 @@ if TYPE_CHECKING:
30
30
  import duckdb
31
31
 
32
32
 
33
+ import threading as _threading
34
+ import weakref as _weakref
35
+
36
+ # Per-thread per-connection cursor store (#80). See ``sdmxlib.storage.lazy._cur``.
37
+ _thread_local = _threading.local()
38
+
39
+
33
40
  def _cur(conn: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConnection:
34
- """Open a fresh per-call cursor (issue #75). SQL here is sdmx.-qualified."""
35
- return conn.cursor()
41
+ """Return this thread's cursor for ``conn`` (issue #80)."""
42
+ table = getattr(_thread_local, "table", None)
43
+ if table is None:
44
+ table = _weakref.WeakKeyDictionary()
45
+ _thread_local.table = table
46
+ cur = table.get(conn)
47
+ if cur is None:
48
+ cur = conn.cursor()
49
+ table[conn] = cur
50
+ return cur
36
51
 
37
52
 
38
53
  # ── Field expressions ────────────────────────────────────────────────────
@@ -8,6 +8,9 @@ Hierarchy navigation (``roots``, ``leaves``, ``narrower``,
8
8
  appropriate persisted table (``sdmx.code`` or ``sdmx.hierarchy_node``).
9
9
  """
10
10
 
11
+ import threading
12
+ import weakref
13
+
11
14
  import duckdb
12
15
 
13
16
  from sdmxlib.model.code_query import (
@@ -46,25 +49,38 @@ def _map_to_istring(m: dict[str, str] | None) -> InternationalString:
46
49
  return InternationalString.of(m)
47
50
 
48
51
 
52
+ # Per-thread per-connection cursor store (#75 / #80). One cursor per
53
+ # (thread, connection) pair, created lazily on first use, reused for the
54
+ # thread's lifetime. Connections are held weakly so closing a Registry
55
+ # (which closes its connection and drops references) lets the entry GC
56
+ # without needing explicit cleanup hooks — no stale cursors after id
57
+ # reuse.
58
+ _thread_local = threading.local()
59
+
60
+
49
61
  def _cur(conn: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConnection:
50
- """Open a fresh per-call cursor (issue #75).
62
+ """Return this thread's cursor for ``conn``, creating on first call.
51
63
 
52
64
  Lazy facades and query classes share a long-lived connection across
53
- method calls, but each ``execute()`` needs its own cursor: DuckDB
54
- reuses a single default-cursor row buffer per connection, and
65
+ method calls, but each ``execute()`` needs its own per-thread cursor:
66
+ DuckDB reuses a single default-cursor row buffer per connection, and
55
67
  concurrent ``.execute()`` calls on the same buffer overwrite each
56
- other's results.
57
-
58
- No ``SET search_path`` here — all SQL in this module qualifies
59
- tables with ``sdmx.`` explicitly, so a fresh cursor's lack of
60
- inherited search path doesn't matter. (Other code paths that issue
61
- unqualified SQL set search_path themselves.)
68
+ other's results (issue #75). Thread-local reuse amortises the
69
+ ``cursor()`` cost across the thread's lifetime (issue #80).
62
70
 
63
- Caller pattern: ``rows = _cur(self._conn).execute(SQL, params).fetchall()``.
64
- The cursor object stays referenced through the chained call, then
65
- falls out of scope and is reclaimed.
71
+ No ``SET search_path`` all SQL in this module qualifies tables with
72
+ ``sdmx.`` explicitly. (Other code paths that issue unqualified SQL
73
+ set search_path themselves.)
66
74
  """
67
- return conn.cursor()
75
+ table = getattr(_thread_local, "table", None)
76
+ if table is None:
77
+ table = weakref.WeakKeyDictionary()
78
+ _thread_local.table = table
79
+ cur = table.get(conn)
80
+ if cur is None:
81
+ cur = conn.cursor()
82
+ table[conn] = cur
83
+ return cur
68
84
 
69
85
 
70
86
  class LazyCodelist:
@@ -13,6 +13,8 @@ Example::
13
13
  cm = readers.code_map(conn, codelist_urn, lang="en") # {code_id: label}
14
14
  """
15
15
 
16
+ import threading
17
+ import weakref
16
18
  from typing import Any, cast
17
19
 
18
20
  import duckdb
@@ -67,6 +69,26 @@ from sdmxlib.storage.writers import _CLASS_TO_NAME
67
69
  _TYPE_REGISTRY: dict[str, type] = {v: k for k, v in _CLASS_TO_NAME.items()}
68
70
 
69
71
 
72
+ # Per-thread cursor store for ``code_map`` / ``code_map_for_dsd`` (#80).
73
+ # Held weakly so a closed Registry's connection doesn't pin a stale
74
+ # cursor entry (avoids the id-reuse hazard a plain id-keyed dict would
75
+ # have when Python recycles the connection's id after GC).
76
+ _thread_local = threading.local()
77
+
78
+
79
+ def _thread_cursor(conn_or_cur: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConnection:
80
+ table = getattr(_thread_local, "table", None)
81
+ if table is None:
82
+ table = weakref.WeakKeyDictionary()
83
+ _thread_local.table = table
84
+ cur = table.get(conn_or_cur)
85
+ if cur is None:
86
+ cur = conn_or_cur.cursor()
87
+ cur.execute("SET search_path = 'sdmx'")
88
+ table[conn_or_cur] = cur
89
+ return cur
90
+
91
+
70
92
  # ── Shared helpers ──────────────────────────────────────────────────────
71
93
 
72
94
 
@@ -1547,7 +1569,7 @@ def code_map(
1547
1569
  upfront configuration. Opens its own per-call cursor (issue #75):
1548
1570
  safe to call concurrently from threads sharing the same connection.
1549
1571
  """
1550
- cur = conn.cursor()
1572
+ cur = _thread_cursor(conn)
1551
1573
  rows = cur.execute(
1552
1574
  f"""
1553
1575
  SELECT code_id, name['{lang}'] AS label
@@ -1570,7 +1592,7 @@ def code_map_for_dsd(
1570
1592
 
1571
1593
  Single JOIN query — no N+1. Per-call cursor as in :func:`code_map`.
1572
1594
  """
1573
- cur = conn.cursor()
1595
+ cur = _thread_cursor(conn)
1574
1596
  rows = cur.execute(
1575
1597
  f"""
1576
1598
  SELECT dc.component_id, c.code_id, c.name['{lang}'] AS label
File without changes
File without changes
File without changes
File without changes
File without changes