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/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
|
+
[](https://pypi.org/project/fdl-cli/)
|
|
22
|
+
[](https://pypi.org/project/fdl-cli/)
|
|
23
|
+
[](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,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.
|