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/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