fdl-cli 0.12.1__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.
- fdl/__init__.py +16 -0
- fdl/catalog.py +476 -0
- fdl/cli.py +159 -0
- fdl/layout.py +64 -0
- fdl/py.typed +0 -0
- fdl/refs.py +115 -0
- fdl/repo.py +433 -0
- fdl/store.py +64 -0
- fdl/stores.py +177 -0
- fdl/version.py +140 -0
- fdl_cli-0.12.1.dist-info/METADATA +116 -0
- fdl_cli-0.12.1.dist-info/RECORD +15 -0
- fdl_cli-0.12.1.dist-info/WHEEL +4 -0
- fdl_cli-0.12.1.dist-info/entry_points.txt +2 -0
- fdl_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
fdl/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""FDL: versions of a DuckLake catalog, kept the way Git keeps commits.
|
|
2
|
+
|
|
3
|
+
The format is written down in ``docs/spec.md``. This package is its reference
|
|
4
|
+
implementation: a repository lives in a local folder or any S3-compatible
|
|
5
|
+
bucket that takes conditional writes, and every function here works on one.
|
|
6
|
+
Services that host repositories call this package the way ``gh`` calls
|
|
7
|
+
``git``, so nothing in here knows about any of them.
|
|
8
|
+
|
|
9
|
+
catalog a DuckLake catalog in its two formats, and the conversion
|
|
10
|
+
layout where each file of a repository sits
|
|
11
|
+
version the record of a version (version.json) and its ID
|
|
12
|
+
refs reading and moving refs under their conditions
|
|
13
|
+
repo init, commit, push, log, expire: the operations, without a terminal
|
|
14
|
+
store what this package needs from the storage underneath
|
|
15
|
+
stores a local folder and S3 as stores
|
|
16
|
+
"""
|
fdl/catalog.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""A DuckLake catalog in its two formats, and the conversion between them.
|
|
2
|
+
|
|
3
|
+
a working catalog SQLite, the one a build writes to
|
|
4
|
+
a frozen catalog DuckDB, the one readers attach over HTTP
|
|
5
|
+
|
|
6
|
+
SQLite is the working format because a pipeline writes to the catalog from
|
|
7
|
+
several connections at once (dbt runs its models in threads), which SQLite
|
|
8
|
+
handles with WAL and a busy timeout. DuckDB is the frozen format because that
|
|
9
|
+
is what readers attach over HTTP, where a single file that can be read by range
|
|
10
|
+
requests is what matters. Neither can do the other's job, so the conversion
|
|
11
|
+
runs both ways.
|
|
12
|
+
|
|
13
|
+
The conversion copies DuckLake's bookkeeping tables row by row. Inline data
|
|
14
|
+
(DuckLake keeps small writes inside the catalog rather than in parquet) is
|
|
15
|
+
copied as it is into a working catalog and refused on the way to a frozen one:
|
|
16
|
+
SQLite has no column types, so the rows would be read back wrong.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
#: Options that make SQLite usable by more than one writer. WAL is set on both
|
|
26
|
+
#: fresh and existing catalogs, so a catalog left in `delete` mode by an older
|
|
27
|
+
#: version migrates on the first attach.
|
|
28
|
+
SQLITE_OPTIONS = ("META_JOURNAL_MODE 'WAL'", "BUSY_TIMEOUT 5000")
|
|
29
|
+
|
|
30
|
+
#: Rows kept inside the catalog instead of written to parquet. Zero: see the
|
|
31
|
+
#: comment where it is used.
|
|
32
|
+
DATA_INLINING_ROW_LIMIT = 0
|
|
33
|
+
|
|
34
|
+
#: Block size of the published catalog. DuckDB's default (256 KiB) gives each of
|
|
35
|
+
#: DuckLake's twenty-odd bookkeeping tables a block of its own, so even a
|
|
36
|
+
#: one-table catalog is about 5 MB, and a reader attaching over HTTP touches
|
|
37
|
+
#: nearly all of it: one range request per block. 16 KiB brings that catalog
|
|
38
|
+
#: under 1 MB and the requests from 29 to 9.
|
|
39
|
+
PUBLISHED_BLOCK_SIZE = 16384
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CatalogError(RuntimeError):
|
|
43
|
+
"""The catalog could not be created, opened or converted."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def data_path_of(catalog_url: str) -> str:
|
|
47
|
+
"""Where a catalog's data files sit, given where the catalog itself is.
|
|
48
|
+
|
|
49
|
+
DuckLake derives one from the other and so does everything else here, so
|
|
50
|
+
the rule lives in one place.
|
|
51
|
+
"""
|
|
52
|
+
return f"{catalog_url}.files/"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _escape(value: str) -> str:
|
|
56
|
+
return str(value).replace("'", "''")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _attach_options(
|
|
60
|
+
data_path: str, meta_type: Literal["duckdb", "sqlite"]
|
|
61
|
+
) -> list[str]:
|
|
62
|
+
options = [
|
|
63
|
+
f"DATA_PATH '{_escape(data_path)}'",
|
|
64
|
+
"OVERRIDE_DATA_PATH true",
|
|
65
|
+
f"META_TYPE '{meta_type}'",
|
|
66
|
+
# Left at its default, DuckLake keeps small tables inside the catalog
|
|
67
|
+
# and writes no parquet at all, which publishes a dataset with no data
|
|
68
|
+
# files. Off, always: what gets served has to be readable by range
|
|
69
|
+
# requests over HTTP.
|
|
70
|
+
f"DATA_INLINING_ROW_LIMIT {DATA_INLINING_ROW_LIMIT}",
|
|
71
|
+
]
|
|
72
|
+
if meta_type == "sqlite":
|
|
73
|
+
options.extend(SQLITE_OPTIONS)
|
|
74
|
+
return options
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def attach_sql(
|
|
78
|
+
catalog_path: Path, *, alias: str, data_path: str, read_only: bool = False
|
|
79
|
+
) -> list[str]:
|
|
80
|
+
"""The statements that open a catalog, in the order they must run.
|
|
81
|
+
|
|
82
|
+
``META_TYPE`` is always declared. Left to detect the backend from a bare
|
|
83
|
+
`ducklake:<file>` path, DuckDB passes a `storage_version` option to the
|
|
84
|
+
SQLite attach underneath, which SQLite rejects.
|
|
85
|
+
"""
|
|
86
|
+
catalog_path = Path(catalog_path)
|
|
87
|
+
meta_type: Literal["duckdb", "sqlite"] = (
|
|
88
|
+
"sqlite" if catalog_path.suffix == ".sqlite" else "duckdb"
|
|
89
|
+
)
|
|
90
|
+
options = _attach_options(data_path, meta_type)
|
|
91
|
+
if read_only:
|
|
92
|
+
options.append("READ_ONLY")
|
|
93
|
+
return [
|
|
94
|
+
"INSTALL ducklake; LOAD ducklake;",
|
|
95
|
+
"INSTALL sqlite; LOAD sqlite;" if meta_type == "sqlite" else "",
|
|
96
|
+
f"ATTACH 'ducklake:{_escape(str(catalog_path))}' AS {alias} "
|
|
97
|
+
f"({', '.join(options)})",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def create(catalog_path: Path, *, alias: str, data_path: str) -> bool:
|
|
102
|
+
"""Create the catalog if it is not there. Returns whether it was created."""
|
|
103
|
+
import duckdb
|
|
104
|
+
|
|
105
|
+
catalog_path = Path(catalog_path)
|
|
106
|
+
if catalog_path.exists():
|
|
107
|
+
return False
|
|
108
|
+
catalog_path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
con = duckdb.connect()
|
|
110
|
+
try:
|
|
111
|
+
for statement in attach_sql(
|
|
112
|
+
catalog_path, alias=alias, data_path=data_path
|
|
113
|
+
):
|
|
114
|
+
if statement:
|
|
115
|
+
con.execute(statement)
|
|
116
|
+
except Exception as exc: # noqa: BLE001 - duckdb raises several types
|
|
117
|
+
raise CatalogError(f"could not create {catalog_path}: {exc}") from exc
|
|
118
|
+
finally:
|
|
119
|
+
con.close()
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#: What a database can leave beside itself when a write does not close
|
|
124
|
+
#: cleanly. DuckDB writes `<db>.wal`; SQLite writes `<db>-journal`, or
|
|
125
|
+
#: `-wal` and `-shm` in WAL mode. **Both formats pass through `convert`**
|
|
126
|
+
#: (`push` writes DuckDB, `pull` writes SQLite), so both sets are handled.
|
|
127
|
+
_SIDECAR_SUFFIXES = (".wal", "-wal", "-shm", "-journal")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _sidecars(path: Path) -> list[Path]:
|
|
131
|
+
return [path.with_name(path.name + suffix) for suffix in _SIDECAR_SUFFIXES]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _refuse_inline_rows(con) -> None:
|
|
135
|
+
"""Stop when the SQLite catalog ``src`` still holds rows inline.
|
|
136
|
+
|
|
137
|
+
SQLite has no column types, so its inline tables are all VARCHAR and copy
|
|
138
|
+
over as such. Readers then misread the rows: DuckDB 2.0 stops with
|
|
139
|
+
"expected JSON for column N but read VARCHAR", and 1.5 reads a DOUBLE
|
|
140
|
+
column as NULL without a word (both reproduced 2026-10-26). The writer
|
|
141
|
+
flushes them before freezing, so finding any here is a bug.
|
|
142
|
+
|
|
143
|
+
The emptied tables are not published either (`convert`): 2.0 checks the
|
|
144
|
+
VARCHAR types even with no rows in them, and DuckLake makes an inline
|
|
145
|
+
table again, with the right types, the next time a write is inlined.
|
|
146
|
+
"""
|
|
147
|
+
tables = con.execute(
|
|
148
|
+
"SELECT table_name FROM information_schema.tables "
|
|
149
|
+
"WHERE table_catalog = 'src' "
|
|
150
|
+
"AND starts_with(table_name, 'ducklake_inlined_data_') "
|
|
151
|
+
"AND table_name <> 'ducklake_inlined_data_tables'"
|
|
152
|
+
).fetchall()
|
|
153
|
+
held = [
|
|
154
|
+
name
|
|
155
|
+
for (name,) in tables
|
|
156
|
+
if con.execute(f"SELECT 1 FROM src.main.{name} LIMIT 1").fetchone()
|
|
157
|
+
]
|
|
158
|
+
if held:
|
|
159
|
+
raise CatalogError(
|
|
160
|
+
f"the catalog still holds rows inline ({', '.join(held)}); "
|
|
161
|
+
f"they would be published as VARCHAR. Flush them first."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def convert(
|
|
166
|
+
source: Path,
|
|
167
|
+
target: Path,
|
|
168
|
+
*,
|
|
169
|
+
data_path: str,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Copy a catalog into the other format, replacing ``target``.
|
|
172
|
+
|
|
173
|
+
Written through a temporary file and renamed once it is complete, so an
|
|
174
|
+
interrupted conversion never leaves a half-copied catalog where a working
|
|
175
|
+
one used to be.
|
|
176
|
+
"""
|
|
177
|
+
import duckdb
|
|
178
|
+
|
|
179
|
+
source = Path(source)
|
|
180
|
+
target = Path(target)
|
|
181
|
+
if not source.is_file():
|
|
182
|
+
raise CatalogError(f"no catalog at {source}")
|
|
183
|
+
target_type: Literal["duckdb", "sqlite"] = (
|
|
184
|
+
"sqlite" if target.suffix == ".sqlite" else "duckdb"
|
|
185
|
+
)
|
|
186
|
+
source_type: Literal["duckdb", "sqlite"] = (
|
|
187
|
+
"sqlite" if source.suffix == ".sqlite" else "duckdb"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
tmp = target.with_name(target.name + ".tmp")
|
|
191
|
+
leftovers = [tmp, *_sidecars(tmp)]
|
|
192
|
+
# A run killed before its cleanup leaves these behind. Attached as they
|
|
193
|
+
# are, the copy would land on top of that catalog and keep its tables.
|
|
194
|
+
for path in leftovers:
|
|
195
|
+
if path.exists():
|
|
196
|
+
path.unlink()
|
|
197
|
+
try:
|
|
198
|
+
con = duckdb.connect()
|
|
199
|
+
try:
|
|
200
|
+
con.execute("INSTALL ducklake; LOAD ducklake;")
|
|
201
|
+
con.execute("INSTALL sqlite; LOAD sqlite;")
|
|
202
|
+
if target_type == "duckdb":
|
|
203
|
+
# The block size is fixed when the file is created, so the
|
|
204
|
+
# file is made here before DuckLake attaches to it.
|
|
205
|
+
con.execute(
|
|
206
|
+
f"ATTACH '{_escape(str(tmp))}' AS blk "
|
|
207
|
+
f"(BLOCK_SIZE {PUBLISHED_BLOCK_SIZE})"
|
|
208
|
+
)
|
|
209
|
+
con.execute("DETACH blk")
|
|
210
|
+
# Provision the destination through the extension so its
|
|
211
|
+
# bookkeeping tables exist, then talk to both sides as plain
|
|
212
|
+
# databases: the tables being copied are DuckLake's own, which the
|
|
213
|
+
# extension does not expose.
|
|
214
|
+
con.execute(
|
|
215
|
+
f"ATTACH 'ducklake:{_escape(str(tmp))}' AS dst "
|
|
216
|
+
f"({', '.join(_attach_options(data_path, target_type))})"
|
|
217
|
+
)
|
|
218
|
+
con.execute("DETACH dst")
|
|
219
|
+
|
|
220
|
+
con.execute(
|
|
221
|
+
f"ATTACH '{_escape(str(source))}' AS src"
|
|
222
|
+
+ (" (TYPE sqlite)" if source_type == "sqlite" else "")
|
|
223
|
+
)
|
|
224
|
+
con.execute(
|
|
225
|
+
f"ATTACH '{_escape(str(tmp))}' AS dst"
|
|
226
|
+
+ (" (TYPE sqlite)" if target_type == "sqlite" else "")
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Publishing: the inline tables are all empty here, and are left out
|
|
230
|
+
publishing = source_type == "sqlite" and target_type == "duckdb"
|
|
231
|
+
if publishing:
|
|
232
|
+
_refuse_inline_rows(con)
|
|
233
|
+
|
|
234
|
+
provisioned = {
|
|
235
|
+
row[0]
|
|
236
|
+
for row in con.execute(
|
|
237
|
+
"SELECT table_name FROM information_schema.tables "
|
|
238
|
+
"WHERE table_catalog = 'dst'"
|
|
239
|
+
).fetchall()
|
|
240
|
+
}
|
|
241
|
+
for (table,) in con.execute(
|
|
242
|
+
"SELECT table_name FROM information_schema.tables "
|
|
243
|
+
"WHERE table_catalog = 'src'"
|
|
244
|
+
).fetchall():
|
|
245
|
+
if publishing and table.startswith("ducklake_inlined_data_"):
|
|
246
|
+
# The registry too, so no reader looks for the tables. The
|
|
247
|
+
# destination is fresh, so its own registry is empty.
|
|
248
|
+
continue
|
|
249
|
+
if table in provisioned:
|
|
250
|
+
con.execute(f"DELETE FROM dst.main.{table}")
|
|
251
|
+
con.execute(
|
|
252
|
+
f"INSERT INTO dst.main.{table} SELECT * FROM src.main.{table}"
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
# Tables a fresh catalog does not provision, such as the
|
|
256
|
+
# inline data of small writes.
|
|
257
|
+
con.execute(
|
|
258
|
+
f"CREATE TABLE dst.main.{table} AS "
|
|
259
|
+
f"SELECT * FROM src.main.{table}"
|
|
260
|
+
)
|
|
261
|
+
con.execute("CHECKPOINT dst")
|
|
262
|
+
finally:
|
|
263
|
+
con.close()
|
|
264
|
+
|
|
265
|
+
if target.exists():
|
|
266
|
+
target.unlink()
|
|
267
|
+
# Sidecars next to the target belong to the catalog being replaced.
|
|
268
|
+
# Keeping them replays one database's log into another the next time
|
|
269
|
+
# the file is opened, so they go with the file they belong to.
|
|
270
|
+
for path in _sidecars(target):
|
|
271
|
+
if path.exists():
|
|
272
|
+
path.unlink()
|
|
273
|
+
tmp.rename(target)
|
|
274
|
+
except CatalogError:
|
|
275
|
+
raise
|
|
276
|
+
except Exception as exc: # noqa: BLE001 - duckdb raises several types
|
|
277
|
+
raise CatalogError(f"could not convert {source} to {target}: {exc}") from exc
|
|
278
|
+
finally:
|
|
279
|
+
for path in leftovers:
|
|
280
|
+
if path.exists():
|
|
281
|
+
path.unlink()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# -- what a catalog records about itself ---------------------------------------
|
|
285
|
+
|
|
286
|
+
DATA_PATH_KEY = "data_path"
|
|
287
|
+
|
|
288
|
+
#: The key a catalog records its own DuckLake format version under.
|
|
289
|
+
FORMAT_KEY = "version"
|
|
290
|
+
|
|
291
|
+
#: Every table that names a file. All of them must hold relative paths.
|
|
292
|
+
_FILE_TABLES = ("ducklake_data_file", "ducklake_delete_file")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclass(frozen=True)
|
|
296
|
+
class AbsolutePath:
|
|
297
|
+
table: str
|
|
298
|
+
path: str
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _open(catalog_path: Path):
|
|
302
|
+
import duckdb
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
return duckdb.connect(str(catalog_path))
|
|
306
|
+
except Exception as exc: # noqa: BLE001 - duckdb raises several types here
|
|
307
|
+
hint = ""
|
|
308
|
+
if catalog_path.suffix == ".sqlite":
|
|
309
|
+
# The working format. Readers get the DuckDB one.
|
|
310
|
+
hint = " This is the working catalog; convert it first."
|
|
311
|
+
raise CatalogError(
|
|
312
|
+
f"cannot open the catalog {catalog_path}: {exc}{hint}"
|
|
313
|
+
) from exc
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _table_exists(con, table: str) -> bool:
|
|
317
|
+
row = con.execute(
|
|
318
|
+
"SELECT count(*) FROM information_schema.tables WHERE table_name = ?",
|
|
319
|
+
[table],
|
|
320
|
+
).fetchone()
|
|
321
|
+
return bool(row and row[0])
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def recorded_data_path(catalog_path: Path) -> str | None:
|
|
325
|
+
"""Return the data path currently recorded in the catalog."""
|
|
326
|
+
con = _open(Path(catalog_path))
|
|
327
|
+
try:
|
|
328
|
+
if not _table_exists(con, "ducklake_metadata"):
|
|
329
|
+
return None
|
|
330
|
+
row = con.execute(
|
|
331
|
+
f"SELECT value FROM ducklake_metadata WHERE key = '{DATA_PATH_KEY}'"
|
|
332
|
+
).fetchone()
|
|
333
|
+
finally:
|
|
334
|
+
con.close()
|
|
335
|
+
return row[0] if row else None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def recorded_format(catalog_path: Path) -> str | None:
|
|
339
|
+
"""Return the DuckLake format version the catalog records for itself."""
|
|
340
|
+
con = _open(Path(catalog_path))
|
|
341
|
+
try:
|
|
342
|
+
if not _table_exists(con, "ducklake_metadata"):
|
|
343
|
+
return None
|
|
344
|
+
row = con.execute(
|
|
345
|
+
f"SELECT value FROM ducklake_metadata "
|
|
346
|
+
f"WHERE key = '{FORMAT_KEY}' AND scope IS NULL"
|
|
347
|
+
).fetchone()
|
|
348
|
+
finally:
|
|
349
|
+
con.close()
|
|
350
|
+
return row[0] if row else None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def set_data_path(catalog_path: Path, value: str) -> None:
|
|
354
|
+
"""Rewrite the catalog's data path to ``value``.
|
|
355
|
+
|
|
356
|
+
``value`` is where readers will find the data files, which can differ
|
|
357
|
+
from the place the build wrote them to.
|
|
358
|
+
"""
|
|
359
|
+
catalog_path = Path(catalog_path)
|
|
360
|
+
con = _open(catalog_path)
|
|
361
|
+
row = None
|
|
362
|
+
try:
|
|
363
|
+
if _table_exists(con, "ducklake_metadata"):
|
|
364
|
+
con.execute(
|
|
365
|
+
"UPDATE ducklake_metadata SET value = ? "
|
|
366
|
+
f"WHERE key = '{DATA_PATH_KEY}' AND scope IS NULL",
|
|
367
|
+
[value],
|
|
368
|
+
)
|
|
369
|
+
row = con.execute(
|
|
370
|
+
f"SELECT count(*) FROM ducklake_metadata "
|
|
371
|
+
f"WHERE key = '{DATA_PATH_KEY}' AND value = ?",
|
|
372
|
+
[value],
|
|
373
|
+
).fetchone()
|
|
374
|
+
finally:
|
|
375
|
+
con.close()
|
|
376
|
+
if not row or not row[0]:
|
|
377
|
+
raise CatalogError(
|
|
378
|
+
f"{catalog_path}: this is not a DuckLake catalog (no "
|
|
379
|
+
f"{DATA_PATH_KEY} to rewrite)."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def absolute_paths(catalog_path: Path) -> list[AbsolutePath]:
|
|
384
|
+
"""Return every file reference that is not relative to the data path."""
|
|
385
|
+
con = _open(Path(catalog_path))
|
|
386
|
+
found: list[AbsolutePath] = []
|
|
387
|
+
try:
|
|
388
|
+
for table in _FILE_TABLES:
|
|
389
|
+
if not _table_exists(con, table):
|
|
390
|
+
continue
|
|
391
|
+
rows = con.execute(
|
|
392
|
+
f"SELECT path FROM {table} WHERE NOT path_is_relative"
|
|
393
|
+
).fetchall()
|
|
394
|
+
found.extend(AbsolutePath(table=table, path=row[0]) for row in rows)
|
|
395
|
+
finally:
|
|
396
|
+
con.close()
|
|
397
|
+
return found
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def data_files(catalog_path: Path) -> list[str]:
|
|
401
|
+
"""Return the data files' keys, relative to the data path.
|
|
402
|
+
|
|
403
|
+
DuckLake stores each part separately (schema, table, file), so the key is
|
|
404
|
+
the three joined. Used where the
|
|
405
|
+
bucket cannot be listed.
|
|
406
|
+
"""
|
|
407
|
+
con = _open(Path(catalog_path))
|
|
408
|
+
keys: set[str] = set()
|
|
409
|
+
try:
|
|
410
|
+
for table in _FILE_TABLES:
|
|
411
|
+
if not _table_exists(con, table):
|
|
412
|
+
continue
|
|
413
|
+
rows = con.execute(f"""
|
|
414
|
+
SELECT DISTINCT s.path, t.path, f.path
|
|
415
|
+
FROM {table} f
|
|
416
|
+
JOIN ducklake_table t ON t.table_id = f.table_id
|
|
417
|
+
JOIN ducklake_schema s ON s.schema_id = t.schema_id
|
|
418
|
+
WHERE f.path_is_relative
|
|
419
|
+
""").fetchall()
|
|
420
|
+
keys.update("".join(part or "" for part in row) for row in rows)
|
|
421
|
+
finally:
|
|
422
|
+
con.close()
|
|
423
|
+
return sorted(keys)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# -- what a version records about its catalog ----------------------------------
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def storage_version(catalog_path: Path) -> str:
|
|
430
|
+
"""The file's storage format as a version records it, such as
|
|
431
|
+
``duckdb-v1.5.0`` (fdl.md's `catalog.storage`).
|
|
432
|
+
|
|
433
|
+
A reader on an older DuckDB cannot open a newer file at all, whatever the
|
|
434
|
+
DuckLake format inside it, so a version records both. DuckDB reports the
|
|
435
|
+
tag as ``v1.5.0+``; the ``+`` (and later) is what every storage tag means,
|
|
436
|
+
so it is dropped rather than written into the format.
|
|
437
|
+
"""
|
|
438
|
+
import duckdb
|
|
439
|
+
|
|
440
|
+
con = duckdb.connect()
|
|
441
|
+
try:
|
|
442
|
+
con.execute(f"ATTACH '{_escape(str(catalog_path))}' AS f (READ_ONLY)")
|
|
443
|
+
row = con.execute(
|
|
444
|
+
"SELECT tags['storage_version'] FROM duckdb_databases() "
|
|
445
|
+
"WHERE database_name = 'f'"
|
|
446
|
+
).fetchone()
|
|
447
|
+
except Exception as exc: # noqa: BLE001 - duckdb raises several types
|
|
448
|
+
raise CatalogError(f"cannot open the catalog {catalog_path}: {exc}") from exc
|
|
449
|
+
finally:
|
|
450
|
+
con.close()
|
|
451
|
+
if not row or not row[0]:
|
|
452
|
+
raise CatalogError(f"{catalog_path} records no storage version")
|
|
453
|
+
return f"duckdb-{row[0].rstrip('+')}"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def snapshot_range(catalog_path: Path, after: int | None) -> tuple[int, int]:
|
|
457
|
+
"""The first and last snapshot past ``after`` (the parent's last one).
|
|
458
|
+
|
|
459
|
+
A version holds the snapshots its writer made since the version it was
|
|
460
|
+
built on. With nothing new the range is the last snapshot alone, which is
|
|
461
|
+
what a version made again from the same state holds.
|
|
462
|
+
"""
|
|
463
|
+
con = _open(Path(catalog_path))
|
|
464
|
+
try:
|
|
465
|
+
last = con.execute("SELECT max(snapshot_id) FROM ducklake_snapshot").fetchone()[0]
|
|
466
|
+
first = con.execute(
|
|
467
|
+
"SELECT min(snapshot_id) FROM ducklake_snapshot WHERE snapshot_id > ?",
|
|
468
|
+
[-1 if after is None else after],
|
|
469
|
+
).fetchone()[0]
|
|
470
|
+
except Exception as exc: # noqa: BLE001 - duckdb raises several types
|
|
471
|
+
raise CatalogError(f"{catalog_path} is not a DuckLake catalog: {exc}") from exc
|
|
472
|
+
finally:
|
|
473
|
+
con.close()
|
|
474
|
+
if last is None:
|
|
475
|
+
raise CatalogError(f"{catalog_path} holds no snapshot")
|
|
476
|
+
return (first if first is not None else last, last)
|
fdl/cli.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The ``fdl`` command: the operations in `fdl.repo`, on a terminal.
|
|
2
|
+
|
|
3
|
+
A working catalog is a DuckLake catalog in SQLite that you write to with any
|
|
4
|
+
DuckDB client. Beside it sits ``<working>.fdl.json``, which remembers the
|
|
5
|
+
repository it came from and the version it was checked out at, so a commit
|
|
6
|
+
knows what it builds on.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from . import catalog, layout, refs, repo
|
|
19
|
+
from .stores import StoreError, open_store
|
|
20
|
+
from .version import VersionError
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(no_args_is_help=True, pretty_exceptions_short=True)
|
|
23
|
+
|
|
24
|
+
Remote = typer.Argument(help="The repository: s3://bucket/prefix/ or a folder")
|
|
25
|
+
Working = typer.Argument(help="The working catalog (.sqlite)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _state_path(working: Path) -> Path:
|
|
29
|
+
return working.with_name(working.name + ".fdl.json")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_state(working: Path) -> dict:
|
|
33
|
+
path = _state_path(working)
|
|
34
|
+
if not path.is_file():
|
|
35
|
+
raise typer.BadParameter(f"{working} was not made by `fdl checkout`")
|
|
36
|
+
return json.loads(path.read_text())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _write_state(working: Path, state: dict) -> None:
|
|
40
|
+
_state_path(working).write_text(json.dumps(state, indent=2) + "\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fail(exc: Exception) -> typer.Exit:
|
|
44
|
+
typer.echo(f"error: {exc}", err=True)
|
|
45
|
+
return typer.Exit(1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ERRORS = (repo.RepoError, refs.RefError, layout.LayoutError, VersionError, StoreError)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def init(
|
|
53
|
+
remote: str = Remote,
|
|
54
|
+
data_dir: str = typer.Option(layout.DEFAULT_DATA_DIR, help="Where data files go, under the root"),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Start a repository (fdl.json and HEAD). Harmless on one that exists."""
|
|
57
|
+
try:
|
|
58
|
+
config = repo.init(open_store(remote), data_dir=data_dir)
|
|
59
|
+
except ERRORS as exc:
|
|
60
|
+
raise _fail(exc) from exc
|
|
61
|
+
typer.echo(f"fdl {config.fdl} repository, data in {config.data_dir}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def checkout(
|
|
66
|
+
remote: str = Remote,
|
|
67
|
+
working: Path = Working,
|
|
68
|
+
ref: str = typer.Option(layout.DEFAULT_BRANCH, help="The branch to build on"),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Make a working catalog from the version a branch holds."""
|
|
71
|
+
store = open_store(remote)
|
|
72
|
+
try:
|
|
73
|
+
version = repo.checkout(store, working, ref=ref)
|
|
74
|
+
data = repo.data_url(store)
|
|
75
|
+
except ERRORS as exc:
|
|
76
|
+
raise _fail(exc) from exc
|
|
77
|
+
_write_state(working, {"remote": remote, "ref": ref, "base": version.id if version else None})
|
|
78
|
+
typer.echo(f"{working} at {version.id if version else 'an empty catalog'}")
|
|
79
|
+
typer.echo("Write to it with:")
|
|
80
|
+
typer.echo(" " + catalog.attach_sql(working, alias="lake", data_path=data)[-1] + ";")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command()
|
|
84
|
+
def commit(
|
|
85
|
+
working: Path = Working,
|
|
86
|
+
message: str = typer.Option(..., "-m", "--message", help="What changed"),
|
|
87
|
+
author: str = typer.Option(..., help="Who made it"),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Freeze the working catalog into a version and push it to its branch."""
|
|
90
|
+
state = _read_state(working)
|
|
91
|
+
store = open_store(state["remote"])
|
|
92
|
+
out = Path(tempfile.mkdtemp(prefix="fdl-"))
|
|
93
|
+
try:
|
|
94
|
+
parent = repo.read_version(store, state["base"]) if state["base"] else None
|
|
95
|
+
version = repo.commit(
|
|
96
|
+
working,
|
|
97
|
+
out,
|
|
98
|
+
parent=parent,
|
|
99
|
+
data_path=repo.data_url(store),
|
|
100
|
+
author={"name": author},
|
|
101
|
+
message=message,
|
|
102
|
+
)
|
|
103
|
+
repo.push(store, out / version.id, version, ref=state["ref"])
|
|
104
|
+
except ERRORS as exc:
|
|
105
|
+
raise _fail(exc) from exc
|
|
106
|
+
finally:
|
|
107
|
+
shutil.rmtree(out, ignore_errors=True)
|
|
108
|
+
_write_state(working, {**state, "base": version.id})
|
|
109
|
+
typer.echo(f"{state['ref']} -> {version.id} (version {version.seq})")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command()
|
|
113
|
+
def log(remote: str = Remote, ref: str = typer.Option(layout.HEAD)) -> None:
|
|
114
|
+
"""Versions from the one a ref holds back to the first."""
|
|
115
|
+
store = open_store(remote)
|
|
116
|
+
try:
|
|
117
|
+
for version in repo.log(store, ref):
|
|
118
|
+
mark = " (expired)" if repo.is_expired(store, version.id) else ""
|
|
119
|
+
typer.echo(f"{version.seq:>4} {version.id} {version.created_at} {version.author['name']}{mark}")
|
|
120
|
+
typer.echo(f" {version.message}")
|
|
121
|
+
except ERRORS as exc:
|
|
122
|
+
raise _fail(exc) from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command()
|
|
126
|
+
def url(remote: str = Remote, ref: str = typer.Option(layout.HEAD)) -> None:
|
|
127
|
+
"""Where the catalog of the version a ref holds is, to ATTACH."""
|
|
128
|
+
store = open_store(remote)
|
|
129
|
+
try:
|
|
130
|
+
version = repo.head(store, ref)
|
|
131
|
+
except ERRORS as exc:
|
|
132
|
+
raise _fail(exc) from exc
|
|
133
|
+
if version is None:
|
|
134
|
+
raise _fail(repo.RepoError(f"{ref} holds no version yet"))
|
|
135
|
+
typer.echo(store.url.rstrip("/") + "/" + layout.version_catalog(version.id))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def expire(
|
|
140
|
+
remote: str = Remote,
|
|
141
|
+
keep: int = typer.Option(1, help="How many of the newest versions to keep"),
|
|
142
|
+
since: str | None = typer.Option(None, help="Also keep those made at or after this time (ISO 8601)"),
|
|
143
|
+
ref: str = typer.Option(layout.DEFAULT_BRANCH),
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Take older versions out of retention: mark them and drop their catalogs.
|
|
146
|
+
|
|
147
|
+
Data files are left in place; versions that are kept still share them.
|
|
148
|
+
"""
|
|
149
|
+
store = open_store(remote)
|
|
150
|
+
try:
|
|
151
|
+
newest = repo.head(store, ref)
|
|
152
|
+
expired = repo.prune(store, newest, keep=keep, since=since) if newest else []
|
|
153
|
+
except ERRORS as exc:
|
|
154
|
+
raise _fail(exc) from exc
|
|
155
|
+
typer.echo(f"Expired {len(expired)} version(s).")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main() -> None:
|
|
159
|
+
app()
|