statcatlib 0.8.0__py3-none-any.whl
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.
- statcatlib/__init__.py +187 -0
- statcatlib/bundle.py +565 -0
- statcatlib/cli/__init__.py +546 -0
- statcatlib/expr.py +167 -0
- statcatlib/model/__init__.py +56 -0
- statcatlib/model/agents.py +32 -0
- statcatlib/model/catalog.py +71 -0
- statcatlib/model/classification.py +19 -0
- statcatlib/model/dataset.py +198 -0
- statcatlib/model/distribution.py +50 -0
- statcatlib/model/licence.py +18 -0
- statcatlib/model/quality.py +31 -0
- statcatlib/model/skos.py +141 -0
- statcatlib/model/spatial.py +19 -0
- statcatlib/model/temporal.py +33 -0
- statcatlib/model/types.py +5 -0
- statcatlib/py.typed +0 -0
- statcatlib/query.py +924 -0
- statcatlib/sources/__init__.py +13 -0
- statcatlib/sources/ckan.py +490 -0
- statcatlib/sources/sdmx.py +321 -0
- statcatlib/store/__init__.py +38 -0
- statcatlib/store/lance.py +1494 -0
- statcatlib/store/row.py +30 -0
- statcatlib/store/schema.py +403 -0
- statcatlib/store/scopes.py +1419 -0
- statcatlib/store/sentinel.py +99 -0
- statcatlib/store/store.py +6836 -0
- statcatlib/store/xhtml.py +206 -0
- statcatlib/urn.py +108 -0
- statcatlib-0.8.0.dist-info/METADATA +429 -0
- statcatlib-0.8.0.dist-info/RECORD +35 -0
- statcatlib-0.8.0.dist-info/WHEEL +4 -0
- statcatlib-0.8.0.dist-info/entry_points.txt +3 -0
- statcatlib-0.8.0.dist-info/licenses/LICENSE +180 -0
statcatlib/bundle.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Portable bundle support — where a bundle's observation parquet lives.
|
|
2
|
+
|
|
3
|
+
A statcat bundle is the on-disk triple ``meta.duckdb`` + ``lake.ducklake`` +
|
|
4
|
+
``parquet/`` (or a remote URI in place of the local ``parquet/``). The
|
|
5
|
+
DuckLake catalog file embeds an absolute ``data_path`` string recorded at
|
|
6
|
+
write time — the *stamp*. The stamp does two jobs at once: it is the
|
|
7
|
+
locator for the parquet, and it is what DuckLake compares against at
|
|
8
|
+
ATTACH time.
|
|
9
|
+
|
|
10
|
+
Who is authoritative about the stamp depends on how the bundle was
|
|
11
|
+
deployed, and the consumer cannot tell by looking:
|
|
12
|
+
|
|
13
|
+
- **Co-located** — everything in one directory. The stamp carries no
|
|
14
|
+
information (it is derivable from the bundle location), so a stale
|
|
15
|
+
value is noise and rewriting it is always safe.
|
|
16
|
+
- **Split** — catalog files local, parquet on object storage. The stamp
|
|
17
|
+
is the *only* record of where the data lives; rewriting it destroys
|
|
18
|
+
information and costs a re-sync to recover.
|
|
19
|
+
|
|
20
|
+
`open_bundle` therefore does not ask the caller which of those it is.
|
|
21
|
+
It reads the stamp and dispatches on what it finds — see `Auto`.
|
|
22
|
+
|
|
23
|
+
Two facts about DuckLake shape this module, both verified rather than
|
|
24
|
+
assumed (#120):
|
|
25
|
+
|
|
26
|
+
- ``ATTACH`` with **no** ``DATA_PATH`` option honours the stamp and skips
|
|
27
|
+
the comparison entirely. Honouring the stamp is therefore *passing
|
|
28
|
+
nothing*, not passing the value back — it cannot disagree with itself.
|
|
29
|
+
- A stale stamp does **not** fail at ATTACH. It fails at the first query
|
|
30
|
+
that reads observations; metadata-only queries (``count(*)``, facets)
|
|
31
|
+
answer correctly from the catalog. So a bad deploy can pass a health
|
|
32
|
+
check and fail later, which is why `inspect_bundle` checks that the
|
|
33
|
+
stamp resolves rather than leaving it to DuckDB to discover.
|
|
34
|
+
|
|
35
|
+
See #94, #98 and #120 for the design history.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import errno
|
|
41
|
+
import shutil
|
|
42
|
+
from contextlib import contextmanager
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import TYPE_CHECKING, Literal
|
|
45
|
+
|
|
46
|
+
import duckdb
|
|
47
|
+
from attrs import field, frozen
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from collections.abc import Generator
|
|
51
|
+
|
|
52
|
+
from statcatlib.store import Catalog
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"At",
|
|
56
|
+
"Auto",
|
|
57
|
+
"BundleDataPathError",
|
|
58
|
+
"BundleInfo",
|
|
59
|
+
"Colocated",
|
|
60
|
+
"DataPolicy",
|
|
61
|
+
"Embedded",
|
|
62
|
+
"inspect_bundle",
|
|
63
|
+
"normalize_lake_data_path",
|
|
64
|
+
"open_bundle",
|
|
65
|
+
"read_current_data_path",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
#: The DuckLake catalog file inside a bundle.
|
|
69
|
+
_LAKE_FILENAME = "lake.ducklake"
|
|
70
|
+
|
|
71
|
+
#: URI schemes that mean "not on this filesystem". A stamp carrying one of
|
|
72
|
+
#: these is authoritative by construction: a bundle directory cannot host an
|
|
73
|
+
#: ``abfss://`` path, so there is no reading under which overwriting it is
|
|
74
|
+
#: what the caller meant.
|
|
75
|
+
_REMOTE_SCHEMES = ("abfss://", "az://", "s3://", "s3a://", "gs://", "gcs://", "http://", "https://", "r2://")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BundleDataPathError(RuntimeError):
|
|
79
|
+
"""The bundle's parquet location could not be determined.
|
|
80
|
+
|
|
81
|
+
Raised by `Auto` when the stamp names a directory that is not there
|
|
82
|
+
*and* there is no ``<bundle>/parquet/`` to fall back to, while the
|
|
83
|
+
lake still references data files. Guessing either way would be wrong:
|
|
84
|
+
rewriting the stamp destroys the only pointer to the data, and
|
|
85
|
+
honouring it yields a catalog that answers counts correctly and
|
|
86
|
+
fails on the first real read.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@frozen
|
|
91
|
+
class Embedded:
|
|
92
|
+
"""Honour whatever path the producer stamped into the lake.
|
|
93
|
+
|
|
94
|
+
ATTACHes with no ``DATA_PATH`` option at all, so DuckLake uses the
|
|
95
|
+
stamp and performs no comparison. Never writes to the lake.
|
|
96
|
+
|
|
97
|
+
This is what a serving deployment should pin. `Auto` self-heals a
|
|
98
|
+
moved bundle, which is right for dev and CLI use and wrong in
|
|
99
|
+
production: if the stamp stops resolving under a pod, the useful
|
|
100
|
+
outcome is a crashloop, not a quiet repoint at an empty directory.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@frozen
|
|
105
|
+
class Colocated:
|
|
106
|
+
"""Treat ``<bundle>/parquet/`` as the truth and rewrite the stamp to match.
|
|
107
|
+
|
|
108
|
+
The co-location contract: the bundle directory holds its own data, so
|
|
109
|
+
the stamp is derivable and a stale value is noise. This is the right
|
|
110
|
+
policy for a bundle that has been copied or moved between mounts.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@frozen
|
|
115
|
+
class At:
|
|
116
|
+
"""Use an explicit path, ignoring the stamp.
|
|
117
|
+
|
|
118
|
+
ATTACHes with ``DATA_PATH`` and ``OVERRIDE_DATA_PATH``, so the path
|
|
119
|
+
need not match the stamp — not even in spelling — and read-only opens
|
|
120
|
+
work too. The override lasts for this open only: with
|
|
121
|
+
``persist=False``, files written during it land under *path* while the
|
|
122
|
+
stamp still names the old location, so a later open that honours the
|
|
123
|
+
stamp will not see them.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
path: str | Path
|
|
127
|
+
"""A local directory or a remote URI (``abfss://``, ``s3://``, ``gs://``).
|
|
128
|
+
|
|
129
|
+
A trailing slash is added if missing.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
persist: bool = field(default=False, kw_only=True)
|
|
133
|
+
"""Write the path into the lake so later opens find it without being told again.
|
|
134
|
+
|
|
135
|
+
Defaults to `False` — pointing one open at a location is not the same
|
|
136
|
+
act as re-stamping the bundle, and conflating the two is what made the
|
|
137
|
+
old default destructive. The persisting form is `statcat bundle rebind`.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@frozen
|
|
142
|
+
class Auto:
|
|
143
|
+
"""Read the stamp and decide — the default.
|
|
144
|
+
|
|
145
|
+
========================== ===================== =========================
|
|
146
|
+
stamp ``<bundle>/parquet/`` action
|
|
147
|
+
========================== ===================== =========================
|
|
148
|
+
remote scheme — honour it, no write
|
|
149
|
+
local, resolves — honour it, no write
|
|
150
|
+
local, missing exists moved → rewrite to it
|
|
151
|
+
local, missing missing, no data rewrite (nothing to lose)
|
|
152
|
+
local, missing missing, has data `BundleDataPathError`
|
|
153
|
+
========================== ===================== =========================
|
|
154
|
+
|
|
155
|
+
Resolve-first rather than scheme-sniffing: "does the stamp resolve?"
|
|
156
|
+
subsumes "is it remote?" and additionally covers parquet on a local
|
|
157
|
+
mount that is not ``<bundle>/parquet/`` — the case that previously
|
|
158
|
+
needed an explicit escape hatch to express.
|
|
159
|
+
|
|
160
|
+
Writes are rare by construction. Every deployment whose bundle has
|
|
161
|
+
not moved opens with zero writes and takes no lock, which is what
|
|
162
|
+
lets N pods open one bundle off a shared mount concurrently.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
#: Where a bundle's observation parquet lives, and who gets to say so.
|
|
167
|
+
type DataPolicy = Auto | Embedded | Colocated | At
|
|
168
|
+
|
|
169
|
+
#: The default policy, as a singleton — a mutable default would be a shared
|
|
170
|
+
#: instance anyway, and these carry no state.
|
|
171
|
+
_AUTO = Auto()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@frozen
|
|
175
|
+
class BundleInfo:
|
|
176
|
+
"""What `inspect_bundle` found, and what `Auto` would do about it.
|
|
177
|
+
|
|
178
|
+
The discriminator made inspectable. The bundle can tell a co-located
|
|
179
|
+
layout from a split one immediately and cheaply; the caller cannot.
|
|
180
|
+
This is that judgement as a value — for logging, for an init
|
|
181
|
+
container's dry run, and for an application to fail at startup rather
|
|
182
|
+
than on the first query that reads observations.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
bundle: Path
|
|
186
|
+
"""The bundle directory."""
|
|
187
|
+
|
|
188
|
+
stamp: str
|
|
189
|
+
"""The ``data_path`` literal recorded in the lake, verbatim."""
|
|
190
|
+
|
|
191
|
+
is_remote: bool
|
|
192
|
+
"""The stamp carries a remote URI scheme."""
|
|
193
|
+
|
|
194
|
+
stamp_resolves: bool | None
|
|
195
|
+
"""The stamp names an existing directory. `None` when remote — not checked."""
|
|
196
|
+
|
|
197
|
+
colocated_exists: bool
|
|
198
|
+
"""``<bundle>/parquet/`` is present."""
|
|
199
|
+
|
|
200
|
+
data_files: int
|
|
201
|
+
"""Parquet files the lake references. Zero means the stamp points at nothing."""
|
|
202
|
+
|
|
203
|
+
target: str | None
|
|
204
|
+
"""What `Auto` would ATTACH with. `None` means "omit ``DATA_PATH``, honour the stamp"."""
|
|
205
|
+
|
|
206
|
+
will_write: bool
|
|
207
|
+
"""`Auto` would rewrite the stamp."""
|
|
208
|
+
|
|
209
|
+
verdict: str
|
|
210
|
+
"""One line naming the layout and the action, for logs and CLI output."""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _is_remote(raw: str) -> bool:
|
|
214
|
+
"""Does this ``data_path`` literal point off the local filesystem?"""
|
|
215
|
+
return raw.startswith(_REMOTE_SCHEMES)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _with_slash(raw: str) -> str:
|
|
219
|
+
"""Canonicalise a ``data_path`` literal for comparison.
|
|
220
|
+
|
|
221
|
+
DuckLake stores ``data_path`` with a trailing slash. We always compare
|
|
222
|
+
against that canonical form so callers can pass either ``/tmp/parquet``
|
|
223
|
+
or ``/tmp/parquet/`` without surprises.
|
|
224
|
+
"""
|
|
225
|
+
return raw if raw.endswith("/") else f"{raw}/"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _colocated_target(bundle_dir: Path) -> str:
|
|
229
|
+
"""The DATA_PATH literal for a bundle that holds its own parquet."""
|
|
230
|
+
return f"{bundle_dir / 'parquet'}/"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _lake_file(source: str | Path) -> Path:
|
|
234
|
+
"""Resolve *source* — a bundle directory or the lake file — to the lake file."""
|
|
235
|
+
lake_path = Path(source)
|
|
236
|
+
if lake_path.is_dir():
|
|
237
|
+
lake_path = lake_path / _LAKE_FILENAME
|
|
238
|
+
return lake_path
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def read_current_data_path(source: str | Path) -> str:
|
|
242
|
+
"""Read the ``data_path`` the producer stamped into a bundle's lake.
|
|
243
|
+
|
|
244
|
+
The path a bundle *says* its parquet lives at. Split-deployment
|
|
245
|
+
consumers keep ``meta.duckdb`` and ``lake.ducklake`` local and leave
|
|
246
|
+
the observation parquet on object storage; this is how they learn
|
|
247
|
+
where that is without duplicating the producer's configuration.
|
|
248
|
+
|
|
249
|
+
Call it directly when the path is wanted *before* opening — to log it,
|
|
250
|
+
to assert it is remote, or to swap an account. `inspect_bundle` is
|
|
251
|
+
usually the better tool, since it also says what `Auto` will do.
|
|
252
|
+
|
|
253
|
+
Reads DuckLake's own ``ducklake_metadata`` rather than a copy: the lake
|
|
254
|
+
already records this, and a second copy in statcatlib's schema could disagree
|
|
255
|
+
with it after a `normalize_lake_data_path`.
|
|
256
|
+
|
|
257
|
+
Opens the lake file read-only — no write lock, no contention with
|
|
258
|
+
concurrent readers.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
source: A bundle directory, or the ``lake.ducklake`` file itself.
|
|
262
|
+
Accepting the directory keeps the layout an implementation
|
|
263
|
+
detail rather than something every caller must know.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
The embedded ``data_path``, verbatim — including its trailing slash.
|
|
267
|
+
Local paths and remote URIs (``abfss://``, ``s3://``) are returned
|
|
268
|
+
alike; no attempt is made to interpret one.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
FileNotFoundError: *source* names neither a lake file nor a
|
|
272
|
+
directory containing one.
|
|
273
|
+
RuntimeError: The lake has no ``data_path`` entry — not a DuckLake
|
|
274
|
+
catalog, or its schema has changed.
|
|
275
|
+
TypeError: The stored value is not a string.
|
|
276
|
+
"""
|
|
277
|
+
lake_path = _lake_file(source)
|
|
278
|
+
if not lake_path.exists():
|
|
279
|
+
msg = f"{source}: no {_LAKE_FILENAME} here — not a statcatlib bundle?"
|
|
280
|
+
raise FileNotFoundError(msg)
|
|
281
|
+
conn = duckdb.connect(str(lake_path), read_only=True)
|
|
282
|
+
try:
|
|
283
|
+
row = conn.execute("SELECT value FROM ducklake_metadata WHERE key = 'data_path'").fetchone()
|
|
284
|
+
finally:
|
|
285
|
+
conn.close()
|
|
286
|
+
if row is None:
|
|
287
|
+
msg = f"{lake_path}: no 'data_path' entry in ducklake_metadata — not a DuckLake catalog or schema has changed"
|
|
288
|
+
raise RuntimeError(msg)
|
|
289
|
+
data_path = row[0]
|
|
290
|
+
if not isinstance(data_path, str):
|
|
291
|
+
msg = f"{lake_path}: ducklake_metadata.data_path is {type(data_path).__name__}, expected a string"
|
|
292
|
+
raise TypeError(msg)
|
|
293
|
+
return data_path
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _count_data_files(lake_path: Path) -> int:
|
|
297
|
+
"""How many parquet files the lake references.
|
|
298
|
+
|
|
299
|
+
Zero means the stamp addresses nothing: either the bundle holds no
|
|
300
|
+
observations yet, or its rows are still inlined in the catalog. Either
|
|
301
|
+
way a rewrite loses no information, which is what lets `Auto` repoint
|
|
302
|
+
an empty relocated bundle instead of refusing it.
|
|
303
|
+
|
|
304
|
+
A lake whose schema lacks the table is treated as empty rather than
|
|
305
|
+
raising — this only ever widens what `Auto` will fix.
|
|
306
|
+
"""
|
|
307
|
+
conn = duckdb.connect(str(lake_path), read_only=True)
|
|
308
|
+
try:
|
|
309
|
+
row = conn.execute("SELECT count(*) FROM ducklake_data_file").fetchone()
|
|
310
|
+
except duckdb.Error:
|
|
311
|
+
return 0
|
|
312
|
+
finally:
|
|
313
|
+
conn.close()
|
|
314
|
+
return int(row[0]) if row is not None else 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def inspect_bundle(source: str | Path) -> BundleInfo:
|
|
318
|
+
"""Report where a bundle says its data is, and what `Auto` would do.
|
|
319
|
+
|
|
320
|
+
The dry run behind ``statcat bundle explain``. Reads only — opens the
|
|
321
|
+
lake read-only, takes no write lock, and changes nothing.
|
|
322
|
+
|
|
323
|
+
Because a stale stamp surfaces at first read rather than at ATTACH,
|
|
324
|
+
this is the check that turns a latent runtime failure into a startup
|
|
325
|
+
one. An application that validates configuration at boot should call
|
|
326
|
+
it there.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
source: A bundle directory, or the ``lake.ducklake`` file itself.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
A `BundleInfo` describing the layout and the action `Auto` would
|
|
333
|
+
take. Reports rather than raises: the ambiguous case comes back
|
|
334
|
+
with a ``verdict`` saying so and ``target`` of `None`, where
|
|
335
|
+
`open_bundle` would raise `BundleDataPathError`.
|
|
336
|
+
|
|
337
|
+
A *source* naming neither a lake file nor a directory containing
|
|
338
|
+
one propagates `FileNotFoundError` from `read_current_data_path`.
|
|
339
|
+
"""
|
|
340
|
+
lake_path = _lake_file(source)
|
|
341
|
+
bundle_dir = lake_path.parent
|
|
342
|
+
stamp = read_current_data_path(lake_path)
|
|
343
|
+
remote = _is_remote(stamp)
|
|
344
|
+
resolves = None if remote else Path(stamp).is_dir()
|
|
345
|
+
colocated = (bundle_dir / "parquet").is_dir()
|
|
346
|
+
data_files = _count_data_files(lake_path)
|
|
347
|
+
|
|
348
|
+
if remote:
|
|
349
|
+
target, will_write, verdict = None, False, f"split — parquet on object storage at {stamp}"
|
|
350
|
+
elif resolves:
|
|
351
|
+
target, will_write, verdict = None, False, f"stamp resolves — parquet at {stamp}"
|
|
352
|
+
elif colocated:
|
|
353
|
+
target = _colocated_target(bundle_dir)
|
|
354
|
+
will_write = True
|
|
355
|
+
verdict = f"bundle moved — would rewrite stamp to {target}"
|
|
356
|
+
elif data_files == 0:
|
|
357
|
+
target = _colocated_target(bundle_dir)
|
|
358
|
+
will_write = True
|
|
359
|
+
verdict = f"no data files — would rewrite stamp to {target}"
|
|
360
|
+
else:
|
|
361
|
+
target, will_write = None, False
|
|
362
|
+
verdict = (
|
|
363
|
+
f"ambiguous — stamp {stamp} does not exist, no {bundle_dir / 'parquet'}/, "
|
|
364
|
+
f"and the lake references {data_files} data file(s)"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return BundleInfo(
|
|
368
|
+
bundle=bundle_dir,
|
|
369
|
+
stamp=stamp,
|
|
370
|
+
is_remote=remote,
|
|
371
|
+
stamp_resolves=resolves,
|
|
372
|
+
colocated_exists=colocated,
|
|
373
|
+
data_files=data_files,
|
|
374
|
+
target=target,
|
|
375
|
+
will_write=will_write,
|
|
376
|
+
verdict=verdict,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _write_data_path(lake_path: Path, target: str) -> None:
|
|
381
|
+
"""Acquire the lake write lock and UPDATE ``ducklake_metadata.data_path``."""
|
|
382
|
+
conn = duckdb.connect(str(lake_path), read_only=False)
|
|
383
|
+
try:
|
|
384
|
+
conn.execute(
|
|
385
|
+
"UPDATE ducklake_metadata SET value = ? WHERE key = 'data_path'",
|
|
386
|
+
[target],
|
|
387
|
+
)
|
|
388
|
+
finally:
|
|
389
|
+
conn.close()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def normalize_lake_data_path(
|
|
393
|
+
lake_path: Path,
|
|
394
|
+
target_data_path: str | Path,
|
|
395
|
+
*,
|
|
396
|
+
scratch_dir: Path | None = None,
|
|
397
|
+
force: bool = False,
|
|
398
|
+
) -> Path:
|
|
399
|
+
"""Ensure the lake's embedded ``data_path`` equals ``target_data_path``.
|
|
400
|
+
|
|
401
|
+
The rewrite is idempotent and read-first: if the embedded value
|
|
402
|
+
already matches the target (after trailing-slash normalization), no
|
|
403
|
+
write lock is acquired and no UPDATE is issued — safe to call from N
|
|
404
|
+
concurrent readers in steady state.
|
|
405
|
+
|
|
406
|
+
Rewriting a **remote** stamp to a **local** path is refused unless
|
|
407
|
+
``force`` is passed. That direction discards the only record of where
|
|
408
|
+
a split bundle's data lives, and recovering costs a re-sync; it is
|
|
409
|
+
occasionally what someone means (after pulling the data down), never
|
|
410
|
+
something to arrive at by default.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
lake_path: Path to the ``lake.ducklake`` file (a DuckDB database).
|
|
414
|
+
target_data_path: The desired DuckLake DATA_PATH. May be a local directory or a remote URI
|
|
415
|
+
(``abfss://``, ``s3://``, ``gs://``). A trailing slash is added if missing.
|
|
416
|
+
scratch_dir: When supplied and the rewrite fails because ``lake_path`` is on a read-only mount, the
|
|
417
|
+
lake file is byte-copied into ``scratch_dir`` and the rewrite is retried there. Returns the path
|
|
418
|
+
of the copy.
|
|
419
|
+
force: Permit a remote → local rewrite. Ignored for every other direction.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
The path to the rewritten (or unchanged) lake file. Usually
|
|
423
|
+
``lake_path``; switches to ``scratch_dir / lake.ducklake`` only
|
|
424
|
+
when the read-only fallback fires.
|
|
425
|
+
|
|
426
|
+
Raises:
|
|
427
|
+
BundleDataPathError: The rewrite would replace a remote stamp with
|
|
428
|
+
a local path and ``force`` was not passed.
|
|
429
|
+
duckdb.IOException: The lake could not be opened for writing and no
|
|
430
|
+
``scratch_dir`` was supplied to fall back to.
|
|
431
|
+
OSError: As above (``PermissionError`` included), or the write
|
|
432
|
+
failed for a reason other than a read-only mount (``EROFS`` /
|
|
433
|
+
``EACCES`` / ``EPERM``) — those are the only errno values the
|
|
434
|
+
``scratch_dir`` fallback handles.
|
|
435
|
+
"""
|
|
436
|
+
target = _with_slash(str(target_data_path))
|
|
437
|
+
|
|
438
|
+
current = read_current_data_path(lake_path)
|
|
439
|
+
if _with_slash(current) == target:
|
|
440
|
+
return lake_path
|
|
441
|
+
|
|
442
|
+
if _is_remote(current) and not _is_remote(target) and not force:
|
|
443
|
+
msg = (
|
|
444
|
+
f"{lake_path}: refusing to rewrite remote data_path {current!r} to local {target!r} — "
|
|
445
|
+
f"the stamp is the only record of where this bundle's data lives, and recovering it "
|
|
446
|
+
f"costs a re-sync. Pass force=True (or `statcat bundle rebind --force`) if this is deliberate."
|
|
447
|
+
)
|
|
448
|
+
raise BundleDataPathError(msg)
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
_write_data_path(lake_path, target)
|
|
452
|
+
except (duckdb.IOException, OSError) as exc:
|
|
453
|
+
if scratch_dir is None:
|
|
454
|
+
raise
|
|
455
|
+
# Read-only mount fallback: copy and rewrite the copy.
|
|
456
|
+
if isinstance(exc, OSError) and exc.errno not in (errno.EROFS, errno.EACCES, errno.EPERM):
|
|
457
|
+
raise
|
|
458
|
+
scratch_dir.mkdir(parents=True, exist_ok=True)
|
|
459
|
+
copy_path = scratch_dir / lake_path.name
|
|
460
|
+
shutil.copyfile(lake_path, copy_path)
|
|
461
|
+
_write_data_path(copy_path, target)
|
|
462
|
+
return copy_path
|
|
463
|
+
|
|
464
|
+
return lake_path
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _resolve_policy(bundle_dir: Path, policy: DataPolicy) -> tuple[str | None, bool]:
|
|
468
|
+
"""Reduce a `DataPolicy` to ``(data_path, persist)``.
|
|
469
|
+
|
|
470
|
+
A ``data_path`` of `None` means "omit the ``DATA_PATH`` option from
|
|
471
|
+
ATTACH entirely" — DuckLake then uses its own stamp and skips the
|
|
472
|
+
comparison, which is the one form that cannot disagree with the lake.
|
|
473
|
+
"""
|
|
474
|
+
if isinstance(policy, Embedded):
|
|
475
|
+
return None, False
|
|
476
|
+
if isinstance(policy, Colocated):
|
|
477
|
+
return _colocated_target(bundle_dir), True
|
|
478
|
+
if isinstance(policy, At):
|
|
479
|
+
return _with_slash(str(policy.path)), policy.persist
|
|
480
|
+
|
|
481
|
+
lake_path = bundle_dir / _LAKE_FILENAME
|
|
482
|
+
if not lake_path.exists():
|
|
483
|
+
# A bundle being created, not opened: there is no stamp to read
|
|
484
|
+
# and co-location is the only thing "here" can mean.
|
|
485
|
+
return _colocated_target(bundle_dir), False
|
|
486
|
+
|
|
487
|
+
info = inspect_bundle(lake_path)
|
|
488
|
+
if info.target is None and not info.will_write:
|
|
489
|
+
if info.verdict.startswith("ambiguous"):
|
|
490
|
+
msg = (
|
|
491
|
+
f"{bundle_dir}: cannot determine where this bundle's parquet lives. "
|
|
492
|
+
f"The lake is stamped {info.stamp!r}, which does not exist; there is no "
|
|
493
|
+
f"{bundle_dir / 'parquet'}/ to fall back to; and {info.data_files} data file(s) "
|
|
494
|
+
f"are referenced, so rewriting the stamp would lose the only pointer to them. "
|
|
495
|
+
f"Pass data=At(...) with the real location, or run `statcat bundle rebind`."
|
|
496
|
+
)
|
|
497
|
+
raise BundleDataPathError(msg)
|
|
498
|
+
return None, False
|
|
499
|
+
return info.target, info.will_write
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@contextmanager
|
|
503
|
+
def open_bundle(
|
|
504
|
+
bundle_uri: str | Path,
|
|
505
|
+
*,
|
|
506
|
+
data: DataPolicy = _AUTO,
|
|
507
|
+
access_mode: Literal["read_write", "read_only"] = "read_write",
|
|
508
|
+
azure_credentials: dict[str, str] | None = None,
|
|
509
|
+
) -> Generator[Catalog]:
|
|
510
|
+
"""Open a statcatlib Catalog bundle, working out where its parquet lives.
|
|
511
|
+
|
|
512
|
+
The default works without the caller knowing how the bundle is laid
|
|
513
|
+
out — co-located, split, or moved. Pass ``data`` to override that
|
|
514
|
+
judgement; see `Auto`, `Embedded`, `Colocated` and `At`.
|
|
515
|
+
|
|
516
|
+
Opening is not re-stamping. `Auto` writes to the lake only when the
|
|
517
|
+
bundle has demonstrably moved, so the steady state for every
|
|
518
|
+
deployment is zero-write and lock-free — N read-only workers can open
|
|
519
|
+
one bundle concurrently. Deliberately repointing a bundle at a new
|
|
520
|
+
location is `statcat bundle rebind`, or ``At(..., persist=True)``.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
bundle_uri: Folder containing ``meta.duckdb`` + ``lake.ducklake``.
|
|
524
|
+
data: Where the observation parquet lives. Defaults to `Auto`, which reads the stamp and decides.
|
|
525
|
+
access_mode: ``'read_write'`` (default) or ``'read_only'``. The read-only mode is what high-
|
|
526
|
+
concurrency worker pools want — DuckDB takes a shared file lock and many processes can attach in
|
|
527
|
+
parallel. A read-only open never writes the stamp, whatever the policy says.
|
|
528
|
+
azure_credentials: Forwarded to `statcatlib.Catalog` when the effective data path is a remote URI
|
|
529
|
+
(e.g. ``abfss://``). Deliberately separate from the bundle: the bundle declares *where*, the
|
|
530
|
+
deployment supplies *how to authenticate*.
|
|
531
|
+
|
|
532
|
+
Yields:
|
|
533
|
+
The opened `statcatlib.Catalog`, closed when the context exits.
|
|
534
|
+
|
|
535
|
+
`BundleDataPathError` propagates when `Auto` cannot determine
|
|
536
|
+
where the parquet lives — see `Auto` for the case, and
|
|
537
|
+
`inspect_bundle` for a non-raising report of the same judgement.
|
|
538
|
+
"""
|
|
539
|
+
bundle_path = Path(bundle_uri)
|
|
540
|
+
read_only = access_mode == "read_only"
|
|
541
|
+
data_path, persist = _resolve_policy(bundle_path, data)
|
|
542
|
+
|
|
543
|
+
# A read-only attach cannot UPDATE ducklake_metadata, so persistence is
|
|
544
|
+
# dropped rather than refused — the open itself is still correct, since
|
|
545
|
+
# the resolved path is passed to ATTACH with OVERRIDE_DATA_PATH, which
|
|
546
|
+
# DuckLake honours under READ_ONLY without touching the stamp (#126).
|
|
547
|
+
if persist and not read_only and data_path is not None:
|
|
548
|
+
lake_file = bundle_path / _LAKE_FILENAME
|
|
549
|
+
if lake_file.exists():
|
|
550
|
+
normalize_lake_data_path(lake_file, data_path)
|
|
551
|
+
|
|
552
|
+
# Imported here to avoid a circular dependency at module load.
|
|
553
|
+
from statcatlib.store import Catalog # noqa: PLC0415
|
|
554
|
+
|
|
555
|
+
catalog = Catalog(
|
|
556
|
+
bundle_uri,
|
|
557
|
+
# `Catalog(data_path=None)` means "co-located" — the sensible default
|
|
558
|
+
# when creating a catalog directly. "Omit DATA_PATH and honour the
|
|
559
|
+
# stamp" is a third state, spelled with the policy that means it.
|
|
560
|
+
data_path=data_path if data_path is not None else Embedded(),
|
|
561
|
+
azure_credentials=azure_credentials,
|
|
562
|
+
read_only=read_only,
|
|
563
|
+
)
|
|
564
|
+
with catalog.open() as opened:
|
|
565
|
+
yield opened
|