sdmxlib 0.27.1__tar.gz → 0.27.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.27.1 → sdmxlib-0.27.2}/PKG-INFO +1 -1
  2. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/pyproject.toml +1 -1
  3. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/local/_registry.py +161 -15
  4. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/README.md +0 -0
  5. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/__init__.py +0 -0
  6. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/api/__init__.py +0 -0
  7. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/api/client.py +0 -0
  8. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/api/federated.py +0 -0
  9. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/api/filters.py +0 -0
  10. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/api/policy.py +0 -0
  11. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/api/providers.py +0 -0
  12. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/api/query.py +0 -0
  13. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/api/registry.py +0 -0
  14. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/api/session.py +0 -0
  15. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/catalog.py +0 -0
  16. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/data_store.py +0 -0
  17. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/__init__.py +0 -0
  18. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  19. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  20. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  21. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  22. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  23. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  24. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  25. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  26. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  27. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  28. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  29. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  30. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  31. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  32. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  33. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  34. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/local/__init__.py +0 -0
  35. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/local/_data_store.py +0 -0
  36. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/__init__.py +0 -0
  37. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/annotations.py +0 -0
  38. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/base.py +0 -0
  39. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/binding.py +0 -0
  40. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/category.py +0 -0
  41. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/code_query.py +0 -0
  42. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/codelist.py +0 -0
  43. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/collections.py +0 -0
  44. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/concept.py +0 -0
  45. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/constraint.py +0 -0
  46. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/convert.py +0 -0
  47. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/dataflow.py +0 -0
  48. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/dataset.py +0 -0
  49. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/datastructure.py +0 -0
  50. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/expr.py +0 -0
  51. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/hierarchy.py +0 -0
  52. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/hierarchy_query.py +0 -0
  53. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/in_memory_registry.py +0 -0
  54. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/istring.py +0 -0
  55. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/mapping.py +0 -0
  56. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/message.py +0 -0
  57. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/metadataflow.py +0 -0
  58. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/metadataset.py +0 -0
  59. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/metadatastructure.py +0 -0
  60. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/organisation.py +0 -0
  61. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/provision.py +0 -0
  62. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/ref.py +0 -0
  63. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/registry.py +0 -0
  64. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/representation.py +0 -0
  65. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/urn.py +0 -0
  66. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/model/validation.py +0 -0
  67. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/polars.py +0 -0
  68. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/py.typed +0 -0
  69. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/rest.py +0 -0
  70. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/sql.py +0 -0
  71. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/storage/__init__.py +0 -0
  72. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/storage/lazy.py +0 -0
  73. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/storage/readers.py +0 -0
  74. {sdmxlib-0.27.1 → sdmxlib-0.27.2}/src/sdmxlib/storage/schema.py +0 -0
  75. {sdmxlib-0.27.1 → sdmxlib-0.27.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.27.1
3
+ Version: 0.27.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.27.1"
3
+ version = "0.27.2"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -30,6 +30,7 @@ stashes the URI; the DuckDB connection is opened by ``open()`` /
30
30
  local.add(...)
31
31
  """
32
32
 
33
+ from collections import OrderedDict
33
34
  from collections.abc import Iterable
34
35
  from pathlib import Path
35
36
  from typing import Any, Self
@@ -57,6 +58,90 @@ _ITEM_TO_SCHEME: dict[str, tuple[str, str]] = {
57
58
  }
58
59
 
59
60
 
61
+ DEFAULT_RESOLVE_CACHE_SIZE = 256
62
+
63
+
64
+ class _LRUDict(OrderedDict[str, Any]):
65
+ """Bounded LRU mapping used as the registry-level resolver cache.
66
+
67
+ Subclasses :class:`OrderedDict` so it can be passed wherever the
68
+ resolver expects ``dict[str, Any]``. Inserts evict least-recently-used
69
+ entries past ``maxsize``; reads via :meth:`get` move keys to the end.
70
+ ``maxsize == 0`` disables caching — inserts are silently dropped, so
71
+ the structure stays empty.
72
+
73
+ Tracks ``hits`` / ``misses`` for :meth:`Registry.resolve_cache_info`;
74
+ ``__contains__`` is treated as internal control flow (the resolver
75
+ uses it for dedup) and is not counted.
76
+ """
77
+
78
+ def __init__(self, maxsize: int) -> None:
79
+ super().__init__()
80
+ if maxsize < 0:
81
+ msg = f"resolve_cache_size must be >= 0, got {maxsize}"
82
+ raise ValueError(msg)
83
+ self.maxsize = maxsize
84
+ self.hits = 0
85
+ self.misses = 0
86
+
87
+ def __setitem__(self, key: str, value: Any) -> None:
88
+ if self.maxsize == 0:
89
+ return
90
+ if key in self:
91
+ self.move_to_end(key)
92
+ super().__setitem__(key, value)
93
+ while len(self) > self.maxsize:
94
+ self.popitem(last=False)
95
+
96
+ def get(self, key: str, default: Any = None) -> Any: # type: ignore[override]
97
+ if super().__contains__(key):
98
+ self.hits += 1
99
+ self.move_to_end(key)
100
+ return super().__getitem__(key)
101
+ self.misses += 1
102
+ return default
103
+
104
+ def reset_stats(self) -> None:
105
+ self.hits = 0
106
+ self.misses = 0
107
+
108
+
109
+ class _CallScopedMemo(dict[str, Any]):
110
+ """Per-call resolver memo layered over a persistent LRU cache.
111
+
112
+ Two reasons for the overlay rather than passing the LRU directly:
113
+
114
+ 1. **Correctness under tight cache sizes (incl. ``maxsize=0``).** The
115
+ resolver writes URN→target during one phase and reads them back in
116
+ a later phase of the same call. If those writes went straight to
117
+ the LRU, an entry could be evicted before the read — leaving Refs
118
+ unresolved for callers using a small (or disabled) cache. The
119
+ overlay holds in-flight entries in an unbounded per-call dict so
120
+ single-call resolution is independent of cache size.
121
+ 2. **Promote-on-success.** Entries written during the call enter the
122
+ LRU only at the end of the call, via :meth:`promote`. That keeps
123
+ partial-failure semantics clean: if a call raises midway, the LRU
124
+ is unchanged.
125
+ """
126
+
127
+ def __init__(self, lru: _LRUDict) -> None:
128
+ super().__init__()
129
+ self._lru = lru
130
+
131
+ def __contains__(self, key: object) -> bool:
132
+ return super().__contains__(key) or self._lru.__contains__(key)
133
+
134
+ def get(self, key: str, default: Any = None) -> Any: # type: ignore[override]
135
+ if super().__contains__(key):
136
+ return super().__getitem__(key)
137
+ return self._lru.get(key, default)
138
+
139
+ def promote(self) -> None:
140
+ """Copy this call's resolved entries into the persistent LRU."""
141
+ for k, v in self.items():
142
+ self._lru[k] = v
143
+
144
+
60
145
  class Registry:
61
146
  """DuckDB-backed persistent SDMX artefact store.
62
147
 
@@ -86,12 +171,22 @@ class Registry:
86
171
  uri: str | Path,
87
172
  *,
88
173
  source_registry: str | None = None,
174
+ resolve_cache_size: int = DEFAULT_RESOLVE_CACHE_SIZE,
89
175
  ) -> None:
176
+ """Open or wrap a DuckDB-backed registry.
177
+
178
+ ``resolve_cache_size`` bounds the LRU of resolved Ref targets that
179
+ :meth:`get` / :meth:`get_all` / :meth:`to_registry` share across
180
+ calls. Pass ``0`` to disable caching (matches pre-v0.27.2 behaviour
181
+ — one full read per Ref). The cache is cleared on every mutation
182
+ (``add`` / ``drop`` / ``replace``) for invalidation correctness.
183
+ """
90
184
  self._uri = Path(uri) if isinstance(uri, str) else uri
91
185
  self._source_registry = source_registry
92
186
  self._conn: duckdb.DuckDBPyConnection | None = None
93
187
  self._cursor: duckdb.DuckDBPyConnection | None = None
94
188
  self._owns_connection = True
189
+ self._resolve_cache: _LRUDict = _LRUDict(resolve_cache_size)
95
190
 
96
191
  @property
97
192
  def uri(self) -> Path:
@@ -137,6 +232,7 @@ class Registry:
137
232
  conn: duckdb.DuckDBPyConnection,
138
233
  *,
139
234
  source_registry: str | None = None,
235
+ resolve_cache_size: int = DEFAULT_RESOLVE_CACHE_SIZE,
140
236
  ) -> Self:
141
237
  """Wrap an existing DuckDB connection.
142
238
 
@@ -144,6 +240,9 @@ class Registry:
144
240
  Registry leaves the connection open. Use this when the connection
145
241
  is shared with another component (e.g. a co-located DuckLake catalog)
146
242
  that owns its lifecycle.
243
+
244
+ ``resolve_cache_size`` configures the resolver LRU; see
245
+ :meth:`__init__`.
147
246
  """
148
247
  instance = cls.__new__(cls)
149
248
  instance._uri = Path(":connection:") # noqa: SLF001 — alternate constructor sets private state
@@ -151,6 +250,7 @@ class Registry:
151
250
  instance._conn = conn # noqa: SLF001
152
251
  instance._cursor = None # noqa: SLF001
153
252
  instance._owns_connection = False # noqa: SLF001
253
+ instance._resolve_cache = _LRUDict(resolve_cache_size) # noqa: SLF001
154
254
  instance._initialise_schema() # noqa: SLF001
155
255
  return instance
156
256
 
@@ -209,9 +309,14 @@ class Registry:
209
309
 
210
310
  ``source_registry`` overrides the registry-level default for this
211
311
  call. ``None`` (default) means "use the registry-level default."
312
+
313
+ Clears the resolver cache: any cached resolved target may now be
314
+ stale (e.g. a freshly-added Codelist replaces the one a previous
315
+ ``get`` had cached).
212
316
  """
213
317
  tag = source_registry if source_registry is not None else self._source_registry
214
318
  writers.put_artefact(self._c, artefact, source_registry=tag)
319
+ self._resolve_cache.clear()
215
320
 
216
321
  def add_all(
217
322
  self,
@@ -256,22 +361,25 @@ class Registry:
256
361
  if result is None:
257
362
  return None
258
363
  if not isinstance(result, (LazyCodelist, LazyHierarchy)):
259
- self._resolve_refs(result)
364
+ memo = _CallScopedMemo(self._resolve_cache)
365
+ self._resolve_refs(result, memo=memo)
366
+ memo.promote()
260
367
  return result # type: ignore[return-value]
261
368
 
262
369
  def get_all[T](self, artefact_type: "type[T]") -> "list[T]":
263
370
  """All artefacts of the given Python type.
264
371
 
265
- Shares a single resolver memo across the batch so any parent
266
- scheme referenced by multiple artefacts is loaded once, not once
267
- per artefact.
372
+ Resolution shares one call-scoped memo across the batch and is
373
+ backed by the registry's persistent ``_resolve_cache``, so any
374
+ parent scheme referenced by multiple artefacts is loaded once,
375
+ not once per artefact.
268
376
  """
269
377
  results = readers.get_all(self._c, artefact_type)
270
- memo: dict[str, Any] = {}
271
- walked: set[int] = set()
378
+ memo = _CallScopedMemo(self._resolve_cache)
272
379
  for r in results:
273
380
  if not isinstance(r, (LazyCodelist, LazyHierarchy)):
274
- self._resolve_refs(r, memo=memo, walked=walked)
381
+ self._resolve_refs(r, memo=memo)
382
+ memo.promote()
275
383
  return results # type: ignore[return-value]
276
384
 
277
385
  # ── Ref resolution ─────────────────────────────────────────────────
@@ -498,14 +606,51 @@ class Registry:
498
606
  # ── Mutations ─────────────────────────────────────────────────────────
499
607
 
500
608
  def drop(self, urn: "SdmxUrn[Any]") -> bool:
501
- """Remove an artefact by URN. Returns True if it existed."""
502
- return writers.drop_artefact(self._c, str(urn))
609
+ """Remove an artefact by URN. Returns True if it existed.
610
+
611
+ Clears the resolver cache (cached resolved targets may reference
612
+ the dropped artefact).
613
+ """
614
+ result = writers.drop_artefact(self._c, str(urn))
615
+ self._resolve_cache.clear()
616
+ return result
503
617
 
504
618
  def replace(self, old_urn: "SdmxUrn[Any]", new: Any) -> None:
505
- """Remove the old artefact and add the new one."""
619
+ """Remove the old artefact and add the new one.
620
+
621
+ Each leg clears the resolver cache via :meth:`add` / :meth:`drop`;
622
+ the net effect is a single fully-cleared cache at the end.
623
+ """
506
624
  writers.drop_artefact(self._c, str(old_urn))
507
625
  self.add(new)
508
626
 
627
+ # ── Resolver cache ────────────────────────────────────────────────────
628
+
629
+ def clear_resolve_cache(self) -> None:
630
+ """Purge the resolver cache and reset hit/miss counters.
631
+
632
+ Useful for explicit invalidation hooks (e.g. a webhook telling the
633
+ service that upstream metadata changed without going through this
634
+ registry's mutation API). Cache also clears automatically on every
635
+ ``add`` / ``drop`` / ``replace``.
636
+ """
637
+ self._resolve_cache.clear()
638
+ self._resolve_cache.reset_stats()
639
+
640
+ def resolve_cache_info(self) -> dict[str, int]:
641
+ """Return resolver cache stats: size, max_size, hits, misses.
642
+
643
+ Intended for ops dashboards and integration tests asserting cache
644
+ behaviour. ``size == 0`` after construction or any mutation; rises
645
+ as ``get`` / ``get_all`` populate it, capped at ``max_size``.
646
+ """
647
+ return {
648
+ "size": len(self._resolve_cache),
649
+ "max_size": self._resolve_cache.maxsize,
650
+ "hits": self._resolve_cache.hits,
651
+ "misses": self._resolve_cache.misses,
652
+ }
653
+
509
654
  # ── Direct SQL helpers ────────────────────────────────────────────────
510
655
 
511
656
  def code_map(self, codelist_urn: "str | SdmxUrn[Any]", *, lang: str) -> dict[str, str]:
@@ -536,17 +681,18 @@ class Registry:
536
681
  def to_registry(self) -> InMemoryRegistry:
537
682
  """Load everything into an in-memory Registry with full Ref interning.
538
683
 
539
- Shares a resolver memo across the bulk load so each parent scheme
540
- is read at most once even when many artefacts reference it.
684
+ Resolution uses the registry's persistent ``_resolve_cache`` so each
685
+ parent scheme is read at most once even when many artefacts
686
+ reference it.
541
687
  """
542
688
  reg = InMemoryRegistry()
543
- memo: dict[str, Any] = {}
544
- walked: set[int] = set()
689
+ memo = _CallScopedMemo(self._resolve_cache)
545
690
  rows = self._c.execute("SELECT urn, artefact_type FROM artefact").fetchall()
546
691
  for urn_str, _artefact_type in rows:
547
692
  obj = readers.get_artefact(self._c, urn_str, eager=True)
548
693
  if obj is None:
549
694
  continue
550
- self._resolve_refs(obj, memo=memo, walked=walked)
695
+ self._resolve_refs(obj, memo=memo)
551
696
  reg.add(obj)
697
+ memo.promote()
552
698
  return reg
File without changes
File without changes
File without changes
File without changes
File without changes