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