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/layout.py ADDED
@@ -0,0 +1,64 @@
1
+ """Where each file of a repository sits, relative to its root.
2
+
3
+ The same names are used on the remote and in a local `.fdl/`, so syncing the
4
+ two is copying files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ #: The version of the format this package writes and reads.
12
+ FDL_VERSION = "0.1"
13
+
14
+ CONFIG = "fdl.json"
15
+ HEAD = "HEAD"
16
+ DEFAULT_BRANCH = "refs/heads/main"
17
+ DEFAULT_DATA_DIR = "data/"
18
+
19
+ VERSION_RECORD = "version.json"
20
+ CATALOG = "ducklake.duckdb"
21
+ EXPIRED = "expired.json"
22
+ #: Other formats of the same catalog, under the version (optional).
23
+ CATALOGS_DIR = "catalogs/"
24
+
25
+ #: What a symbolic ref starts with. HEAD is the only one.
26
+ SYMBOLIC = "ref: "
27
+
28
+ _REF_PART = re.compile(r"[a-z0-9][a-z0-9._-]*")
29
+ _REF_KINDS = ("heads", "tags", "requests")
30
+ _REF_MAX = 128
31
+
32
+
33
+ class LayoutError(ValueError):
34
+ """A name that this format does not allow."""
35
+
36
+
37
+ def check_ref(name: str) -> str:
38
+ """Return ``name`` if it is a full ref name (``refs/heads/main``), or refuse."""
39
+ parts = name.split("/")
40
+ if len(parts) < 3 or parts[0] != "refs" or parts[1] not in _REF_KINDS:
41
+ raise LayoutError(f"not a ref: {name!r} (refs/heads/<name>, refs/tags/<name>)")
42
+ short = "/".join(parts[2:])
43
+ if len(short) > _REF_MAX or not all(_REF_PART.fullmatch(p) for p in parts[2:]):
44
+ raise LayoutError(
45
+ f"not a ref name: {short!r} (lower case letters, digits and ._-, "
46
+ f"up to {_REF_MAX} characters)"
47
+ )
48
+ return name
49
+
50
+
51
+ def version_dir(version_id: str) -> str:
52
+ return f"versions/{version_id}/"
53
+
54
+
55
+ def version_record(version_id: str) -> str:
56
+ return version_dir(version_id) + VERSION_RECORD
57
+
58
+
59
+ def version_catalog(version_id: str) -> str:
60
+ return version_dir(version_id) + CATALOG
61
+
62
+
63
+ def version_expired(version_id: str) -> str:
64
+ return version_dir(version_id) + EXPIRED
fdl/py.typed ADDED
File without changes
fdl/refs.py ADDED
@@ -0,0 +1,115 @@
1
+ """Reading refs, and moving them only on condition.
2
+
3
+ A ref holds a version ID and a newline. HEAD alone holds ``ref: <name>``
4
+ instead. Every move is a conditional write: create when absent, replace when
5
+ the ETag is still the one read. A writer that loses the race is told so and
6
+ has to build on what won, which is what keeps two pushes from erasing each
7
+ other without a server in between.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from . import layout
15
+ from .store import Store, etag_of, is_conflict, is_missing
16
+
17
+
18
+ class RefError(RuntimeError):
19
+ """A ref that is malformed, or names something that is not there."""
20
+
21
+
22
+ class RefConflict(RefError):
23
+ """The ref moved after it was read."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Ref:
28
+ name: str
29
+ #: A version ID, or for HEAD the ref it points at.
30
+ target: str
31
+ etag: str | None
32
+
33
+
34
+ def read(store: Store, name: str) -> Ref | None:
35
+ """The ref as stored, with the ETag a move has to name. ``None`` if absent.
36
+
37
+ The ETag is read first: if the ref moves in between, the content is newer
38
+ than the ETag and the move that follows is refused, which is the safe way
39
+ round.
40
+ """
41
+ headers = store.head(name)
42
+ if headers is None:
43
+ # Some stores answer a HEAD they may not read (403) as absent. Taken at
44
+ # its word, a branch nobody can read looks unborn, and the version
45
+ # built on it loses its parent. A GET says which it is: missing is
46
+ # 404, anything else is raised.
47
+ try:
48
+ store.get(name)
49
+ except Exception as exc: # noqa: BLE001 - stores raise their own types
50
+ if is_missing(exc):
51
+ return None
52
+ raise
53
+ raise RefError(f"{name} is there but its ETag could not be read")
54
+ try:
55
+ text = store.get(name).decode("utf-8")
56
+ except Exception as exc: # noqa: BLE001 - stores raise their own types
57
+ if is_missing(exc):
58
+ return None
59
+ raise
60
+ if not text.endswith("\n") or text.count("\n") != 1:
61
+ raise RefError(f"{name} is not one line ending in a newline")
62
+ target = text[:-1]
63
+ if name == layout.HEAD:
64
+ if not target.startswith(layout.SYMBOLIC):
65
+ raise RefError(f"HEAD does not point at a ref: {target!r}")
66
+ target = layout.check_ref(target[len(layout.SYMBOLIC) :])
67
+ return Ref(name=name, target=target, etag=etag_of(headers))
68
+
69
+
70
+ def resolve(store: Store, name: str = layout.HEAD) -> tuple[str, str | None]:
71
+ """The ref ``name`` stands for and the version it holds (``None`` if unborn)."""
72
+ if name == layout.HEAD:
73
+ head = read(store, layout.HEAD)
74
+ name = head.target if head else layout.DEFAULT_BRANCH
75
+ ref = read(store, layout.check_ref(name))
76
+ return name, ref.target if ref else None
77
+
78
+
79
+ def write(store: Store, name: str, version_id: str, *, expected: Ref | None) -> str:
80
+ """Point ``name`` at ``version_id``, on condition it still is ``expected``.
81
+
82
+ ``expected`` is what the caller read: ``None`` to create the ref. Returns
83
+ the new ETag.
84
+ """
85
+ layout.check_ref(name)
86
+ condition = (
87
+ {"if_match": expected.etag} if expected is not None else {"if_none_match": "*"}
88
+ )
89
+ if expected is not None and not expected.etag:
90
+ raise RefError(f"{name} was read without an ETag, so it cannot be moved safely")
91
+ try:
92
+ return store.put(
93
+ name,
94
+ f"{version_id}\n".encode(),
95
+ content_type="text/plain; charset=utf-8",
96
+ **condition,
97
+ )
98
+ except Exception as exc: # noqa: BLE001 - stores raise their own types
99
+ if is_conflict(exc):
100
+ raise RefConflict(f"{name} moved since it was read") from exc
101
+ raise
102
+
103
+
104
+ def write_head(store: Store, branch: str = layout.DEFAULT_BRANCH) -> None:
105
+ """Create HEAD pointing at ``branch``. Left alone when it is already there."""
106
+ try:
107
+ store.put(
108
+ layout.HEAD,
109
+ f"{layout.SYMBOLIC}{layout.check_ref(branch)}\n".encode(),
110
+ content_type="text/plain; charset=utf-8",
111
+ if_none_match="*",
112
+ )
113
+ except Exception as exc: # noqa: BLE001 - stores raise their own types
114
+ if not is_conflict(exc):
115
+ raise
fdl/repo.py ADDED
@@ -0,0 +1,433 @@
1
+ """The operations on a repository, with no terminal and no service behind them.
2
+
3
+ Each takes a `Store` for the remote and paths for the local side, and returns
4
+ what it did. Printing, prompts and credentials belong to whoever calls these.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime as dt
10
+ import hashlib
11
+ import itertools
12
+ import json
13
+ import shutil
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Iterator
17
+
18
+ from . import catalog, layout, refs
19
+ from .store import Store, is_conflict, is_missing
20
+ from .version import Catalog, DuckLake, Version, new_id, parse, utc_now
21
+
22
+
23
+ class RepoError(RuntimeError):
24
+ """The repository cannot do what was asked, and nothing was changed."""
25
+
26
+
27
+ class NotFastForward(RepoError):
28
+ """The version is not built on what the ref holds now. Pull and build again."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Config:
33
+ fdl: str
34
+ data_dir: str
35
+
36
+
37
+ def init(store: Store, *, data_dir: str = layout.DEFAULT_DATA_DIR) -> Config:
38
+ """Start a repository: `fdl.json` and HEAD, and no versions yet.
39
+
40
+ An existing repository is left as it is and its settings returned, so
41
+ running this twice is harmless.
42
+ """
43
+ if not data_dir.endswith("/"):
44
+ raise RepoError(f"data_dir has to end with '/': {data_dir!r}")
45
+ record = {"fdl": layout.FDL_VERSION, "data_dir": data_dir}
46
+ try:
47
+ store.put(
48
+ layout.CONFIG,
49
+ (json.dumps(record, indent=2) + "\n").encode(),
50
+ content_type="application/json",
51
+ if_none_match="*",
52
+ )
53
+ except Exception as exc: # noqa: BLE001 - stores raise their own types
54
+ if not is_conflict(exc):
55
+ raise
56
+ refs.write_head(store)
57
+ return read_config(store)
58
+
59
+
60
+ def read_config(store: Store) -> Config:
61
+ try:
62
+ record = json.loads(store.get(layout.CONFIG))
63
+ except Exception as exc: # noqa: BLE001 - stores raise their own types
64
+ if is_missing(exc):
65
+ raise RepoError("not an FDL repository (no fdl.json)") from exc
66
+ raise
67
+ major = str(record.get("fdl", "")).split(".")[0]
68
+ if major != layout.FDL_VERSION.split(".")[0]:
69
+ raise RepoError(f"written for fdl {record.get('fdl')}, this reads {layout.FDL_VERSION}")
70
+ return Config(fdl=record["fdl"], data_dir=record.get("data_dir", layout.DEFAULT_DATA_DIR))
71
+
72
+
73
+ def read_version(store: Store, version_id: str) -> Version:
74
+ try:
75
+ data = store.get(layout.version_record(version_id))
76
+ except Exception as exc: # noqa: BLE001 - stores raise their own types
77
+ if is_missing(exc):
78
+ raise RepoError(f"no version {version_id}") from exc
79
+ raise
80
+ return parse(data, expected_id=version_id)
81
+
82
+
83
+ def head(store: Store, name: str = layout.HEAD) -> Version | None:
84
+ """The version a ref holds, or ``None`` before the first push."""
85
+ _, version_id = refs.resolve(store, name)
86
+ return read_version(store, version_id) if version_id else None
87
+
88
+
89
+ def commit(
90
+ working: Path,
91
+ out_dir: Path,
92
+ *,
93
+ parent: Version | None,
94
+ data_path: str,
95
+ author: dict,
96
+ message: str,
97
+ inputs: tuple[dict, ...] = (),
98
+ extensions: dict | None = None,
99
+ ) -> Version:
100
+ """Freeze the working catalog into a version under ``out_dir/<ID>/``.
101
+
102
+ ``data_path`` is where readers find the data files (the remote's root and
103
+ data_dir), written into the frozen catalog. Every file reference must be
104
+ relative to it, or the version would point readers at the writer's disk.
105
+ Nothing is uploaded: that is `push`.
106
+ """
107
+ Path(out_dir).mkdir(parents=True, exist_ok=True)
108
+ frozen = Path(out_dir) / f".freezing-{new_id()}.duckdb"
109
+ try:
110
+ catalog.convert(working, frozen, data_path=data_path)
111
+ catalog.set_data_path(frozen, data_path)
112
+ absolute = catalog.absolute_paths(frozen)
113
+ if absolute:
114
+ raise RepoError(
115
+ f"{len(absolute)} file(s) are recorded with an absolute path "
116
+ f"(first: {absolute[0].path}); rebuild so they sit under the data path"
117
+ )
118
+ return record(
119
+ frozen,
120
+ out_dir,
121
+ parent=parent,
122
+ author=author,
123
+ message=message,
124
+ inputs=inputs,
125
+ extensions=extensions,
126
+ )
127
+ finally:
128
+ frozen.unlink(missing_ok=True)
129
+
130
+
131
+ def checkout(store: Store, working: Path, *, ref: str = layout.HEAD) -> Version | None:
132
+ """Make a working catalog from the version ``ref`` holds.
133
+
134
+ Before the first version there is nothing to copy, and an empty working
135
+ catalog is created instead. Returns the version checked out, which a
136
+ commit from this working catalog builds on.
137
+ """
138
+ working = Path(working)
139
+ config = read_config(store)
140
+ data_path = _data_url(store, config)
141
+ version = head(store, ref)
142
+ if version is None:
143
+ catalog.create(working, alias="lake", data_path=data_path)
144
+ return None
145
+ if is_expired(store, version.id):
146
+ raise RepoError(f"{version.id} is past retention and has no catalog")
147
+ working.parent.mkdir(parents=True, exist_ok=True)
148
+ frozen = working.with_name(f".{working.name}.{version.id}.duckdb")
149
+ try:
150
+ frozen.write_bytes(store.get(layout.version_catalog(version.id)))
151
+ catalog.convert(frozen, working, data_path=data_path)
152
+ finally:
153
+ frozen.unlink(missing_ok=True)
154
+ return version
155
+
156
+
157
+ def data_url(store: Store) -> str:
158
+ """Where readers and writers find the data files: the root and data_dir."""
159
+ return _data_url(store, read_config(store))
160
+
161
+
162
+ def _data_url(store: Store, config: Config) -> str:
163
+ root = getattr(store, "url", None)
164
+ if not root:
165
+ raise RepoError("this store does not say where it is, so data files cannot be placed")
166
+ return root.rstrip("/") + "/" + config.data_dir
167
+
168
+
169
+ def record(
170
+ frozen: Path,
171
+ out_dir: Path,
172
+ *,
173
+ parent: Version | None,
174
+ author: dict,
175
+ message: str,
176
+ inputs: tuple[dict, ...] = (),
177
+ extensions: dict | None = None,
178
+ ) -> Version:
179
+ """Make a version out of a catalog that is already frozen.
180
+
181
+ For a writer that freezes the catalog itself (with checks of its own) and
182
+ only needs the version around it. The catalog is copied, not moved.
183
+ """
184
+ if not author.get("name"):
185
+ raise RepoError("a version needs an author name")
186
+ version_id = new_id()
187
+ target = Path(out_dir) / version_id
188
+ target.mkdir(parents=True)
189
+ copied = target / layout.CATALOG
190
+ try:
191
+ shutil.copyfile(frozen, copied)
192
+ recorded = catalog.recorded_format(copied)
193
+ if not recorded:
194
+ raise RepoError(f"{frozen} records no DuckLake format")
195
+ first, last = catalog.snapshot_range(
196
+ copied, parent.ducklake.snapshot_id if parent else None
197
+ )
198
+ version = Version(
199
+ id=version_id,
200
+ seq=parent.seq + 1 if parent else 1,
201
+ parents=(parent.id,) if parent else (),
202
+ created_at=utc_now(),
203
+ author=dict(author),
204
+ message=message,
205
+ catalog=Catalog(
206
+ path=layout.CATALOG,
207
+ storage=catalog.storage_version(copied),
208
+ bytes=copied.stat().st_size,
209
+ sha256=_sha256(copied),
210
+ ),
211
+ ducklake=DuckLake(format=recorded, snapshot_id=last, snapshots=(first, last)),
212
+ inputs=tuple(inputs),
213
+ extensions=dict(extensions or {}),
214
+ )
215
+ (target / layout.VERSION_RECORD).write_bytes(version.to_json())
216
+ except BaseException:
217
+ # Whatever stopped it (a full disk included), a half-made version is a
218
+ # copy of the catalog that nothing will ever read.
219
+ shutil.rmtree(target, ignore_errors=True)
220
+ raise
221
+ return version
222
+
223
+
224
+ def push(
225
+ store: Store,
226
+ local: Path,
227
+ version: Version,
228
+ *,
229
+ ref: str = layout.DEFAULT_BRANCH,
230
+ move_ref: bool = True,
231
+ ) -> None:
232
+ """Upload a committed version, then move ``ref`` to it.
233
+
234
+ In the order the format asks for: the data files must already be there,
235
+ then the catalog, then `version.json`, and the ref last. A push stopped
236
+ half way leaves files no ref points at, which readers never see.
237
+
238
+ ``move_ref=False`` uploads the version and stops: for a host that moves
239
+ its refs itself (after an approval, say).
240
+ """
241
+ config = read_config(store)
242
+ current_ref = refs.read(store, layout.check_ref(ref))
243
+ if current_ref is not None and current_ref.target == version.id:
244
+ # This push again, after a run whose answer was lost.
245
+ return
246
+ _refuse_unless_fast_forward(current_ref, version)
247
+
248
+ frozen = Path(local) / layout.CATALOG
249
+ missing = _missing_data_files(store, config, frozen)
250
+ if missing:
251
+ raise RepoError(
252
+ f"{len(missing)} data file(s) the catalog names are not in the "
253
+ f"repository (first: {missing[0]}). Rebuild from the working "
254
+ f"catalog and commit again."
255
+ )
256
+ upload(store, local, version)
257
+ if not move_ref:
258
+ return
259
+ try:
260
+ refs.write(store, ref, version.id, expected=current_ref)
261
+ except refs.RefConflict as exc:
262
+ # A write sent again after a lost answer is refused by its own first
263
+ # attempt. That moved the ref to this version, which is what was asked.
264
+ now = refs.read(store, ref)
265
+ if now is not None and now.target == version.id:
266
+ return
267
+ raise NotFastForward(f"{ref} moved during the push. Pull and build again.") from exc
268
+
269
+
270
+ def upload(store: Store, local: Path, version: Version) -> None:
271
+ """Put a committed version's files, catalog first and the record last.
272
+
273
+ No checks and no ref: for a host that checks and moves its refs itself.
274
+ """
275
+ _put_once(store, layout.version_catalog(version.id), Path(local) / layout.CATALOG)
276
+ _put_once(store, layout.version_record(version.id), Path(local) / layout.VERSION_RECORD)
277
+
278
+
279
+ def log(store: Store, name: str = layout.HEAD) -> Iterator[Version]:
280
+ """Versions from the one ``name`` holds back to the first, by first parent."""
281
+ current = head(store, name)
282
+ while current is not None:
283
+ yield current
284
+ current = read_version(store, current.parents[0]) if current.parents else None
285
+
286
+
287
+ def is_ancestor(store: Store, ancestor_id: str, descendant: Version) -> bool:
288
+ """Whether ``ancestor_id`` is ``descendant`` or behind it.
289
+
290
+ Stops once ``seq`` falls below the ancestor's, so a check costs the
291
+ distance between the two rather than the whole history.
292
+ """
293
+ target = read_version(store, ancestor_id)
294
+ pending = [descendant]
295
+ seen: set[str] = set()
296
+ while pending:
297
+ current = pending.pop()
298
+ if current.id == ancestor_id:
299
+ return True
300
+ if current.id in seen or current.seq <= target.seq:
301
+ continue
302
+ seen.add(current.id)
303
+ pending.extend(read_version(store, parent) for parent in current.parents)
304
+ return False
305
+
306
+
307
+ def is_expired(store: Store, version_id: str) -> bool:
308
+ return store.head(layout.version_expired(version_id)) is not None
309
+
310
+
311
+ def expire(store: Store, version_id: str, *, reason: str = "retention") -> None:
312
+ """Take a version out of retention: the mark first, then its catalogs.
313
+
314
+ `version.json` stays, so the history still says the version was there and
315
+ what it was. Data files are not touched: other versions share them, and
316
+ which ones may go is for a pass over the whole repository to decide.
317
+ Running it again finishes a run that stopped between the two steps.
318
+ """
319
+ read_version(store, version_id)
320
+ held = _held(store)
321
+ if version_id in held:
322
+ raise RepoError(f"{held[version_id]} points at {version_id}, so it is kept")
323
+ record = {"expired_at": utc_now(), "reason": reason}
324
+ try:
325
+ store.put(
326
+ layout.version_expired(version_id),
327
+ (json.dumps(record) + "\n").encode(),
328
+ content_type="application/json",
329
+ if_none_match="*",
330
+ )
331
+ except Exception as exc: # noqa: BLE001 - stores raise their own types
332
+ if not is_conflict(exc):
333
+ raise
334
+ store.delete(
335
+ [
336
+ layout.version_catalog(version_id),
337
+ *store.list(layout.version_dir(version_id) + layout.CATALOGS_DIR),
338
+ ]
339
+ )
340
+
341
+
342
+ def prune(
343
+ store: Store,
344
+ newest: Version,
345
+ *,
346
+ keep: int = 1,
347
+ since: str | None = None,
348
+ reason: str = "retention",
349
+ ) -> list[str]:
350
+ """Expire the versions behind ``newest`` that fall out of retention.
351
+
352
+ Kept are the ``keep`` newest on its first-parent line (``newest`` among
353
+ them), every one created at or after ``since`` (an ISO time, as
354
+ ``created_at`` is written), and any a ref points at. The walk stops at
355
+ the first version already expired: an earlier prune took everything past
356
+ it. Returns the IDs expired, newest first.
357
+ """
358
+ if keep < 1:
359
+ raise ValueError(f"keep must be >= 1, got {keep}")
360
+ held = _held(store)
361
+ expired: list[str] = []
362
+ current: Version | None = newest
363
+ for position in itertools.count():
364
+ if current is None or (position > 0 and is_expired(store, current.id)):
365
+ break
366
+ recent = since is not None and _instant(current.created_at) >= _instant(since)
367
+ if position >= keep and not recent and current.id not in held:
368
+ expire(store, current.id, reason=reason)
369
+ expired.append(current.id)
370
+ current = read_version(store, current.parents[0]) if current.parents else None
371
+ return expired
372
+
373
+
374
+ def _held(store: Store) -> dict[str, str]:
375
+ """The versions some ref points at, each with one ref that does."""
376
+ held: dict[str, str] = {}
377
+ for name in store.list("refs/"):
378
+ try:
379
+ ref = refs.read(store, name)
380
+ except (refs.RefError, layout.LayoutError):
381
+ continue
382
+ if ref is not None:
383
+ held.setdefault(ref.target, name)
384
+ return held
385
+
386
+
387
+ def _instant(value: str) -> dt.datetime:
388
+ return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
389
+
390
+
391
+ def _refuse_unless_fast_forward(current: refs.Ref | None, version: Version) -> None:
392
+ if current is None:
393
+ if version.parents:
394
+ raise NotFastForward(
395
+ f"{version.id} is built on {version.parents[0]}, but the branch "
396
+ f"has no version yet"
397
+ )
398
+ return
399
+ if current.target not in version.parents:
400
+ raise NotFastForward(
401
+ f"{current.name} is at {current.target}, which {version.id} is not "
402
+ f"built on. Pull and build again."
403
+ )
404
+
405
+
406
+ def _missing_data_files(store: Store, config: Config, frozen: Path) -> list[str]:
407
+ present = set(store.list(config.data_dir))
408
+ return [
409
+ key
410
+ for key in (config.data_dir + name for name in catalog.data_files(frozen))
411
+ if key not in present
412
+ ]
413
+
414
+
415
+ def _put_once(store: Store, key: str, path: Path) -> None:
416
+ """Write a file of a version. Versions do not change, so an object already
417
+ there is only accepted when it is this very file (a retried push)."""
418
+ content_type = "application/json" if key.endswith(".json") else "application/octet-stream"
419
+ try:
420
+ store.put_file(key, path, content_type=content_type, if_none_match="*")
421
+ except Exception as exc: # noqa: BLE001 - stores raise their own types
422
+ if not is_conflict(exc):
423
+ raise
424
+ if store.get(key) != path.read_bytes():
425
+ raise RepoError(f"{key} is already there with other content") from exc
426
+
427
+
428
+ def _sha256(path: Path) -> str:
429
+ digest = hashlib.sha256()
430
+ with path.open("rb") as fh:
431
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
432
+ digest.update(chunk)
433
+ return digest.hexdigest()
fdl/store.py ADDED
@@ -0,0 +1,64 @@
1
+ """What FDL needs from the storage a repository lives in.
2
+
3
+ Read, write, and write on condition. A store without the last one can be read
4
+ from but not pushed to, because the last writer would silently replace someone
5
+ else's version.
6
+
7
+ `fdl.stores` has two: a local folder and S3. Anything else with the same
8
+ methods works too.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Protocol, Sequence
15
+
16
+
17
+ class Store(Protocol):
18
+ def put(
19
+ self,
20
+ key: str,
21
+ data: bytes,
22
+ *,
23
+ content_type: str | None = None,
24
+ if_match: str | None = None,
25
+ if_none_match: str | None = None,
26
+ ) -> str:
27
+ """Write ``data`` and return the stored ETag. A failed condition raises
28
+ an error whose ``status`` is 412."""
29
+
30
+ def put_file(
31
+ self,
32
+ key: str,
33
+ path: Path,
34
+ *,
35
+ content_type: str | None = None,
36
+ if_match: str | None = None,
37
+ if_none_match: str | None = None,
38
+ ) -> str: ...
39
+
40
+ def get(self, key: str) -> bytes:
41
+ """Read the object. A missing one raises an error whose ``status`` is 404."""
42
+
43
+ def head(self, key: str) -> dict[str, str] | None:
44
+ """The object's headers (``ETag`` among them), or ``None`` when missing."""
45
+
46
+ def list(self, prefix: str = "") -> list[str]: ...
47
+
48
+ def delete(self, keys: Sequence[str]) -> None:
49
+ """Remove these keys. One already gone is not an error."""
50
+
51
+
52
+ def is_conflict(error: BaseException) -> bool:
53
+ """Whether a write was refused by its condition."""
54
+ return getattr(error, "status", None) == 412
55
+
56
+
57
+ def is_missing(error: BaseException) -> bool:
58
+ return getattr(error, "status", None) == 404
59
+
60
+
61
+ def etag_of(headers: dict[str, str]) -> str | None:
62
+ """The ETag out of `Store.head`, whichever case the store wrote it in."""
63
+ found = {key.lower(): value for key, value in headers.items()}
64
+ return found.get("etag")