statcatlib 0.8.0__tar.gz → 0.9.1__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 (35) hide show
  1. {statcatlib-0.8.0 → statcatlib-0.9.1}/PKG-INFO +6 -6
  2. {statcatlib-0.8.0 → statcatlib-0.9.1}/README.md +1 -1
  3. {statcatlib-0.8.0 → statcatlib-0.9.1}/pyproject.toml +6 -6
  4. {statcatlib-0.8.0 → statcatlib-0.9.1}/pyproject.toml.orig +13 -10
  5. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/__init__.py +2 -0
  6. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/sources/ckan.py +6 -6
  7. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/sources/sdmx.py +11 -15
  8. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/store/__init__.py +1 -0
  9. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/store/lance.py +1 -1
  10. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/store/store.py +108 -2
  11. {statcatlib-0.8.0 → statcatlib-0.9.1}/LICENSE +0 -0
  12. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/bundle.py +0 -0
  13. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/cli/__init__.py +0 -0
  14. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/expr.py +0 -0
  15. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/__init__.py +0 -0
  16. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/agents.py +0 -0
  17. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/catalog.py +0 -0
  18. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/classification.py +0 -0
  19. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/dataset.py +0 -0
  20. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/distribution.py +0 -0
  21. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/licence.py +0 -0
  22. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/quality.py +0 -0
  23. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/skos.py +0 -0
  24. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/spatial.py +0 -0
  25. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/temporal.py +0 -0
  26. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/model/types.py +0 -0
  27. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/py.typed +0 -0
  28. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/query.py +0 -0
  29. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/sources/__init__.py +0 -0
  30. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/store/row.py +0 -0
  31. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/store/schema.py +0 -0
  32. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/store/scopes.py +0 -0
  33. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/store/sentinel.py +0 -0
  34. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/store/xhtml.py +0 -0
  35. {statcatlib-0.8.0 → statcatlib-0.9.1}/src/statcatlib/urn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statcatlib
3
- Version: 0.8.0
3
+ Version: 0.9.1
4
4
  Summary: Queryable open data catalog engine for DCAT-AP, StatDCAT-AP, CKAN, and SDMX
5
5
  Keywords: sdmx,dcat,open-data,catalog,duckdb,statistical-data,ckan
6
6
  Author: gabrielgellner
@@ -11,19 +11,19 @@ Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Intended Audience :: Science/Research
13
13
  Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Topic :: Database
16
16
  Classifier: Topic :: Scientific/Engineering :: Information Analysis
17
17
  Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
18
18
  Requires-Dist: attrs>=26.1.0
19
- Requires-Dist: duckdb>=1.1,<2
19
+ Requires-Dist: duckdb>=2.0.0
20
20
  Requires-Dist: httpx>=0.28
21
- Requires-Dist: sdmxlib>=0.67
21
+ Requires-Dist: sdmxlib>=0.70.1
22
22
  Requires-Dist: lancedb>=0.39 ; extra == 'search'
23
23
  Requires-Dist: sentence-transformers>=3.0 ; extra == 'search'
24
24
  Requires-Dist: typer>=0.15 ; extra == 'search'
25
25
  Requires-Dist: rich>=15.0 ; extra == 'search'
26
- Requires-Python: >=3.13
26
+ Requires-Python: >=3.14
27
27
  Project-URL: Homepage, https://github.com/StatCat-Suite/statcatlib
28
28
  Project-URL: Repository, https://github.com/StatCat-Suite/statcatlib
29
29
  Project-URL: Documentation, https://statcat-suite.github.io/statcatlib
@@ -65,7 +65,7 @@ SQL JOINs are the graph traversal. No network round-trips at query time.
65
65
 
66
66
  ## Installation
67
67
 
68
- Requires Python 3.13+. Managed with [uv](https://docs.astral.sh/uv/).
68
+ Requires Python 3.14+. Managed with [uv](https://docs.astral.sh/uv/).
69
69
 
70
70
  ```bash
71
71
  uv add statcatlib
@@ -31,7 +31,7 @@ SQL JOINs are the graph traversal. No network round-trips at query time.
31
31
 
32
32
  ## Installation
33
33
 
34
- Requires Python 3.13+. Managed with [uv](https://docs.astral.sh/uv/).
34
+ Requires Python 3.14+. Managed with [uv](https://docs.astral.sh/uv/).
35
35
 
36
36
  ```bash
37
37
  uv add statcatlib
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "statcatlib"
3
- version = "0.8.0"
3
+ version = "0.9.1"
4
4
  description = "Queryable open data catalog engine for DCAT-AP, StatDCAT-AP, CKAN, and SDMX"
5
5
  license = "Apache-2.0"
6
6
  license-files = ["LICENSE"]
7
7
  readme = "README.md"
8
- requires-python = ">=3.13"
8
+ requires-python = ">=3.14"
9
9
  keywords = [
10
10
  "sdmx",
11
11
  "dcat",
@@ -20,16 +20,16 @@ classifiers = [
20
20
  "Intended Audience :: Developers",
21
21
  "Intended Audience :: Science/Research",
22
22
  "Programming Language :: Python :: 3",
23
- "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
24
  "Topic :: Database",
25
25
  "Topic :: Scientific/Engineering :: Information Analysis",
26
26
  "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
27
27
  ]
28
28
  dependencies = [
29
29
  "attrs>=26.1.0",
30
- "duckdb>=1.1,<2",
30
+ "duckdb>=2.0.0",
31
31
  "httpx>=0.28",
32
- "sdmxlib>=0.67",
32
+ "sdmxlib>=0.70.1",
33
33
  ]
34
34
 
35
35
  [[project.authors]]
@@ -79,7 +79,7 @@ markers = [
79
79
  ]
80
80
 
81
81
  [tool.basedpyright]
82
- pythonVersion = "3.13"
82
+ pythonVersion = "3.14"
83
83
  typeCheckingMode = "standard"
84
84
  venvPath = "."
85
85
  venv = ".venv"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statcatlib"
3
- version = "0.8.0"
3
+ version = "0.9.1"
4
4
  description = "Queryable open data catalog engine for DCAT-AP, StatDCAT-AP, CKAN, and SDMX"
5
5
  license = "Apache-2.0"
6
6
  license-files = ["LICENSE"]
@@ -8,27 +8,30 @@ readme = "README.md"
8
8
  authors = [
9
9
  { name = "gabrielgellner", email = "gabrielgellner@gmail.com" }
10
10
  ]
11
- requires-python = ">=3.13"
11
+ requires-python = ">=3.14"
12
12
  keywords = ["sdmx", "dcat", "open-data", "catalog", "duckdb", "statistical-data", "ckan"]
13
13
  classifiers = [
14
14
  "Development Status :: 3 - Alpha",
15
15
  "Intended Audience :: Developers",
16
16
  "Intended Audience :: Science/Research",
17
17
  "Programming Language :: Python :: 3",
18
- "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
19
  "Topic :: Database",
20
20
  "Topic :: Scientific/Engineering :: Information Analysis",
21
21
  "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
22
22
  ]
23
23
  dependencies = [
24
24
  "attrs>=26.1.0",
25
- # Below 2: on Python 3.13 the newest sdmxlib that resolves is 0.67.1 (0.68+
26
- # needs 3.14), and it does not cap DuckDB itself, but its lake writes fail
27
- # under DuckDB 2.0 — sdmxlib 0.69.1 added the same cap for the same reason.
28
- # Lift it with the move to an sdmxlib that supports DuckDB 2.
29
- "duckdb>=1.1,<2",
25
+ # >=2.0: sdmxlib 0.70 moved its store to DuckDB 2.0, and the catalog
26
+ # shares that store's connection. A catalog written here cannot be opened
27
+ # by DuckDB 1.x, so by statcatlib 0.8 or earlier.
28
+ "duckdb>=2.0.0",
30
29
  "httpx>=0.28",
31
- "sdmxlib>=0.67",
30
+ # >=0.70: needs Python 3.14 and DuckDB 2.0; `reg.data()` is gone, and a
31
+ # component without a concept is refused as it is built. >=0.70.1:
32
+ # `reg.obs()` asks Eurostat for CSV it serves, so `ingest_data()` can
33
+ # fetch from it (sdmxlib GH-50).
34
+ "sdmxlib>=0.70.1",
32
35
  ]
33
36
 
34
37
  [project.optional-dependencies]
@@ -95,7 +98,7 @@ dev = [
95
98
  ]
96
99
 
97
100
  [tool.basedpyright]
98
- pythonVersion = "3.13"
101
+ pythonVersion = "3.14"
99
102
  typeCheckingMode = "standard"
100
103
  venvPath = "."
101
104
  venv = ".venv"
@@ -83,6 +83,7 @@ from statcatlib.store import (
83
83
  Hit,
84
84
  SEARCH_CONTEXT_ANNOTATION_TYPE,
85
85
  SEARCH_EXCLUDE_ANNOTATION_TYPE,
86
+ IncompatibleCatalogError,
86
87
  IncompatibleIndexError,
87
88
  QueryBuilder,
88
89
  ReferenceMetadataBlend,
@@ -145,6 +146,7 @@ __all__ = [
145
146
  "Frequency",
146
147
  "GeospatialDataset",
147
148
  "Hit",
149
+ "IncompatibleCatalogError",
148
150
  "IncompatibleIndexError",
149
151
  "InternationalString",
150
152
  "LicenceDocument",
@@ -206,7 +206,7 @@ def _parse_istring(
206
206
  if fr_val2 := parsed.get(fr_key):
207
207
  kw2["fr"] = fr_val2
208
208
  return InternationalString(**kw2)
209
- except (json.JSONDecodeError, ValueError):
209
+ except json.JSONDecodeError, ValueError:
210
210
  pass
211
211
  return InternationalString(en=raw or fallback_en)
212
212
  return InternationalString(en=fallback_en)
@@ -231,7 +231,7 @@ def _parse_keywords(raw: object) -> list[InternationalString]:
231
231
  if isinstance(raw, str):
232
232
  try:
233
233
  raw = json.loads(raw)
234
- except (json.JSONDecodeError, ValueError):
234
+ except json.JSONDecodeError, ValueError:
235
235
  return []
236
236
  if not isinstance(raw, dict):
237
237
  return []
@@ -255,7 +255,7 @@ def _parse_frequency(raw: object) -> Frequency | None:
255
255
  parsed = json.loads(raw)
256
256
  if isinstance(parsed, dict):
257
257
  raw = parsed
258
- except (json.JSONDecodeError, ValueError):
258
+ except json.JSONDecodeError, ValueError:
259
259
  pass
260
260
  if isinstance(raw, dict):
261
261
  en_label = (raw.get("en") or "").strip()
@@ -285,7 +285,7 @@ def _parse_subjects(raw: object) -> list[ConceptRef]:
285
285
  if isinstance(raw, str):
286
286
  try:
287
287
  raw = json.loads(raw)
288
- except (json.JSONDecodeError, ValueError):
288
+ except json.JSONDecodeError, ValueError:
289
289
  raw = [raw]
290
290
  if not isinstance(raw, list):
291
291
  return []
@@ -302,7 +302,7 @@ def _parse_spatial(raw: object) -> list[SpatialCoverage]:
302
302
  if isinstance(raw, str):
303
303
  try:
304
304
  raw = json.loads(raw)
305
- except (json.JSONDecodeError, ValueError):
305
+ except json.JSONDecodeError, ValueError:
306
306
  raw = [raw]
307
307
  if not isinstance(raw, list):
308
308
  return []
@@ -427,7 +427,7 @@ def _ckan_package_to_dataset(raw: dict[str, object]) -> OpenDataset:
427
427
  if isinstance(lang_raw, str):
428
428
  try:
429
429
  lang_raw = json.loads(lang_raw)
430
- except (json.JSONDecodeError, ValueError):
430
+ except json.JSONDecodeError, ValueError:
431
431
  lang_raw = [lang_raw]
432
432
  language: list[str] = [str(lang) for lang in _seq(lang_raw) if lang]
433
433
 
@@ -240,8 +240,8 @@ def ingest_data(
240
240
  ) -> str:
241
241
  """Materialise observation data for one dataflow into the bundle's lake.
242
242
 
243
- Fetches the (wide) observations via ``reg.data(...).pivot()``, writes
244
- them into ``lake.<agency>.df_<id>_<version>`` through the bundle's
243
+ Fetches the observations wide via ``reg.obs(...).pivot()``, writes them
244
+ into ``lake.<agency>.df_<id>_<version>`` through the bundle's
245
245
  DuckLake-backed data store, and registers a
246
246
  `sdmxlib.DataflowBinding` so the dataflow is queryable through
247
247
  ``cat.observations(...)``.
@@ -257,7 +257,7 @@ def ingest_data(
257
257
  identifier: Catalog identifier in ``AGENCY:ID(VERSION)`` form.
258
258
  ``(latest)`` resolves to the highest stored version.
259
259
  measure_dim: Dimension to pivot into measure columns. ``None``
260
- (default) leaves the data long.
260
+ (default) pivots on the DSD's ``MeasureDimension``.
261
261
 
262
262
  Returns:
263
263
  The fully-qualified table location (``<schema>.<table>``).
@@ -266,8 +266,9 @@ def ingest_data(
266
266
  KeyError: No `AggregateDataset` is stored under *identifier* — run
267
267
  ``ingest_sdmx`` first.
268
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.
269
+ nothing to fetch observations for; *identifier* is not in
270
+ ``AGENCY:ID(VERSION)`` form; or *measure_dim* is ``None`` and the
271
+ DSD declares no ``MeasureDimension`` to pivot on.
271
272
  """
272
273
  identifier = _resolve_latest(store, identifier)
273
274
 
@@ -289,21 +290,16 @@ def ingest_data(
289
290
  table = f"df_{flow_id}_{version.replace('.', '_')}".lower()
290
291
 
291
292
  # 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)
293
+ # returns an eager DataFrame; with ``on=None`` it pivots on the DSD's
294
+ # MeasureDimension, as ``reg.data(...).pivot()`` did.
295
+ flow_urn = Dataflow.parse_urn(ds.sdmx_dataflow_urn)
296
+ wide = reg.obs(flow_urn).pivot(on=measure_dim)
297
+ store.data.write_observations(schema=schema, table=table, source=wide)
301
298
 
302
299
  # Register a DataflowBinding so ``cat.observations(flow_urn)`` can find
303
300
  # the table. Derive identity column maps from the DSD when resolvable.
304
301
  import sdmxlib as sl # noqa: PLC0415
305
302
 
306
- flow_urn = Dataflow.parse_urn(ds.sdmx_dataflow_urn)
307
303
  flow = store.registry.get(flow_urn)
308
304
  if isinstance(flow, Dataflow) and isinstance(flow.structure, sl.Ref):
309
305
  dsd = store.registry.get(flow.structure.urn)
@@ -28,6 +28,7 @@ from statcatlib.store.store import (
28
28
  Hit,
29
29
  SEARCH_CONTEXT_ANNOTATION_TYPE,
30
30
  SEARCH_EXCLUDE_ANNOTATION_TYPE,
31
+ IncompatibleCatalogError,
31
32
  IncompatibleIndexError,
32
33
  QueryBuilder,
33
34
  ReferenceMetadataBlend,
@@ -1043,7 +1043,7 @@ class LanceIndex:
1043
1043
  """
1044
1044
  try:
1045
1045
  data = json.loads((self._path / _MANIFEST_FILE).read_text())
1046
- except (OSError, ValueError):
1046
+ except OSError, ValueError:
1047
1047
  return None
1048
1048
  return data if isinstance(data, dict) else None
1049
1049
 
@@ -27,6 +27,7 @@ import sdmxlib.model.datastructure
27
27
  from attrs import Factory, define, field
28
28
  from sdmxlib import InternationalString
29
29
  from sdmxlib.model.registry import artefact_urn
30
+ from sdmxlib.storage.schema import SCHEMA_VERSION as _SDMX_SCHEMA_VERSION
30
31
 
31
32
  from statcatlib.bundle import Embedded
32
33
  from statcatlib.model.agents import Agent, ContactPoint
@@ -143,6 +144,60 @@ class IncompatibleIndexError(ValueError):
143
144
  """
144
145
 
145
146
 
147
+ class IncompatibleCatalogError(RuntimeError):
148
+ """Raised on open when the catalog's ``sdmx`` schema is another sdmxlib's.
149
+
150
+ sdmxlib treats its store as a cache: on a schema-version mismatch,
151
+ `LocalRegistry` drops the ``sdmx`` schema and rebuilds it empty. Here
152
+ that schema is not a cache. It is the structural half of the catalog,
153
+ which ``statcat.dataset`` links into by URN, so the rebuild would leave
154
+ every dataset pointing at nothing, and structural queries answering
155
+ empty rather than failing.
156
+ """
157
+
158
+
159
+ def _refuse_sdmx_schema_rebuild(conn: duckdb.DuckDBPyConnection) -> None:
160
+ """Refuse to open a catalog whose SDMX structure sdmxlib would drop.
161
+
162
+ Runs before `LocalRegistry.from_connection`, which is where the drop
163
+ happens. A missing ``sdmx`` schema, or one at another version that holds
164
+ no artefacts, has nothing to lose, so sdmxlib is left to create or
165
+ rebuild it.
166
+
167
+ Read-only opens are refused here too. sdmxlib would refuse them itself,
168
+ but its message says to reopen read-write and rebuild in place, which
169
+ for a catalog is the data loss this guard exists to prevent.
170
+
171
+ Args:
172
+ conn: The catalog's connection, before sdmxlib has touched it.
173
+
174
+ Raises:
175
+ IncompatibleCatalogError: The stored ``sdmx`` schema is at another
176
+ version and holds artefacts.
177
+ """
178
+ try:
179
+ row = conn.execute("""
180
+ SELECT value FROM sdmx._meta WHERE key = 'schema_version'
181
+ """).fetchone()
182
+ stored = row[0] if row else None
183
+ artefacts = conn.execute("""
184
+ SELECT count(*) FROM sdmx.artefact
185
+ """).fetchone()
186
+ except duckdb.CatalogException:
187
+ return
188
+ n_artefacts = artefacts[0] if artefacts else 0
189
+ if stored == _SDMX_SCHEMA_VERSION or n_artefacts == 0:
190
+ return
191
+ msg = (
192
+ f"this catalog's SDMX structure is stored in sdmxlib schema {stored}, and this "
193
+ f"build of sdmxlib uses schema {_SDMX_SCHEMA_VERSION}. sdmxlib migrates by dropping "
194
+ f"the sdmx schema, which would delete the {n_artefacts} artefacts this catalog's "
195
+ f"datasets link to. Rebuild the catalog from its sources with this version, or "
196
+ f"open it with the statcatlib release that built it."
197
+ )
198
+ raise IncompatibleCatalogError(msg)
199
+
200
+
146
201
  @define(frozen=True)
147
202
  class EmbeddingMetadata:
148
203
  """Read-side view of a bundle's ``search/embeddings.json`` sidecar.
@@ -237,7 +292,7 @@ def _read_embeddings_meta(search_dir: Path) -> dict[str, object] | None:
237
292
  return None
238
293
  try:
239
294
  data = json.loads(meta_path.read_text(encoding="utf-8"))
240
- except (OSError, json.JSONDecodeError):
295
+ except OSError, json.JSONDecodeError:
241
296
  return None
242
297
  if not isinstance(data, dict):
243
298
  return None
@@ -2677,6 +2732,16 @@ class Catalog:
2677
2732
 
2678
2733
  with Catalog("my_catalog").open() as cat:
2679
2734
  ...
2735
+
2736
+ Raises `IncompatibleCatalogError` when the catalog's SDMX structure
2737
+ was stored by an sdmxlib with another store schema, which this one
2738
+ would drop on open. The connection is closed again first.
2739
+
2740
+ Returns:
2741
+ This catalog, open.
2742
+
2743
+ Raises:
2744
+ ValueError: ``embedding_model`` was given for an in-memory catalog.
2680
2745
  """
2681
2746
  if self._conn is not None:
2682
2747
  return self
@@ -2697,7 +2762,7 @@ class Catalog:
2697
2762
  # schema and the sdmx schema co-exist in one DuckDB file, so cross-
2698
2763
  # schema queries (e.g. join statcat.dataset to sdmx.codelist) work
2699
2764
  # natively without ATTACH plumbing.
2700
- self._registry = sdmxlib.LocalRegistry.from_connection(self._conn)
2765
+ self._registry = self._open_registry(self._conn)
2701
2766
  # ATTACH the DuckLake sidecar (folder mode only — in-memory has no
2702
2767
  # lake). Sidecar layout because DuckDB rejects self-ATTACH; the
2703
2768
  # ducklake catalog file sits next to meta.duckdb so the bundle is
@@ -2737,6 +2802,28 @@ class Catalog:
2737
2802
  self._query_prefix = meta.query_prefix
2738
2803
  return self
2739
2804
 
2805
+ def _open_registry(self, conn: duckdb.DuckDBPyConnection) -> sdmxlib.LocalRegistry:
2806
+ """Compose the sdmxlib `LocalRegistry` on *conn*, once the guard allows it.
2807
+
2808
+ Args:
2809
+ conn: The connection `open` just made, which this catalog owns.
2810
+
2811
+ Returns:
2812
+ The registry sharing *conn*.
2813
+
2814
+ Raises:
2815
+ IncompatibleCatalogError: sdmxlib would drop this catalog's SDMX
2816
+ structure. *conn* is closed first, releasing the file so the
2817
+ release that built it can reopen it from this process.
2818
+ """
2819
+ try:
2820
+ _refuse_sdmx_schema_rebuild(conn)
2821
+ except IncompatibleCatalogError:
2822
+ conn.close()
2823
+ self._conn = None
2824
+ raise
2825
+ return sdmxlib.LocalRegistry.from_connection(conn)
2826
+
2740
2827
  def _open_connection(self) -> tuple[duckdb.DuckDBPyConnection, Path | None]:
2741
2828
  """Open the DuckDB connection and (for folder mode) prepare the bundle dir.
2742
2829
 
@@ -2825,6 +2912,24 @@ class Catalog:
2825
2912
 
2826
2913
  ``embedding_model`` is rejected here because shared-connection mode
2827
2914
  has no on-disk ``data/`` directory to host the Lance index.
2915
+
2916
+ As `open` does, this raises `IncompatibleCatalogError` when the
2917
+ connection's SDMX structure was stored by an sdmxlib with another
2918
+ store schema, which this one would drop. *conn* is left open then:
2919
+ the caller owns it.
2920
+
2921
+ Args:
2922
+ conn: The connection to wrap. Left open by `close`.
2923
+ embedding_model: Must be ``None``; see above.
2924
+ document_prefix: Prefix for documents embedded into the index.
2925
+ query_prefix: Prefix for queries embedded against the index.
2926
+ read_only: Open the catalog schema without creating it.
2927
+
2928
+ Returns:
2929
+ A catalog over *conn*, already open.
2930
+
2931
+ Raises:
2932
+ ValueError: ``embedding_model`` was given.
2828
2933
  """
2829
2934
  if embedding_model is not None:
2830
2935
  msg = "from_connection() does not support embedding_model — Lance needs a folder-mode catalog"
@@ -2848,6 +2953,7 @@ class Catalog:
2848
2953
  _ = _catalog_schema.open_schema_readonly(conn)
2849
2954
  else:
2850
2955
  _ = _catalog_schema.create_schema(conn)
2956
+ _refuse_sdmx_schema_rebuild(conn)
2851
2957
  instance._registry = sdmxlib.LocalRegistry.from_connection(conn) # noqa: SLF001
2852
2958
  # Shared-connection mode: the caller owns any ATTACH plumbing. The
2853
2959
  # DataStore wraps the same connection without applying its own
File without changes