tether-vcs 0.1.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.
- tether/__init__.py +95 -0
- tether/_clickdoc.py +97 -0
- tether/backends/__init__.py +45 -0
- tether/backends/base.py +348 -0
- tether/backends/delta.py +209 -0
- tether/backends/dolt.py +399 -0
- tether/backends/ducklake.py +425 -0
- tether/backends/file.py +420 -0
- tether/backends/git.py +265 -0
- tether/backends/iceberg.py +282 -0
- tether/backends/icechunk.py +256 -0
- tether/backends/lakefs.py +291 -0
- tether/backends/lance.py +299 -0
- tether/backends/memory.py +221 -0
- tether/backends/neon.py +317 -0
- tether/cli.py +530 -0
- tether/errors.py +76 -0
- tether/handles.py +258 -0
- tether/manifest.py +511 -0
- tether/py.typed +0 -0
- tether/repo.py +1051 -0
- tether/testing.py +154 -0
- tether/vcs.py +483 -0
- tether_vcs-0.1.0.dist-info/METADATA +398 -0
- tether_vcs-0.1.0.dist-info/RECORD +28 -0
- tether_vcs-0.1.0.dist-info/WHEEL +4 -0
- tether_vcs-0.1.0.dist-info/entry_points.txt +3 -0
- tether_vcs-0.1.0.dist-info/licenses/LICENSE +202 -0
tether/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""tether: jj-style version control for heterogeneous datasets.
|
|
2
|
+
|
|
3
|
+
The primary entry point is `Repo`: open (or initialize) a dataset inside an
|
|
4
|
+
existing git/jj repository, register objects with `Repo.add`, then `commit`,
|
|
5
|
+
`new`, `open`, `verify`, `diff`, and `gc`. Everything the CLI does is a thin
|
|
6
|
+
wrapper over it.
|
|
7
|
+
|
|
8
|
+
Supporting modules:
|
|
9
|
+
|
|
10
|
+
- `tether.handles`: the typed native handles `Repo.open` returns.
|
|
11
|
+
- `tether.backends`: the `ObjectBackend` protocol, capability tiers, reports,
|
|
12
|
+
and the backend registry (one module per system under `tether.backends.*`).
|
|
13
|
+
- `tether.testing`: the conformance suite for backend authors.
|
|
14
|
+
- `tether.vcs`: the jj/git adapters tether stores its history through.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> from tether import Repo
|
|
18
|
+
>>> repo = Repo.find(".")
|
|
19
|
+
>>> repo.commit("baseline") # pin every object, commit the manifests
|
|
20
|
+
>>> repo.new() # fork writable branches off the pins
|
|
21
|
+
>>> handle = repo.open("zarr/imaging")
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from tether import backends, handles, testing, vcs
|
|
27
|
+
from tether.errors import (
|
|
28
|
+
BackendError,
|
|
29
|
+
CapabilityError,
|
|
30
|
+
ConfigError,
|
|
31
|
+
ImmutableObjectModified,
|
|
32
|
+
MultiObjectError,
|
|
33
|
+
PinDriftError,
|
|
34
|
+
StaleWorkingCopyError,
|
|
35
|
+
TetherError,
|
|
36
|
+
UnpinnedStateError,
|
|
37
|
+
VcsError,
|
|
38
|
+
)
|
|
39
|
+
from tether.manifest import (
|
|
40
|
+
ObjectManifest,
|
|
41
|
+
Pin,
|
|
42
|
+
Policy,
|
|
43
|
+
RepoConfig,
|
|
44
|
+
WorkspaceState,
|
|
45
|
+
compute_pin_id,
|
|
46
|
+
listing_name,
|
|
47
|
+
manifest_hash,
|
|
48
|
+
ref_for_pin,
|
|
49
|
+
working_ref_name,
|
|
50
|
+
)
|
|
51
|
+
from tether.repo import (
|
|
52
|
+
TETHER_REV_ENV,
|
|
53
|
+
CommitResult,
|
|
54
|
+
DiffEntry,
|
|
55
|
+
GcReport,
|
|
56
|
+
ObjectStatus,
|
|
57
|
+
Repo,
|
|
58
|
+
StatusReport,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"TETHER_REV_ENV",
|
|
63
|
+
"BackendError",
|
|
64
|
+
"CapabilityError",
|
|
65
|
+
"CommitResult",
|
|
66
|
+
"ConfigError",
|
|
67
|
+
"DiffEntry",
|
|
68
|
+
"GcReport",
|
|
69
|
+
"ImmutableObjectModified",
|
|
70
|
+
"MultiObjectError",
|
|
71
|
+
"ObjectManifest",
|
|
72
|
+
"ObjectStatus",
|
|
73
|
+
"Pin",
|
|
74
|
+
"PinDriftError",
|
|
75
|
+
"Policy",
|
|
76
|
+
"Repo",
|
|
77
|
+
"RepoConfig",
|
|
78
|
+
"StaleWorkingCopyError",
|
|
79
|
+
"StatusReport",
|
|
80
|
+
"TetherError",
|
|
81
|
+
"UnpinnedStateError",
|
|
82
|
+
"VcsError",
|
|
83
|
+
"WorkspaceState",
|
|
84
|
+
"backends",
|
|
85
|
+
"compute_pin_id",
|
|
86
|
+
"handles",
|
|
87
|
+
"listing_name",
|
|
88
|
+
"manifest_hash",
|
|
89
|
+
"ref_for_pin",
|
|
90
|
+
"testing",
|
|
91
|
+
"vcs",
|
|
92
|
+
"working_ref_name",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
__version__ = "0.1.0"
|
tether/_clickdoc.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Mirror the Typer CLI onto real Click objects for documentation tooling.
|
|
2
|
+
|
|
3
|
+
Typer vendors its own copy of Click, so the command tree
|
|
4
|
+
``typer.main.get_command(app)`` returns is not an instance of the ``click``
|
|
5
|
+
package's classes and tools that introspect Click CLIs (Great Docs) see a single
|
|
6
|
+
opaque command. This module rebuilds the tree with the installed ``click`` so
|
|
7
|
+
every subcommand, argument, and option is discoverable.
|
|
8
|
+
|
|
9
|
+
Documentation-only: importing it requires ``click`` (a Great Docs dependency),
|
|
10
|
+
and nothing in tether imports it at runtime.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from tether.cli import app
|
|
21
|
+
|
|
22
|
+
# Typer's vendored param types report Python-ish names ("str", "int", ...).
|
|
23
|
+
_TYPES: dict[str, click.ParamType] = {
|
|
24
|
+
"str": click.STRING,
|
|
25
|
+
"text": click.STRING,
|
|
26
|
+
"int": click.INT,
|
|
27
|
+
"integer": click.INT,
|
|
28
|
+
"float": click.FLOAT,
|
|
29
|
+
"bool": click.BOOL,
|
|
30
|
+
"boolean": click.BOOL,
|
|
31
|
+
"path": click.Path(),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _param_type(param: Any) -> click.ParamType:
|
|
36
|
+
kind = str(getattr(param.type, "name", "text")).lower()
|
|
37
|
+
choices = getattr(param.type, "choices", None)
|
|
38
|
+
if choices:
|
|
39
|
+
return click.Choice([str(c) for c in choices])
|
|
40
|
+
return _TYPES.get(kind, click.STRING)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default(param: Any) -> Any:
|
|
44
|
+
default = getattr(param, "default", None)
|
|
45
|
+
return None if callable(default) else default
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _convert_param(param: Any) -> click.Parameter:
|
|
49
|
+
if getattr(param, "param_type_name", "") == "argument":
|
|
50
|
+
return click.Argument(
|
|
51
|
+
[param.name],
|
|
52
|
+
required=param.required,
|
|
53
|
+
type=_param_type(param),
|
|
54
|
+
default=_default(param),
|
|
55
|
+
nargs=getattr(param, "nargs", 1),
|
|
56
|
+
)
|
|
57
|
+
decls = list(param.opts)
|
|
58
|
+
if param.secondary_opts:
|
|
59
|
+
decls = [f"{param.opts[0]}/{param.secondary_opts[0]}", *param.opts[1:]]
|
|
60
|
+
return click.Option(
|
|
61
|
+
decls,
|
|
62
|
+
help=getattr(param, "help", None),
|
|
63
|
+
required=param.required,
|
|
64
|
+
type=None if param.is_flag else _param_type(param),
|
|
65
|
+
is_flag=bool(param.is_flag),
|
|
66
|
+
default=_default(param),
|
|
67
|
+
multiple=bool(getattr(param, "multiple", False)),
|
|
68
|
+
show_default=bool(getattr(param, "show_default", False)),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _convert_command(name: str, command: Any) -> click.Command:
|
|
73
|
+
params = [_convert_param(p) for p in command.params if p.name != "help"]
|
|
74
|
+
return click.Command(
|
|
75
|
+
name=name,
|
|
76
|
+
help=command.help,
|
|
77
|
+
short_help=getattr(command, "short_help", None),
|
|
78
|
+
epilog=getattr(command, "epilog", None),
|
|
79
|
+
params=params,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_click_group() -> click.Group:
|
|
84
|
+
"""Return a real ``click.Group`` mirroring the ``tether`` Typer app."""
|
|
85
|
+
typer_group: Any = typer.main.get_command(app) # a TyperGroup at runtime
|
|
86
|
+
subcommands: dict[str, Any] = dict(getattr(typer_group, "commands", {}))
|
|
87
|
+
return click.Group(
|
|
88
|
+
name="tether",
|
|
89
|
+
help=typer_group.help,
|
|
90
|
+
commands={
|
|
91
|
+
name: _convert_command(name, cmd)
|
|
92
|
+
for name, cmd in sorted(subcommands.items())
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
click_app = build_click_group()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Backend protocol, capability tiers, reports, and the registry.
|
|
2
|
+
|
|
3
|
+
A backend maps tether's operations onto one class of system. Built-in kinds
|
|
4
|
+
(`file`, `icechunk`, `neon`, `git`, `iceberg`, `delta`, `lance`, `lakefs`,
|
|
5
|
+
`ducklake`, `dolt`, `memory`) live in `tether.backends.<kind>` and register
|
|
6
|
+
themselves on import; `build_backend` imports them lazily so importing this
|
|
7
|
+
package does not pull in optional third-party dependencies. Third-party
|
|
8
|
+
backends implement `ObjectBackend` and call `register_backend`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from tether.backends.base import (
|
|
14
|
+
MAX_DIFF_ENTRIES,
|
|
15
|
+
Capability,
|
|
16
|
+
ChangeEntry,
|
|
17
|
+
Listings,
|
|
18
|
+
ObjectBackend,
|
|
19
|
+
ObjectDiff,
|
|
20
|
+
Tier,
|
|
21
|
+
VerifyReport,
|
|
22
|
+
VerifyStatus,
|
|
23
|
+
build_backend,
|
|
24
|
+
effective_capabilities,
|
|
25
|
+
known_kinds,
|
|
26
|
+
register_backend,
|
|
27
|
+
tier_of,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"MAX_DIFF_ENTRIES",
|
|
32
|
+
"Capability",
|
|
33
|
+
"ChangeEntry",
|
|
34
|
+
"Listings",
|
|
35
|
+
"ObjectBackend",
|
|
36
|
+
"ObjectDiff",
|
|
37
|
+
"Tier",
|
|
38
|
+
"VerifyReport",
|
|
39
|
+
"VerifyStatus",
|
|
40
|
+
"build_backend",
|
|
41
|
+
"effective_capabilities",
|
|
42
|
+
"known_kinds",
|
|
43
|
+
"register_backend",
|
|
44
|
+
"tier_of",
|
|
45
|
+
]
|
tether/backends/base.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Backend protocol, capability tiers, and a small registry.
|
|
2
|
+
|
|
3
|
+
A *backend* maps tether operations onto one class of system (files, Icechunk,
|
|
4
|
+
Neon, ...). Backends declare their capabilities; the engine (``repo.py``)
|
|
5
|
+
enforces them per command and degrades explicitly, and the conformance suite
|
|
6
|
+
(``testing.py``) runs the tier-appropriate checks against any backend.
|
|
7
|
+
|
|
8
|
+
One backend instance serves every object of its kind; per-object addressing is
|
|
9
|
+
carried in the ``locator`` argument to each method, so backends are cheap,
|
|
10
|
+
stateless coordinators over user-supplied resources.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import Enum, Flag, auto
|
|
18
|
+
from typing import Any, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from tether.errors import CapabilityError
|
|
21
|
+
from tether.handles import Handle
|
|
22
|
+
from tether.manifest import Locator, Pin, State
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Capability(Flag):
|
|
26
|
+
"""What a backend can do.
|
|
27
|
+
|
|
28
|
+
The first four flags are cumulative tiers (see `Tier` and `tier_of`); the
|
|
29
|
+
rest are orthogonal refinements the engine and the docs use to explain
|
|
30
|
+
behavior.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
NONE = 0
|
|
34
|
+
"""No capabilities."""
|
|
35
|
+
FINGERPRINT = auto()
|
|
36
|
+
"""Can read the current state (drift detection). Every backend has this."""
|
|
37
|
+
ADDRESSABLE = auto()
|
|
38
|
+
"""A recorded state can be read back later without a native ref."""
|
|
39
|
+
PIN = auto()
|
|
40
|
+
"""Can create and delete a durable, GC-proof native ref for a state."""
|
|
41
|
+
FORK = auto()
|
|
42
|
+
"""Can create a writable branch off a pin."""
|
|
43
|
+
CHEAP_FINGERPRINT = auto()
|
|
44
|
+
"""Fingerprints are metadata-only; no heavy connection is opened."""
|
|
45
|
+
RETENTION_BOUND = auto()
|
|
46
|
+
"""Recorded/pinned states expire with the system's retention window."""
|
|
47
|
+
NEEDS_QUIESCENCE = auto()
|
|
48
|
+
"""`commit` should check for active writers first."""
|
|
49
|
+
ATOMIC_REF = auto()
|
|
50
|
+
"""Pins have create-if-absent semantics."""
|
|
51
|
+
DIFF = auto()
|
|
52
|
+
"""Can describe what changed between two recorded states."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Tier(Enum):
|
|
56
|
+
"""The cumulative capability tier of a backend (or of one object)."""
|
|
57
|
+
|
|
58
|
+
OBSERVED = "observed"
|
|
59
|
+
"""Drift detection only; committed state is not recoverable."""
|
|
60
|
+
ADDRESSABLE = "addressable"
|
|
61
|
+
"""Committed state can be read back; nothing to create or GC."""
|
|
62
|
+
PINNABLE = "pinnable"
|
|
63
|
+
"""Commit creates a durable native ref; `verify` and `gc` apply."""
|
|
64
|
+
FORKABLE = "forkable"
|
|
65
|
+
"""`new` forks writable branches; `open` returns writable handles."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def tier_of(caps: Capability) -> Tier:
|
|
69
|
+
if Capability.FORK in caps:
|
|
70
|
+
return Tier.FORKABLE
|
|
71
|
+
if Capability.PIN in caps:
|
|
72
|
+
return Tier.PINNABLE
|
|
73
|
+
if Capability.ADDRESSABLE in caps:
|
|
74
|
+
return Tier.ADDRESSABLE
|
|
75
|
+
return Tier.OBSERVED
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class VerifyStatus(Enum):
|
|
79
|
+
"""Outcome of `ObjectBackend.verify`."""
|
|
80
|
+
|
|
81
|
+
OK = "ok"
|
|
82
|
+
"""The pin (or recorded state) resolves to what the manifest says."""
|
|
83
|
+
DRIFTED = "drifted"
|
|
84
|
+
"""The pin exists but points at a different state."""
|
|
85
|
+
MISSING = "missing"
|
|
86
|
+
"""The pin or recorded state is gone (deleted, expired)."""
|
|
87
|
+
UNKNOWN = "unknown"
|
|
88
|
+
"""Cannot tell cheaply; verify with `deep=True`."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class VerifyReport:
|
|
93
|
+
"""Result of `ObjectBackend.verify` for one object."""
|
|
94
|
+
|
|
95
|
+
status: VerifyStatus
|
|
96
|
+
"""The outcome."""
|
|
97
|
+
message: str = ""
|
|
98
|
+
"""Human-readable detail (what it points at, why it is unknown, ...)."""
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def ok(self) -> bool:
|
|
102
|
+
"""`True` when `status` is `VerifyStatus.OK`."""
|
|
103
|
+
return self.status is VerifyStatus.OK
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------- #
|
|
107
|
+
# Content diffs
|
|
108
|
+
# --------------------------------------------------------------------------- #
|
|
109
|
+
MAX_DIFF_ENTRIES = 2000
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class ChangeEntry:
|
|
114
|
+
"""One changed thing inside an object: a file, table, array, key, ..."""
|
|
115
|
+
|
|
116
|
+
path: str
|
|
117
|
+
"""What changed (file path, table name, array path, ...)."""
|
|
118
|
+
change: str
|
|
119
|
+
"""`"added"`, `"removed"`, `"modified"`, or `"renamed"`."""
|
|
120
|
+
detail: str = ""
|
|
121
|
+
"""Backend-specific detail (`"+2 rows"`, `"+1 -1"`, `"12 chunks"`)."""
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict[str, str]:
|
|
124
|
+
d = {"path": self.path, "change": self.change}
|
|
125
|
+
if self.detail:
|
|
126
|
+
d["detail"] = self.detail
|
|
127
|
+
return d
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ObjectDiff:
|
|
132
|
+
"""What changed inside one object between two recorded states.
|
|
133
|
+
|
|
134
|
+
Backends describe changes at whatever granularity their system exposes
|
|
135
|
+
natively and cheaply (files, tables, arrays, fragments, versions); ``unit``
|
|
136
|
+
names it. Entries are capped at :data:`MAX_DIFF_ENTRIES`; counts are not.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
unit: str = "entries"
|
|
140
|
+
"""What is being counted: files, tables, arrays, fragments, commits, ..."""
|
|
141
|
+
added: int = 0
|
|
142
|
+
"""Exact count of added units."""
|
|
143
|
+
removed: int = 0
|
|
144
|
+
"""Exact count of removed units."""
|
|
145
|
+
modified: int = 0
|
|
146
|
+
"""Exact count of modified (or renamed) units."""
|
|
147
|
+
entries: list[ChangeEntry] = field(default_factory=list)
|
|
148
|
+
"""Per-unit entries, capped at `MAX_DIFF_ENTRIES`."""
|
|
149
|
+
truncated: bool = False
|
|
150
|
+
"""Whether entries were dropped because of the cap."""
|
|
151
|
+
note: str = ""
|
|
152
|
+
"""Context the counts alone do not convey (e.g. a missing listing)."""
|
|
153
|
+
|
|
154
|
+
def add(self, path: str, change: str, detail: str = "") -> None:
|
|
155
|
+
"""Record one change: bump the matching counter and append an entry."""
|
|
156
|
+
if change == "added":
|
|
157
|
+
self.added += 1
|
|
158
|
+
elif change == "removed":
|
|
159
|
+
self.removed += 1
|
|
160
|
+
else:
|
|
161
|
+
self.modified += 1
|
|
162
|
+
if len(self.entries) < MAX_DIFF_ENTRIES:
|
|
163
|
+
self.entries.append(ChangeEntry(path, change, detail))
|
|
164
|
+
else:
|
|
165
|
+
self.truncated = True
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def is_empty(self) -> bool:
|
|
169
|
+
"""`True` when nothing changed."""
|
|
170
|
+
return not (self.added or self.removed or self.modified or self.entries)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def summary(self) -> str:
|
|
174
|
+
"""One line: `"+A -R ~M <unit>"` plus truncation and note."""
|
|
175
|
+
text = f"+{self.added} -{self.removed} ~{self.modified} {self.unit}"
|
|
176
|
+
if self.truncated:
|
|
177
|
+
text += f" (first {len(self.entries)} shown)"
|
|
178
|
+
if self.note:
|
|
179
|
+
text += f"; {self.note}"
|
|
180
|
+
return text
|
|
181
|
+
|
|
182
|
+
def to_dict(self) -> dict[str, Any]:
|
|
183
|
+
return {
|
|
184
|
+
"summary": self.summary,
|
|
185
|
+
"unit": self.unit,
|
|
186
|
+
"added": self.added,
|
|
187
|
+
"removed": self.removed,
|
|
188
|
+
"modified": self.modified,
|
|
189
|
+
"truncated": self.truncated,
|
|
190
|
+
"note": self.note,
|
|
191
|
+
"entries": [e.to_dict() for e in self.entries],
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
Listings = tuple[str | None, str | None]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@runtime_checkable
|
|
199
|
+
class ObjectBackend(Protocol):
|
|
200
|
+
"""The operations tether needs from one class of system.
|
|
201
|
+
|
|
202
|
+
``listing`` and ``diff`` have default implementations (no listing; diff is a
|
|
203
|
+
capability error) so backends without ``DIFF`` need not define them.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
kind: str
|
|
207
|
+
capabilities: Capability
|
|
208
|
+
|
|
209
|
+
def identity(self, locator: Locator) -> Locator:
|
|
210
|
+
"""Locator subset that participates in the content-addressed pin id.
|
|
211
|
+
|
|
212
|
+
Defaults to the full locator; override to exclude non-identity fields
|
|
213
|
+
(region, credential references, source branch, ...).
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def fingerprint(self, locator: Locator, working_ref: str | None) -> State:
|
|
217
|
+
"""Read the current state at ``working_ref`` (or the locator's base)."""
|
|
218
|
+
|
|
219
|
+
def pin(self, locator: Locator, state: State, pin_id: str) -> Pin:
|
|
220
|
+
"""Create a durable native ref for ``state``. Requires ``PIN``."""
|
|
221
|
+
|
|
222
|
+
def unpin(self, locator: Locator, pin: Pin) -> None:
|
|
223
|
+
"""Release a pin. Requires ``PIN``."""
|
|
224
|
+
|
|
225
|
+
def list_pins(self, locator: Locator) -> set[str]:
|
|
226
|
+
"""Return pin ids that currently exist natively. Requires ``PIN``."""
|
|
227
|
+
|
|
228
|
+
def verify(
|
|
229
|
+
self,
|
|
230
|
+
locator: Locator,
|
|
231
|
+
state: State,
|
|
232
|
+
pin: Pin | None,
|
|
233
|
+
deep: bool,
|
|
234
|
+
) -> VerifyReport:
|
|
235
|
+
"""Check that ``state``/``pin`` still hold."""
|
|
236
|
+
|
|
237
|
+
def fork(self, locator: Locator, pin: Pin, name: str) -> str:
|
|
238
|
+
"""Create a writable branch ``name`` off ``pin``. Requires ``FORK``."""
|
|
239
|
+
|
|
240
|
+
def delete_working_ref(self, locator: Locator, ref: str) -> None:
|
|
241
|
+
"""Delete a working ref created by :meth:`fork`. Requires ``FORK``."""
|
|
242
|
+
|
|
243
|
+
def open(
|
|
244
|
+
self,
|
|
245
|
+
locator: Locator,
|
|
246
|
+
target: str | Pin | State | None,
|
|
247
|
+
read_only: bool,
|
|
248
|
+
) -> Handle:
|
|
249
|
+
"""Return a native handle for a target.
|
|
250
|
+
|
|
251
|
+
``target`` is one of: ``None`` (the base/working ref), a ``str`` working
|
|
252
|
+
ref, a :class:`~tether.manifest.Pin` (read a pinned state), or a
|
|
253
|
+
recorded ``State`` mapping (read an addressable state without a pin).
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def listing(self, locator: Locator, state: State) -> str | None:
|
|
257
|
+
"""Optional detailed description of ``state`` to store alongside it.
|
|
258
|
+
|
|
259
|
+
The engine writes the text content-addressed under ``.tether/listings/``
|
|
260
|
+
at commit time and hands both sides back to :meth:`diff` later. Used by
|
|
261
|
+
backends whose state is a digest (the ``file`` backend's directory and
|
|
262
|
+
prefix listings) so Observed states can still be diffed.
|
|
263
|
+
"""
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
def diff(
|
|
267
|
+
self,
|
|
268
|
+
locator: Locator,
|
|
269
|
+
a: State,
|
|
270
|
+
b: State,
|
|
271
|
+
*,
|
|
272
|
+
listings: Listings = (None, None),
|
|
273
|
+
) -> ObjectDiff:
|
|
274
|
+
"""Describe what changed from state ``a`` to state ``b``. Requires ``DIFF``."""
|
|
275
|
+
raise CapabilityError(f"{self.kind} backend cannot diff", kind=self.kind)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# --------------------------------------------------------------------------- #
|
|
279
|
+
# Registry
|
|
280
|
+
# --------------------------------------------------------------------------- #
|
|
281
|
+
BackendFactory = Callable[[dict], ObjectBackend]
|
|
282
|
+
_REGISTRY: dict[str, BackendFactory] = {}
|
|
283
|
+
|
|
284
|
+
# Built-in kinds and the module that registers them. Imported lazily so optional
|
|
285
|
+
# third-party dependencies (icechunk, pyiceberg, ...) are only loaded on demand.
|
|
286
|
+
_BUILTIN_MODULES: dict[str, str] = {
|
|
287
|
+
"memory": "tether.backends.memory",
|
|
288
|
+
"file": "tether.backends.file",
|
|
289
|
+
"icechunk": "tether.backends.icechunk",
|
|
290
|
+
"neon": "tether.backends.neon",
|
|
291
|
+
"git": "tether.backends.git",
|
|
292
|
+
"iceberg": "tether.backends.iceberg",
|
|
293
|
+
"delta": "tether.backends.delta",
|
|
294
|
+
"lance": "tether.backends.lance",
|
|
295
|
+
"lakefs": "tether.backends.lakefs",
|
|
296
|
+
"ducklake": "tether.backends.ducklake",
|
|
297
|
+
"dolt": "tether.backends.dolt",
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def register_backend(kind: str, factory: BackendFactory) -> None:
|
|
302
|
+
_REGISTRY[kind] = factory
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def build_backend(kind: str, config: dict | None = None) -> ObjectBackend:
|
|
306
|
+
from importlib import import_module
|
|
307
|
+
|
|
308
|
+
from tether.errors import ConfigError
|
|
309
|
+
|
|
310
|
+
if kind not in _REGISTRY and kind in _BUILTIN_MODULES:
|
|
311
|
+
try:
|
|
312
|
+
import_module(_BUILTIN_MODULES[kind])
|
|
313
|
+
except ImportError as exc:
|
|
314
|
+
raise ConfigError(
|
|
315
|
+
f"backend {kind!r} needs an optional dependency: {exc}. "
|
|
316
|
+
f"Install the matching extra (e.g. `pip install tether-vcs[{kind}]`)."
|
|
317
|
+
) from exc
|
|
318
|
+
try:
|
|
319
|
+
factory = _REGISTRY[kind]
|
|
320
|
+
except KeyError as exc:
|
|
321
|
+
raise ConfigError(
|
|
322
|
+
f"unknown backend kind: {kind!r} "
|
|
323
|
+
f"(known: {', '.join(known_kinds()) or 'none'})"
|
|
324
|
+
) from exc
|
|
325
|
+
return factory(config or {})
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def known_kinds() -> list[str]:
|
|
329
|
+
return sorted(set(_REGISTRY) | set(_BUILTIN_MODULES))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def effective_capabilities(
|
|
333
|
+
backend: ObjectBackend,
|
|
334
|
+
locator: Locator,
|
|
335
|
+
policy: object,
|
|
336
|
+
) -> Capability:
|
|
337
|
+
"""Capabilities for a specific object.
|
|
338
|
+
|
|
339
|
+
Some backends (notably ``file``) span tiers depending on the locator and
|
|
340
|
+
policy: a local file is Observed while an S3 object in a versioned bucket is
|
|
341
|
+
Addressable. Backends may implement ``effective_capabilities(locator, policy)``
|
|
342
|
+
to refine their class-level :attr:`capabilities`; otherwise the class value
|
|
343
|
+
is used.
|
|
344
|
+
"""
|
|
345
|
+
fn = getattr(backend, "effective_capabilities", None)
|
|
346
|
+
if callable(fn):
|
|
347
|
+
return fn(locator, policy)
|
|
348
|
+
return backend.capabilities
|