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 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
+ ]
@@ -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