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/stores.py ADDED
@@ -0,0 +1,177 @@
1
+ """Two stores: a folder on this machine, and an S3-compatible bucket.
2
+
3
+ Both raise `StoreError` with the HTTP status a failure would have had (404 for
4
+ missing, 412 for a failed condition), which is all `fdl.store` looks at.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from pathlib import Path
11
+ from typing import Sequence
12
+ from urllib.parse import urlparse
13
+
14
+
15
+ class StoreError(RuntimeError):
16
+ def __init__(self, message: str, *, status: int | None = None) -> None:
17
+ super().__init__(message)
18
+ self.status = status
19
+
20
+
21
+ class LocalStore:
22
+ """A repository in a folder. ETags are content hashes, which is enough for
23
+ the conditional writes refs rely on. One writer at a time: the condition
24
+ is checked and the file written in two steps."""
25
+
26
+ def __init__(self, root: Path | str) -> None:
27
+ self.root = Path(root)
28
+
29
+ @property
30
+ def url(self) -> str:
31
+ return str(self.root.resolve())
32
+
33
+ def _path(self, key: str) -> Path:
34
+ path = (self.root / key).resolve()
35
+ if not path.is_relative_to(self.root.resolve()):
36
+ raise StoreError(f"key escapes the store root: {key!r}")
37
+ return path
38
+
39
+ @staticmethod
40
+ def _etag(data: bytes) -> str:
41
+ return '"' + hashlib.md5(data, usedforsecurity=False).hexdigest() + '"'
42
+
43
+ def put(
44
+ self,
45
+ key: str,
46
+ data: bytes,
47
+ *,
48
+ content_type: str | None = None,
49
+ if_match: str | None = None,
50
+ if_none_match: str | None = None,
51
+ ) -> str:
52
+ path = self._path(key)
53
+ current = path.read_bytes() if path.is_file() else None
54
+ if if_none_match == "*" and current is not None:
55
+ raise StoreError(f"{key} already exists", status=412)
56
+ if if_match is not None and (current is None or self._etag(current) != if_match):
57
+ raise StoreError(f"{key} does not match {if_match}", status=412)
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ path.write_bytes(data)
60
+ return self._etag(data)
61
+
62
+ def put_file(self, key: str, path: Path, **kwargs) -> str:
63
+ return self.put(key, Path(path).read_bytes(), **kwargs)
64
+
65
+ def get(self, key: str) -> bytes:
66
+ path = self._path(key)
67
+ if not path.is_file():
68
+ raise StoreError(f"no such object: {key}", status=404)
69
+ return path.read_bytes()
70
+
71
+ def head(self, key: str) -> dict[str, str] | None:
72
+ path = self._path(key)
73
+ return {"ETag": self._etag(path.read_bytes())} if path.is_file() else None
74
+
75
+ def list(self, prefix: str = "") -> list[str]:
76
+ if not self.root.is_dir():
77
+ return []
78
+ keys = (str(p.relative_to(self.root)) for p in sorted(self.root.rglob("*")) if p.is_file())
79
+ return [key for key in keys if key.startswith(prefix)]
80
+
81
+ def delete(self, keys: Sequence[str]) -> None:
82
+ for key in keys:
83
+ self._path(key).unlink(missing_ok=True)
84
+
85
+
86
+ class S3Store:
87
+ """A repository under ``s3://<bucket>/<prefix>``.
88
+
89
+ Credentials and the endpoint come from boto3's usual places (the
90
+ ``AWS_*`` environment variables, ``AWS_ENDPOINT_URL`` for R2 and other
91
+ S3-compatible stores, or a profile).
92
+ """
93
+
94
+ def __init__(self, bucket: str, prefix: str = "", *, client=None) -> None:
95
+ if client is None:
96
+ try:
97
+ import boto3
98
+ except ImportError as exc:
99
+ raise StoreError("S3 needs boto3: pip install 'fdl-cli[s3]'") from exc
100
+
101
+ client = boto3.client("s3")
102
+ self.client = client
103
+ self.bucket = bucket
104
+ self.prefix = prefix.strip("/") + "/" if prefix.strip("/") else ""
105
+
106
+ @property
107
+ def url(self) -> str:
108
+ return f"s3://{self.bucket}/{self.prefix}"
109
+
110
+ def _call(self, method: str, key: str, **kwargs):
111
+ from botocore.exceptions import ClientError
112
+
113
+ try:
114
+ return getattr(self.client, method)(Bucket=self.bucket, **kwargs)
115
+ except ClientError as exc:
116
+ status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
117
+ raise StoreError(f"{method} {key}: {exc}", status=status) from exc
118
+
119
+ def put(
120
+ self,
121
+ key: str,
122
+ data: bytes,
123
+ *,
124
+ content_type: str | None = None,
125
+ if_match: str | None = None,
126
+ if_none_match: str | None = None,
127
+ ) -> str:
128
+ options = {
129
+ name: value
130
+ for name, value in (
131
+ ("ContentType", content_type),
132
+ ("IfMatch", if_match),
133
+ ("IfNoneMatch", if_none_match),
134
+ )
135
+ if value is not None
136
+ }
137
+ answer = self._call("put_object", key, Key=self.prefix + key, Body=data, **options)
138
+ return answer["ETag"]
139
+
140
+ def put_file(self, key: str, path: Path, **kwargs) -> str:
141
+ # Catalogs are a few MB, so one request is enough.
142
+ return self.put(key, Path(path).read_bytes(), **kwargs)
143
+
144
+ def get(self, key: str) -> bytes:
145
+ return self._call("get_object", key, Key=self.prefix + key)["Body"].read()
146
+
147
+ def head(self, key: str) -> dict[str, str] | None:
148
+ try:
149
+ answer = self._call("head_object", key, Key=self.prefix + key)
150
+ except StoreError as exc:
151
+ if exc.status == 404:
152
+ return None
153
+ raise
154
+ return {"ETag": answer["ETag"]}
155
+
156
+ def list(self, prefix: str = "") -> list[str]:
157
+ keys: list[str] = []
158
+ pages = self.client.get_paginator("list_objects_v2").paginate(
159
+ Bucket=self.bucket, Prefix=self.prefix + prefix
160
+ )
161
+ for page in pages:
162
+ keys.extend(item["Key"][len(self.prefix) :] for item in page.get("Contents", []))
163
+ return keys
164
+
165
+ def delete(self, keys: Sequence[str]) -> None:
166
+ for key in keys:
167
+ self._call("delete_object", key, Key=self.prefix + key)
168
+
169
+
170
+ def open_store(url: str) -> LocalStore | S3Store:
171
+ """``s3://bucket/prefix/`` or a path on this machine."""
172
+ parsed = urlparse(url)
173
+ if parsed.scheme == "s3":
174
+ return S3Store(parsed.netloc, parsed.path)
175
+ if parsed.scheme in ("", "file"):
176
+ return LocalStore(parsed.path if parsed.scheme == "file" else url)
177
+ raise StoreError(f"not a store this knows: {url!r} (s3://bucket/prefix/ or a folder)")
fdl/version.py ADDED
@@ -0,0 +1,140 @@
1
+ """The record of a version (`version.json`) and how its ID is made."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import json
7
+ import os
8
+ import re
9
+ import time
10
+ from dataclasses import dataclass, field
11
+
12
+ from . import layout
13
+
14
+ _ID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
15
+
16
+
17
+ class VersionError(ValueError):
18
+ """A version record this format cannot read."""
19
+
20
+
21
+ def new_id(now_ms: int | None = None) -> str:
22
+ """A UUIDv7: ordered by the time it was made, and safe to make without
23
+ asking anyone, so two writers never collide.
24
+
25
+ By hand because `uuid.uuid7` arrived in Python 3.14.
26
+ """
27
+ ms = int(time.time() * 1000) if now_ms is None else now_ms
28
+ value = (ms & ((1 << 48) - 1)) << 80
29
+ value |= 0x7 << 76
30
+ value |= (int.from_bytes(os.urandom(2), "big") & 0x0FFF) << 64
31
+ value |= 0b10 << 62
32
+ value |= int.from_bytes(os.urandom(8), "big") & ((1 << 62) - 1)
33
+ text = f"{value:032x}"
34
+ return f"{text[:8]}-{text[8:12]}-{text[12:16]}-{text[16:20]}-{text[20:]}"
35
+
36
+
37
+ def is_id(value: str) -> bool:
38
+ return bool(_ID.fullmatch(value))
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Catalog:
43
+ path: str
44
+ storage: str
45
+ bytes: int
46
+ sha256: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class DuckLake:
51
+ format: str
52
+ snapshot_id: int
53
+ #: The first and last snapshot this version holds past its parent's.
54
+ snapshots: tuple[int, int]
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Version:
59
+ id: str
60
+ seq: int
61
+ parents: tuple[str, ...]
62
+ created_at: str
63
+ author: dict
64
+ message: str
65
+ catalog: Catalog
66
+ ducklake: DuckLake
67
+ changes: tuple[dict, ...] = ()
68
+ inputs: tuple[dict, ...] = ()
69
+ extensions: dict = field(default_factory=dict)
70
+ fdl: str = layout.FDL_VERSION
71
+
72
+ def to_json(self) -> bytes:
73
+ record = {
74
+ "fdl": self.fdl,
75
+ "id": self.id,
76
+ "seq": self.seq,
77
+ "parents": list(self.parents),
78
+ "created_at": self.created_at,
79
+ "author": self.author,
80
+ "message": self.message,
81
+ "catalog": {
82
+ "path": self.catalog.path,
83
+ "storage": self.catalog.storage,
84
+ "bytes": self.catalog.bytes,
85
+ "sha256": self.catalog.sha256,
86
+ },
87
+ "ducklake": {
88
+ "format": self.ducklake.format,
89
+ "snapshot_id": self.ducklake.snapshot_id,
90
+ "snapshots": list(self.ducklake.snapshots),
91
+ },
92
+ "changes": list(self.changes),
93
+ "inputs": list(self.inputs),
94
+ "extensions": self.extensions,
95
+ }
96
+ return (json.dumps(record, ensure_ascii=False, indent=2) + "\n").encode()
97
+
98
+
99
+ def parse(data: bytes, *, expected_id: str | None = None) -> Version:
100
+ """Read a `version.json`, refusing what a reader of this format must not open."""
101
+ try:
102
+ record = json.loads(data)
103
+ major = str(record["fdl"]).split(".")[0]
104
+ if major != layout.FDL_VERSION.split(".")[0]:
105
+ raise VersionError(f"written for fdl {record['fdl']}, this reads {layout.FDL_VERSION}")
106
+ if expected_id is not None and record["id"] != expected_id:
107
+ raise VersionError(f"records id {record['id']} but sits under {expected_id}")
108
+ catalog = record["catalog"]
109
+ ducklake = record["ducklake"]
110
+ return Version(
111
+ fdl=record["fdl"],
112
+ id=record["id"],
113
+ seq=int(record["seq"]),
114
+ parents=tuple(record["parents"]),
115
+ created_at=record["created_at"],
116
+ author=dict(record["author"]),
117
+ message=record["message"],
118
+ catalog=Catalog(
119
+ path=catalog["path"],
120
+ storage=catalog["storage"],
121
+ bytes=int(catalog["bytes"]),
122
+ sha256=catalog["sha256"],
123
+ ),
124
+ ducklake=DuckLake(
125
+ format=ducklake["format"],
126
+ snapshot_id=int(ducklake["snapshot_id"]),
127
+ snapshots=tuple(ducklake["snapshots"]),
128
+ ),
129
+ changes=tuple(record.get("changes", ())),
130
+ inputs=tuple(record.get("inputs", ())),
131
+ extensions=dict(record.get("extensions", {})),
132
+ )
133
+ except VersionError:
134
+ raise
135
+ except (KeyError, TypeError, ValueError) as exc:
136
+ raise VersionError(f"not a version record: {exc}") from exc
137
+
138
+
139
+ def utc_now() -> str:
140
+ return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.5
2
+ Name: fdl-cli
3
+ Version: 0.12.1
4
+ Summary: Versions of a DuckLake catalog on object storage, kept the way Git keeps commits
5
+ Project-URL: Homepage, https://fdl.flo8s.com/
6
+ Project-URL: Documentation, https://fdl.flo8s.com/
7
+ Project-URL: Repository, https://github.com/queria-io/fdl
8
+ Project-URL: Changelog, https://github.com/queria-io/fdl/blob/main/CHANGELOG.md
9
+ Author: flo8s
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: duckdb<2.0,>=1.5.2
14
+ Requires-Dist: typer>=0.15
15
+ Provides-Extra: s3
16
+ Requires-Dist: boto3>=1.36; extra == 's3'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # fdl
20
+
21
+ [![PyPI](https://img.shields.io/pypi/v/fdl-cli)](https://pypi.org/project/fdl-cli/)
22
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/fdl-cli/)
23
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
24
+
25
+ Versions of a [DuckLake](https://ducklake.select/) catalog on object storage, kept the way Git keeps commits.
26
+
27
+ FDL is a storage layout and a tool for it. Each version is a frozen catalog with a record of its parent, author and message; refs such as `refs/heads/main` say which version readers get. Data files are shared between versions and never rewritten. All it needs is a store that can read, write and write on condition: a folder, S3, R2 or anything S3-compatible. There is no server.
28
+
29
+ ```
30
+ <root>/
31
+ fdl.json
32
+ HEAD ref: refs/heads/main
33
+ refs/heads/main 019a3c4e-…
34
+ versions/019a3c4e-…/
35
+ version.json parents, author, message, sha256
36
+ ducklake.duckdb the frozen catalog
37
+ data/main/towns/ducklake-….parquet
38
+ ```
39
+
40
+ The format is in [docs/spec.md](docs/spec.md).
41
+
42
+ ## Quick start
43
+
44
+ ```bash
45
+ pip install 'fdl-cli[s3]'
46
+
47
+ fdl init s3://my-bucket/towns/
48
+ fdl checkout s3://my-bucket/towns/ work/lake.sqlite
49
+ ```
50
+
51
+ The package is `fdl-cli` on PyPI. It installs the `fdl` command and the `fdl` Python package (`import fdl`).
52
+
53
+ Write to the working catalog with DuckDB, attached the way `checkout` prints:
54
+
55
+ ```sql
56
+ ATTACH 'ducklake:work/lake.sqlite' AS lake (
57
+ DATA_PATH 's3://my-bucket/towns/data/', OVERRIDE_DATA_PATH true,
58
+ META_TYPE 'sqlite', DATA_INLINING_ROW_LIMIT 0, META_JOURNAL_MODE 'WAL', BUSY_TIMEOUT 5000
59
+ );
60
+ CREATE TABLE lake.main.towns AS SELECT * FROM 'towns.csv';
61
+ ```
62
+
63
+ Rows have to go to Parquet, not into the catalog (`DATA_INLINING_ROW_LIMIT 0`): a version is read over HTTP, and inlined rows would not be in it.
64
+
65
+ Then make a version and move `main` to it:
66
+
67
+ ```bash
68
+ fdl commit work/lake.sqlite -m "First towns" --author alice
69
+ fdl log s3://my-bucket/towns/
70
+ ```
71
+
72
+ Anyone who can read the bucket can attach it:
73
+
74
+ ```bash
75
+ fdl url s3://my-bucket/towns/
76
+ ```
77
+
78
+ ```sql
79
+ ATTACH 'ducklake:s3://my-bucket/towns/versions/019a3c4e-…/ducklake.duckdb' AS towns (READ_ONLY);
80
+ ```
81
+
82
+ Credentials and the endpoint come from boto3's usual places (`AWS_*` environment variables, `AWS_ENDPOINT_URL` for R2 and other S3-compatible stores). A folder works as a repository too: pass its path instead of an `s3://` URL.
83
+
84
+ ## Commands
85
+
86
+ | Command | What it does |
87
+ | --- | --- |
88
+ | `fdl init <remote>` | Start a repository (`fdl.json` and `HEAD`) |
89
+ | `fdl checkout <remote> <working>` | Make a working catalog from the version `main` holds |
90
+ | `fdl commit <working> -m … --author …` | Freeze the working catalog into a version and push it |
91
+ | `fdl log <remote>` | List versions from `main` back to the first |
92
+ | `fdl url <remote>` | Print the catalog URL of the version `main` holds |
93
+ | `fdl expire <remote> --keep N` | Mark older versions as past retention and delete their catalogs |
94
+
95
+ A push that is not built on what `main` holds now is refused: check out again and redo the work.
96
+
97
+ ## Python
98
+
99
+ ```python
100
+ from fdl import repo
101
+ from fdl.stores import open_store
102
+
103
+ store = open_store("s3://my-bucket/towns/")
104
+ for version in repo.log(store):
105
+ print(version.seq, version.id, version.message)
106
+ ```
107
+
108
+ `fdl.repo` has the operations without a terminal (`init`, `checkout`, `commit`, `push`, `log`, `expire`, `prune`), for tools and services that build on FDL.
109
+
110
+ ## Earlier releases
111
+
112
+ Releases up to 0.11 were a different tool for managing a single frozen catalog, published as `frozen-ducklake`. Their code is at the [`legacy`](https://github.com/queria-io/fdl/tree/legacy) branch.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,15 @@
1
+ fdl/__init__.py,sha256=NwwwbIa96aDPoy1fgl_hcY1zurqKD0O6wjoRBsZHN2s,872
2
+ fdl/catalog.py,sha256=VORkt_HpyKVt2wQwkdt3XF-vuPdhEyduEMd0ryL8uCw,17782
3
+ fdl/cli.py,sha256=Ac0Q4tNA318G_b7qQmOaY4OYLSTHRFk45cLKd_Mo4Os,5456
4
+ fdl/layout.py,sha256=rFmZwp7EgNTMXI2AErYNlkmOSbPMw5VFnhO48V52NBA,1806
5
+ fdl/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ fdl/refs.py,sha256=MslKbcWO9dRrRSjNYvp0ZAhh7vfjVacEuN0BIEPaGho,4219
7
+ fdl/repo.py,sha256=NC8DNtUkmmsVk_PzwYmNAsJ8yfCyvLM2WEj8O3RTiHA,15675
8
+ fdl/store.py,sha256=ADqdZU4Nr9jk7yVyBXoPis2s72OAedXekX0wT5Imr5s,1929
9
+ fdl/stores.py,sha256=UXV4OX8mv78A_pTJtnlbeVM365t7Aya5zHsPhZ2oQ-8,6258
10
+ fdl/version.py,sha256=K-CKkSs6zeMYDBHEcQeQZJPrl59toZnpnHjjPiOqIzQ,4470
11
+ fdl_cli-0.12.1.dist-info/METADATA,sha256=kuw0xnYmLPtfaP0eSW0y-L13OX1q26AIPfOZY3xtmhM,4356
12
+ fdl_cli-0.12.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
13
+ fdl_cli-0.12.1.dist-info/entry_points.txt,sha256=GOQe2LF3vdQ0W8wCI9i8Ldyikn26dfUDiJa5JUaZ7j8,37
14
+ fdl_cli-0.12.1.dist-info/licenses/LICENSE,sha256=t0Of_GryPUMuBvGrtopnujMcxjgVBWMbUIyL7cCZMvk,1062
15
+ fdl_cli-0.12.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fdl = fdl.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 flo8s
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.