aloelite 0.1.0__tar.gz
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.
- aloelite-0.1.0/PKG-INFO +13 -0
- aloelite-0.1.0/README.md +235 -0
- aloelite-0.1.0/aloelite/__init__.py +99 -0
- aloelite-0.1.0/aloelite/aloelite.py +237 -0
- aloelite-0.1.0/aloelite/crypto.py +244 -0
- aloelite-0.1.0/aloelite/db.py +324 -0
- aloelite-0.1.0/aloelite/descriptor.py +285 -0
- aloelite-0.1.0/aloelite/errors.py +167 -0
- aloelite-0.1.0/aloelite/fuse.py +565 -0
- aloelite-0.1.0/aloelite/models.py +206 -0
- aloelite-0.1.0/aloelite/operations.py +880 -0
- aloelite-0.1.0/aloelite/resolve.py +125 -0
- aloelite-0.1.0/aloelite/types.py +113 -0
- aloelite-0.1.0/aloelite.egg-info/PKG-INFO +13 -0
- aloelite-0.1.0/aloelite.egg-info/SOURCES.txt +31 -0
- aloelite-0.1.0/aloelite.egg-info/dependency_links.txt +1 -0
- aloelite-0.1.0/aloelite.egg-info/entry_points.txt +2 -0
- aloelite-0.1.0/aloelite.egg-info/requires.txt +10 -0
- aloelite-0.1.0/aloelite.egg-info/top_level.txt +2 -0
- aloelite-0.1.0/manager/__init__.py +13 -0
- aloelite-0.1.0/manager/__main__.py +74 -0
- aloelite-0.1.0/manager/api.py +293 -0
- aloelite-0.1.0/manager/errors.py +64 -0
- aloelite-0.1.0/manager/preflight.py +282 -0
- aloelite-0.1.0/manager/store.py +201 -0
- aloelite-0.1.0/manager/supervisor.py +274 -0
- aloelite-0.1.0/manager/test_supervisor.py +219 -0
- aloelite-0.1.0/pyproject.toml +32 -0
- aloelite-0.1.0/setup.cfg +4 -0
- aloelite-0.1.0/tests/test_encryption.py +267 -0
- aloelite-0.1.0/tests/test_integration.py +124 -0
- aloelite-0.1.0/tests/test_operations.py +735 -0
- aloelite-0.1.0/tests/test_store.py +206 -0
aloelite-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aloelite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Requires-Python: >=3.11
|
|
5
|
+
Requires-Dist: cryptography
|
|
6
|
+
Requires-Dist: pydantic
|
|
7
|
+
Requires-Dist: pyyaml
|
|
8
|
+
Requires-Dist: msgpack
|
|
9
|
+
Requires-Dist: pyfuse3
|
|
10
|
+
Requires-Dist: trio
|
|
11
|
+
Requires-Dist: flask
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest; extra == "test"
|
aloelite-0.1.0/README.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# aloelite
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
<img src="https://raw.githubusercontent.com/Aloecraft-org/xtrshow/main/doc/icon.png" style="height:96px; width:96px;"/>
|
|
6
|
+
|
|
7
|
+
**AloeLite SQLite Filesystem**
|
|
8
|
+
|
|
9
|
+
[](https://pypi.org/project/aloelite/)
|
|
10
|
+
[](https://pypi.org/project/aloelite/)
|
|
11
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
12
|
+
|
|
13
|
+
[](https://github.com/Aloecraft-org/aloelite/actions/workflows/main.yml)
|
|
14
|
+
[](https://pepy.tech/project/aloelite)
|
|
15
|
+
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
## Overview
|
|
19
|
+
|
|
20
|
+
AloeLite is a filesystem implemented as a SQLite database. The entire filesystem — files, directories, metadata, and content — lives in a single portable `.sqlite` file that can be copied, versioned, and opened anywhere SQLite runs.
|
|
21
|
+
|
|
22
|
+
It is designed for situations where you want filesystem semantics (paths, directories, streaming I/O) but need more control than a raw filesystem gives you: portable snapshots, at-rest encryption, content deduplication, and a clean programmatic API. A single file is easier to back up, replicate, and audit than a directory tree.
|
|
23
|
+
|
|
24
|
+
**What it provides:**
|
|
25
|
+
|
|
26
|
+
- A Python API for creating and navigating volumes, with full streaming read/write support validated against multi-gigabyte files
|
|
27
|
+
- At-rest encryption per volume (ChaCha20-Poly1305, Argon2id key derivation), with the PIN accepted only at mount time and never stored
|
|
28
|
+
- Content deduplication via a chunk pool — identical data stored once across all files in a volume
|
|
29
|
+
- FUSE integration so any application can use an AloeLite volume as a plain directory, without modification
|
|
30
|
+
- A container-ready volume manager that exposes volumes over HTTP and propagates FUSE mounts to other containers via bind mount — suitable as a lightweight Docker/Podman volume provisioner
|
|
31
|
+
- Export and checkpoint endpoints that produce clean, self-contained SQLite snapshots while the volume remains mounted, enabling simple backup workflows without coordination
|
|
32
|
+
|
|
33
|
+
**What it is not:** a general-purpose network filesystem, a database replacement, or a POSIX-complete block device. Random-access rewrites on large files fall back to a buffered path. Node metadata (paths, timestamps, directory structure) is stored in plaintext even on encrypted volumes — see [Security Notes](#security-notes).
|
|
34
|
+
|
|
35
|
+
## Abstract
|
|
36
|
+
|
|
37
|
+
This document specifies the design of a portable filesystem implemented on top of SQLite. The system models a filesystem as a small set of relational primitives (i.e. nodes, edges, volumes, and mounts) rather than as a fixed on-disk layout, deferring byte packing, page management, and durability to SQLite's mature storage engine. It is deliberately interface-agnostic: it presents a coherent internal model of files, directories, placement, and access without committing to any single external protocol, while remaining structurally amenable to exposing one (WebDAV, FUSE, or others) in the future. The design favors a hierarchical tree as its default arrangement but encodes that hierarchy as a relaxable constraint rather than a structural assumption, leaving a clear path toward a more general graph-shaped namespace. Supporting concerns (e.g. content storage, archival, and verifiable modification) are accommodated as first-class parts of the model even where their full implementation is staged for later.
|
|
38
|
+
|
|
39
|
+
## Discussion
|
|
40
|
+
|
|
41
|
+
The motivation for building on SQLite is portability and reach. A filesystem expressed as a SQLite database is a single, self-describing file that can be opened, moved, and inspected anywhere SQLite runs, which is nearly everywhere, and it inherits decades of work on storage layout and transactional integrity for free. The cost of that choice is that the filesystem's structure must be expressed relationally; the contribution of this design is a set of primitives that do so cleanly while keeping future capabilities reachable rather than precluded.
|
|
42
|
+
|
|
43
|
+
The model separates four concerns that filesystems often conflate. A *node* is an identity: a file (Entry) or a directory (Container), bearing a stable time-ordered identifier and its own name. An *edge* is a placement: a directed, immutable relationship that situates a node beneath a container within a particular volume. A *volume* is an origin: the root to which a coherent tree of placements ultimately refers. A *mount* is an access context: a live, volume-bound session, anchored at a node, through which operation on the filesystem is brokered. Holding these four apart is what gives the design its flexibility. Because a node's name and existence are independent of where it sits, the same node can in principle be reachable from more than one place, which is the seam through which links, mounts, and an eventual graph layout enter without disturbing the core. Because placement lives in immutable edges, every structural change is expressed as the creation of a new edge rather than the mutation of an existing one, which keeps the history of where things have been available and gives later features (e.g. ordering, verification, recovery) a stable substrate to build on. Because origins are modeled explicitly rather than inferred, the boundary of a volume is a real, referenceable thing rather than a convention. And because access is brokered through mounts rather than ambient, the system has a concrete answer to a question filesystems usually answer with the operating system: who holds a handle, who holds a lock, and what to reclaim when a session ends.
|
|
44
|
+
|
|
45
|
+
File contents are held apart from node metadata, so that traversing and resolving the namespace touches only small, frequently-accessed rows and never drags large payloads along. Reading and writing a whole file is an atomic operation in the ordinary case, with a streaming, descriptor-like access path for large or incremental I/O. That access path is mediated by mounts: because the filesystem has no native notion of a process, a mount stands in as the session identity that holds open handles and locks, and locks are scoped to the mount that acquired them, so that ending a session has a well-defined effect on everything it held. This advisory locking coexists with rather than commandeers SQLite's own transactional concurrency. Archival packs a subtree into a portable serialized form within the safety of a single transaction, so that the act of consolidating data cannot lose it. And the design reserves room for cryptographic verification of modification (e.g. a Merkle structure over the tree) by ensuring that mutations flow through a single, well-defined path where such bookkeeping can later be attached. None of these later-stage capabilities is fully realized in the first iteration; the purpose of the model described here is to make each of them an addition rather than a redesign.
|
|
46
|
+
|
|
47
|
+
**Implementation Status**
|
|
48
|
+
|
|
49
|
+
The core model — nodes, edges, volumes, and mounts — is fully realized, including path resolution, structural operations (create, move, rename, copy, remove, pack/unpack), advisory locking, and mount-scoped session management. File content is stored in a content-addressed chunk pool with deduplication, per-version manifests, configurable retention, and bounded-memory streaming I/O for both reads and writes; the streaming descriptor is production-validated against files in the tens of gigabytes. At-rest encryption is implemented at the storage boundary (ChaCha20-Poly1305, Argon2id key derivation, per-volume wrapped key), with convergent-nonce and random-nonce modes and a FUSE front-end that accepts a PIN at mount time. A container manager (`manager/`) exposes volumes as FUSE-mounted directories over a nine-endpoint HTTP API, suitable for use as a Docker/Podman volume provisioner. Reserved but not yet realized: cryptographic verification of the node tree (Merkle structure over content and placement), content-defined chunking, key rotation, graph-shaped namespaces beyond the default hierarchical tree, and node metadata encryption (currently plaintext in the SQLite schema — see [Security Notes](#security-notes)).
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Getting Started
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
pip install aloelite
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For FUSE support (Linux only):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
sudo apt install fuse3 libfuse3-dev
|
|
63
|
+
pip install aloelite[fuse]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Python API
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from aloelite.aloelite import AloeLite
|
|
70
|
+
from aloelite.types import WriteMode, Whence
|
|
71
|
+
|
|
72
|
+
with AloeLite("photos.sqlite") as fs:
|
|
73
|
+
vol = fs.create_volume("photos")
|
|
74
|
+
|
|
75
|
+
with fs.mount(vol.id) as m:
|
|
76
|
+
m.create_container("/2024")
|
|
77
|
+
m.set_metadata("/2024", {"year": "2024", "album": "trip"})
|
|
78
|
+
m.create_entry("/2024/caption.txt", b"a sunset")
|
|
79
|
+
|
|
80
|
+
with m.open_write("/note.txt") as w:
|
|
81
|
+
w.write(b"hello ")
|
|
82
|
+
w.write(b"world")
|
|
83
|
+
|
|
84
|
+
print(m.read_all("/note.txt")) # -> b"hello world"
|
|
85
|
+
|
|
86
|
+
with m.open_read("/note.txt") as r:
|
|
87
|
+
head = r.read(5)
|
|
88
|
+
r.seek(-5, Whence.END)
|
|
89
|
+
tail = r.read()
|
|
90
|
+
|
|
91
|
+
m.rename("/note.txt", "readme.txt")
|
|
92
|
+
m.move("/readme.txt", "/2024/readme.txt")
|
|
93
|
+
m.copy("/2024", "/backup")
|
|
94
|
+
m.remove_recursive("/backup")
|
|
95
|
+
|
|
96
|
+
fs.prune()
|
|
97
|
+
print(fs.health_check()) # -> [] when consistent
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Encryption
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
PIN = b"correct-horse-battery-staple"
|
|
104
|
+
|
|
105
|
+
with AloeLite("vault.sqlite") as fs:
|
|
106
|
+
vol = fs.create_volume("vault", pin=PIN) # Argon2id key derivation, ChaCha20-Poly1305
|
|
107
|
+
|
|
108
|
+
with fs.mount(vol.id, pin=PIN) as m:
|
|
109
|
+
m.create_entry("/secret.txt", b"eyes only")
|
|
110
|
+
print(m.read_all("/secret.txt")) # -> b"eyes only"
|
|
111
|
+
|
|
112
|
+
# Wrong PIN is rejected at mount time (not at read time)
|
|
113
|
+
from aloelite import errors
|
|
114
|
+
try:
|
|
115
|
+
fs.mount(vol.id, pin=b"wrong")
|
|
116
|
+
except errors.BadKey:
|
|
117
|
+
print("wrong PIN rejected ✓")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Encryption is invisible at the `Mount` API level. Use `enc_mode="random"` to trade chunk deduplication for zero equality leakage.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## FUSE
|
|
125
|
+
|
|
126
|
+
Mount an AloeLite volume as a regular directory (Linux, requires `fuse3`):
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Plain volume
|
|
130
|
+
aloelite-fuse photos.sqlite photos /mnt/photos
|
|
131
|
+
|
|
132
|
+
# Encrypted volume — three ways to supply the PIN
|
|
133
|
+
aloelite-fuse vault.sqlite vault /mnt/vault --pin "my secret"
|
|
134
|
+
aloelite-fuse vault.sqlite vault /mnt/vault --pin-file ~/.vaultpin
|
|
135
|
+
aloelite-fuse vault.sqlite vault /mnt/vault --pin-env VAULT_PIN
|
|
136
|
+
|
|
137
|
+
# Unmount
|
|
138
|
+
fusermount3 -u /mnt/photos
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The FUSE driver uses bounded-memory streaming I/O for both reads and writes — a 15 GB copy does not buffer in RAM. Sequential writes flush one chunk at a time; non-sequential access on large files falls back to a buffered path.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Volume Manager
|
|
146
|
+
|
|
147
|
+
The volume manager is a privileged container that manages multiple AloeLite volumes and exposes each as a FUSE-mounted subdirectory, accessible to other containers via bind mount.
|
|
148
|
+
|
|
149
|
+
### Run
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
# Host directories (once)
|
|
153
|
+
sudo mkdir -p /aloelite-root /mnt/aloelite
|
|
154
|
+
|
|
155
|
+
docker run -d --privileged \
|
|
156
|
+
-v /aloelite-root:/aloelite-root \
|
|
157
|
+
-v /mnt/aloelite:/mnt:rshared \
|
|
158
|
+
--device /dev/fuse \
|
|
159
|
+
-p 8080:8080 \
|
|
160
|
+
aloecraft/aloelite-manager
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`/aloelite-root` holds the backing SQLite files and persists across restarts. `/mnt/aloelite` is the host-visible mount root; FUSE mounts inside the container propagate here via `rshared`. `--privileged` (or at minimum `CAP_SYS_ADMIN`) is required.
|
|
164
|
+
|
|
165
|
+
### API
|
|
166
|
+
|
|
167
|
+
| Method | Path | Description |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `POST` | `/volumes` | Create a volume |
|
|
170
|
+
| `GET` | `/volumes` | List all volumes |
|
|
171
|
+
| `DELETE` | `/volumes/<id>` | Delete a volume (unmounts first) |
|
|
172
|
+
| `POST` | `/volumes/<id>/mount` | Mount a volume |
|
|
173
|
+
| `DELETE` | `/volumes/<id>/mount` | Unmount a volume |
|
|
174
|
+
| `GET` | `/volumes/<id>/mount` | Mount status |
|
|
175
|
+
| `GET` | `/volumes/<id>/stat` | Backing file metadata (size, mtime) |
|
|
176
|
+
| `GET` | `/volumes/<id>/export` | Checkpoint + stream the SQLite file |
|
|
177
|
+
| `POST` | `/volumes/<id>/checkpoint` | Run `WAL_CHECKPOINT(TRUNCATE)` |
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
# Create and mount
|
|
181
|
+
curl -s -X POST http://localhost:8080/volumes \
|
|
182
|
+
-H 'Content-Type: application/json' \
|
|
183
|
+
-d '{"name": "myphotos"}' | tee /tmp/vol.json
|
|
184
|
+
|
|
185
|
+
VID=$(jq -r .id /tmp/vol.json)
|
|
186
|
+
curl -s -X POST http://localhost:8080/volumes/$VID/mount \
|
|
187
|
+
-H 'Content-Type: application/json' -d '{}'
|
|
188
|
+
|
|
189
|
+
# The volume is now a plain directory on the host
|
|
190
|
+
ls /mnt/aloelite/$VID
|
|
191
|
+
|
|
192
|
+
# Consume from another container
|
|
193
|
+
docker run --rm -v /mnt/aloelite/$VID:/data alpine ls /data
|
|
194
|
+
|
|
195
|
+
# Backup: poll stat, export on change
|
|
196
|
+
curl -s http://localhost:8080/volumes/$VID/stat | jq
|
|
197
|
+
curl -s http://localhost:8080/volumes/$VID/export -o snapshot.sqlite
|
|
198
|
+
|
|
199
|
+
# Encrypted volume
|
|
200
|
+
curl -s -X POST http://localhost:8080/volumes \
|
|
201
|
+
-H 'Content-Type: application/json' \
|
|
202
|
+
-d '{"name": "vault", "encrypted": true, "pin": "correct-horse"}'
|
|
203
|
+
# Mount with: -d '{"pin": "correct-horse"}'
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The export endpoint runs `WAL_CHECKPOINT(TRUNCATE)` before streaming, producing a complete self-contained SQLite file with no accompanying WAL. The volume does not need to be unmounted to export — SQLite's read consistency guarantees a coherent snapshot regardless of active writes.
|
|
207
|
+
|
|
208
|
+
### Backup sync pattern
|
|
209
|
+
|
|
210
|
+
```
|
|
211
|
+
loop:
|
|
212
|
+
poll GET /volumes/<id>/stat
|
|
213
|
+
if mtime > last_known_mtime:
|
|
214
|
+
GET /volumes/<id>/export → write to temp file → rename into place
|
|
215
|
+
last_known_mtime = mtime
|
|
216
|
+
sleep(interval)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The rename into place is atomic; a failed export leaves the previous replica intact.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Security Notes
|
|
224
|
+
|
|
225
|
+
**Chunk data** is encrypted at the storage boundary (ChaCha20-Poly1305, Argon2id key derivation). The SQLite file is opaque without the PIN.
|
|
226
|
+
|
|
227
|
+
**Node metadata** (paths, timestamps, node IDs, directory structure) is stored in plaintext in the SQLite schema. An observer with access to the file can read the filesystem tree even without the PIN. For sensitive deployments, place the backing file on an encrypted volume (LUKS, encrypted home directory, etc.) or use the `pack` primitive to seal a subtree before transport.
|
|
228
|
+
|
|
229
|
+
The volume manager API is intended for trusted networks. PINs are transmitted in request bodies and never logged or persisted; the derived key is held only for the duration of the mount session.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
Apache 2.0. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# ./aloelite/__init__.py
|
|
2
|
+
# License: Apache-2.0 (disclaimer at bottom of file)
|
|
3
|
+
"""
|
|
4
|
+
aloefs — Python reference implementation of the SQLite-backed Mount API.
|
|
5
|
+
|
|
6
|
+
This is the oracle: the implementation the conformance suite is generated from
|
|
7
|
+
and the other three (Rust, JS/WASM, Kotlin) are tested against. It drives the
|
|
8
|
+
shared SQL templates (sql-templates.yaml) rather than hand-written SQL, so it
|
|
9
|
+
stays a true reference for the template-driven implementations.
|
|
10
|
+
|
|
11
|
+
Layering, bottom to top:
|
|
12
|
+
schema.sql (the SQL floor)
|
|
13
|
+
db.Db / Db.txn (connection, templates, transaction boundary)
|
|
14
|
+
resolve.resolve / resolve_parent (path -> id, the most-reused logic)
|
|
15
|
+
[function layer] (flat Mount API — next session)
|
|
16
|
+
types / errors / models (vocabulary, used throughout)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from . import errors, operations
|
|
20
|
+
from .db import Db, Templates
|
|
21
|
+
from .descriptor import Descriptor
|
|
22
|
+
from .models import (
|
|
23
|
+
Anomaly,
|
|
24
|
+
ContentPruneReport,
|
|
25
|
+
DirEntry,
|
|
26
|
+
LockInfo,
|
|
27
|
+
MountInfo,
|
|
28
|
+
NodeInfo,
|
|
29
|
+
PruneReport,
|
|
30
|
+
VolumeInfo,
|
|
31
|
+
)
|
|
32
|
+
from .resolve import Parent, Resolved, resolve, resolve_parent, split_path
|
|
33
|
+
from .types import (
|
|
34
|
+
EdgeId,
|
|
35
|
+
FdId,
|
|
36
|
+
LockId,
|
|
37
|
+
LockMode,
|
|
38
|
+
MountId,
|
|
39
|
+
MountState,
|
|
40
|
+
NodeId,
|
|
41
|
+
NodeType,
|
|
42
|
+
Path,
|
|
43
|
+
Timestamp,
|
|
44
|
+
VolumeId,
|
|
45
|
+
Whence,
|
|
46
|
+
WriteMode,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
# scaffolding
|
|
51
|
+
"Db",
|
|
52
|
+
"Templates",
|
|
53
|
+
# resolve
|
|
54
|
+
"resolve",
|
|
55
|
+
"resolve_parent",
|
|
56
|
+
"split_path",
|
|
57
|
+
"Resolved",
|
|
58
|
+
"Parent",
|
|
59
|
+
# models
|
|
60
|
+
"VolumeInfo",
|
|
61
|
+
"NodeInfo",
|
|
62
|
+
"DirEntry",
|
|
63
|
+
"MountInfo",
|
|
64
|
+
"LockInfo",
|
|
65
|
+
"Anomaly",
|
|
66
|
+
"PruneReport",
|
|
67
|
+
"ContentPruneReport",
|
|
68
|
+
# types
|
|
69
|
+
"NodeId",
|
|
70
|
+
"EdgeId",
|
|
71
|
+
"VolumeId",
|
|
72
|
+
"MountId",
|
|
73
|
+
"LockId",
|
|
74
|
+
"FdId",
|
|
75
|
+
"Path",
|
|
76
|
+
"Timestamp",
|
|
77
|
+
"NodeType",
|
|
78
|
+
"MountState",
|
|
79
|
+
"Whence",
|
|
80
|
+
"WriteMode",
|
|
81
|
+
"LockMode",
|
|
82
|
+
# errors module
|
|
83
|
+
"errors",
|
|
84
|
+
"operations",
|
|
85
|
+
"Descriptor",
|
|
86
|
+
]
|
|
87
|
+
# Copyright Michael Godfrey 2026 | aloecraft.org <michael@aloecraft.org>
|
|
88
|
+
#
|
|
89
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
90
|
+
# you may not use this file except in compliance with the License.
|
|
91
|
+
# You may obtain a copy of the License at
|
|
92
|
+
#
|
|
93
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
94
|
+
#
|
|
95
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
96
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
97
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
98
|
+
# See the License for the specific language governing permissions and
|
|
99
|
+
# limitations under the License.
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# ./aloelite/aloelite.py
|
|
2
|
+
# License: Apache-2.0 (disclaimer at bottom of file)
|
|
3
|
+
"""
|
|
4
|
+
AloeLite — the ergonomic, Pythonic wrapper.
|
|
5
|
+
|
|
6
|
+
This is the ONLY layer that is allowed object state and sugar. It sits on top of
|
|
7
|
+
the flat function layer (operations.py) and adds nothing to the contract — the
|
|
8
|
+
other three implementations will each grow their own idiomatic wrapper over the
|
|
9
|
+
same operations. Two objects, each owning a resource as a context manager:
|
|
10
|
+
|
|
11
|
+
AloeLite — owns the file / connection (the transient physical attachment).
|
|
12
|
+
`with AloeLite(path) as fs:` opens it; exit closes the connection.
|
|
13
|
+
Note a mount is a ROW, not this connection: the connection is
|
|
14
|
+
disposable, the mount id outlives it.
|
|
15
|
+
|
|
16
|
+
Mount — a handle bound to one mount id. `with fs.mount(vol) as m:` opens a
|
|
17
|
+
session; exit unmounts it. Every method forwards to operations.*
|
|
18
|
+
with the mount id already bound, so callers write m.list("/")
|
|
19
|
+
instead of operations.list(db, mount_id, "/").
|
|
20
|
+
|
|
21
|
+
The streaming descriptor returned by m.open_read/open_write is itself a context
|
|
22
|
+
manager (its own concern: the lock lifecycle), so it composes:
|
|
23
|
+
with fs.mount(vol) as m:
|
|
24
|
+
with m.open_write("/f") as w:
|
|
25
|
+
w.write(b"...")
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import builtins
|
|
31
|
+
from pathlib import Path as _FsPath
|
|
32
|
+
from typing import Iterator
|
|
33
|
+
|
|
34
|
+
from . import operations as ops
|
|
35
|
+
from .db import Db
|
|
36
|
+
from .descriptor import Descriptor
|
|
37
|
+
from .models import (
|
|
38
|
+
ContentPruneReport,
|
|
39
|
+
DirEntry,
|
|
40
|
+
MountInfo,
|
|
41
|
+
NodeInfo,
|
|
42
|
+
PruneReport,
|
|
43
|
+
VolumeInfo,
|
|
44
|
+
)
|
|
45
|
+
from .types import MountId, NodeId, VolumeId, WriteMode
|
|
46
|
+
|
|
47
|
+
# Default spec locations, resolved relative to this package. Override per call.
|
|
48
|
+
_PKG = _FsPath(__file__).resolve().parent
|
|
49
|
+
_DEFAULT_TEMPLATES = _PKG / "../config/sql-templates.yaml"
|
|
50
|
+
_DEFAULT_SCHEMA = _PKG / "../sql/schema.sql"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AloeLite:
|
|
54
|
+
"""A handle to an AloeLite filesystem file (owns the connection)."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
path: str | _FsPath = ":memory:",
|
|
59
|
+
*,
|
|
60
|
+
templates_path: str | _FsPath = _DEFAULT_TEMPLATES,
|
|
61
|
+
schema_path: str | _FsPath | None = _DEFAULT_SCHEMA,
|
|
62
|
+
ensure_schema: bool = True,
|
|
63
|
+
) -> None:
|
|
64
|
+
# The schema is idempotent (CREATE ... IF NOT EXISTS), so applying it on
|
|
65
|
+
# open is safe for both new and existing files.
|
|
66
|
+
self._db = Db.open(
|
|
67
|
+
path,
|
|
68
|
+
templates_path,
|
|
69
|
+
schema_path=schema_path if ensure_schema else None,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# -- connection lifecycle (this object's context manager) ----------------
|
|
73
|
+
def __enter__(self) -> "AloeLite":
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def __exit__(self, *exc: object) -> None:
|
|
77
|
+
self.close()
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
self._db.close()
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def db(self) -> Db:
|
|
84
|
+
"""Escape hatch to the connection wrapper (for advanced/raw use)."""
|
|
85
|
+
return self._db
|
|
86
|
+
|
|
87
|
+
# -- volumes -------------------------------------------------------------
|
|
88
|
+
def create_volume(
|
|
89
|
+
self,
|
|
90
|
+
name: str | None = None,
|
|
91
|
+
chunk_size: int = 1048576,
|
|
92
|
+
pin: bytes | None = None,
|
|
93
|
+
*,
|
|
94
|
+
enc_mode: str = "convergent",
|
|
95
|
+
) -> VolumeInfo:
|
|
96
|
+
return ops.create_volume(self._db, name, chunk_size, pin, enc_mode=enc_mode)
|
|
97
|
+
|
|
98
|
+
def list_volumes(self) -> builtins.list[VolumeInfo]:
|
|
99
|
+
return ops.list_volumes(self._db)
|
|
100
|
+
|
|
101
|
+
# -- mounts --------------------------------------------------------------
|
|
102
|
+
def mount(
|
|
103
|
+
self,
|
|
104
|
+
volume: VolumeId,
|
|
105
|
+
at: str = "/",
|
|
106
|
+
ttl_ms: int | None = None,
|
|
107
|
+
pin: bytes | None = None,
|
|
108
|
+
) -> "Mount":
|
|
109
|
+
mid = ops.mount(self._db, volume, at, ttl_ms, pin)
|
|
110
|
+
sess = self._db.active_session
|
|
111
|
+
token = sess["token"] if sess and sess.get("mount_id") == mid else None
|
|
112
|
+
return Mount(self._db, mid, token=token)
|
|
113
|
+
|
|
114
|
+
def attach(self, mount: MountId) -> "Mount":
|
|
115
|
+
"""Re-attach to an existing mount row (e.g. one created elsewhere and
|
|
116
|
+
resumed on this connection). The mount is validated lazily, per op."""
|
|
117
|
+
return Mount(self._db, mount)
|
|
118
|
+
|
|
119
|
+
# -- maintenance ---------------------------------------------------------
|
|
120
|
+
def prune(self, volume: VolumeId | None = None) -> PruneReport:
|
|
121
|
+
return ops.prune(self._db, volume)
|
|
122
|
+
|
|
123
|
+
def prune_content(self, volume: VolumeId | None = None) -> ContentPruneReport:
|
|
124
|
+
return ops.prune_content(self._db, volume)
|
|
125
|
+
|
|
126
|
+
def health_check(self) -> builtins.list:
|
|
127
|
+
return ops.health_check(self._db)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Mount:
|
|
131
|
+
"""A bound mount/session handle. Context manager: exit unmounts."""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self, db: Db, mount_id: MountId, *, token: bytes | None = None
|
|
135
|
+
) -> None:
|
|
136
|
+
self._db = db
|
|
137
|
+
self.id = mount_id
|
|
138
|
+
# The per-mount token (encrypted volumes only); None when unencrypted.
|
|
139
|
+
# Runtime-only handle that, with N_m, stands in for the PIN this session.
|
|
140
|
+
self.token = token
|
|
141
|
+
|
|
142
|
+
# -- the mount's context manager (session lifecycle) ---------------------
|
|
143
|
+
def __enter__(self) -> "Mount":
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
def __exit__(self, *exc: object) -> None:
|
|
147
|
+
self.unmount()
|
|
148
|
+
|
|
149
|
+
def unmount(self) -> None:
|
|
150
|
+
ops.unmount(self._db, self.id)
|
|
151
|
+
|
|
152
|
+
def info(self) -> MountInfo:
|
|
153
|
+
return ops.mount_info(self._db, self.id)
|
|
154
|
+
|
|
155
|
+
def renew(self, ttl_ms: int | None = None) -> MountInfo:
|
|
156
|
+
return ops.renew_mount(self._db, self.id, ttl_ms)
|
|
157
|
+
|
|
158
|
+
# -- read ----------------------------------------------------------------
|
|
159
|
+
def stat(self, path: str) -> NodeInfo:
|
|
160
|
+
return ops.stat(self._db, self.id, path)
|
|
161
|
+
|
|
162
|
+
def stat_by_id(self, node: NodeId) -> NodeInfo:
|
|
163
|
+
return ops.stat_by_id(self._db, self.id, node)
|
|
164
|
+
|
|
165
|
+
def exists(self, path: str) -> bool:
|
|
166
|
+
return ops.exists(self._db, self.id, path)
|
|
167
|
+
|
|
168
|
+
def list(self, path: str = "/") -> builtins.list[DirEntry]:
|
|
169
|
+
return ops.list(self._db, self.id, path)
|
|
170
|
+
|
|
171
|
+
def read_all(self, path: str) -> bytes:
|
|
172
|
+
return ops.read_all(self._db, self.id, path)
|
|
173
|
+
|
|
174
|
+
def path_of(self, node: NodeId) -> str:
|
|
175
|
+
return ops.path_of(self._db, self.id, node)
|
|
176
|
+
|
|
177
|
+
# -- structural ----------------------------------------------------------
|
|
178
|
+
def create_container(self, path: str) -> NodeId:
|
|
179
|
+
return ops.create_container(self._db, self.id, path)
|
|
180
|
+
|
|
181
|
+
def create_entry(self, path: str, data: bytes | None = None) -> NodeId:
|
|
182
|
+
return ops.create_entry(self._db, self.id, path, data)
|
|
183
|
+
|
|
184
|
+
def write_all(self, path: str, data: bytes) -> None:
|
|
185
|
+
ops.write_all(self._db, self.id, path, data)
|
|
186
|
+
|
|
187
|
+
def rename(self, path: str, name: str) -> None:
|
|
188
|
+
ops.rename(self._db, self.id, path, name)
|
|
189
|
+
|
|
190
|
+
def set_metadata(self, path: str, metadata: dict[str, str]) -> None:
|
|
191
|
+
ops.set_metadata(self._db, self.id, path, metadata)
|
|
192
|
+
|
|
193
|
+
def set_retention(self, path: str, keep: int | None) -> None:
|
|
194
|
+
ops.set_retention(self._db, self.id, path, keep)
|
|
195
|
+
|
|
196
|
+
def move(self, src: str, dst: str) -> None:
|
|
197
|
+
ops.move(self._db, self.id, src, dst)
|
|
198
|
+
|
|
199
|
+
def remove(self, path: str) -> None:
|
|
200
|
+
ops.remove(self._db, self.id, path)
|
|
201
|
+
|
|
202
|
+
def remove_recursive(self, path: str) -> None:
|
|
203
|
+
ops.remove_recursive(self._db, self.id, path)
|
|
204
|
+
|
|
205
|
+
def copy(self, src: str, dst: str) -> NodeId:
|
|
206
|
+
return ops.copy(self._db, self.id, src, dst)
|
|
207
|
+
|
|
208
|
+
def pack(self, path: str) -> NodeId:
|
|
209
|
+
return ops.pack(self._db, self.id, path)
|
|
210
|
+
|
|
211
|
+
def unpack(self, path: str) -> None:
|
|
212
|
+
ops.unpack(self._db, self.id, path)
|
|
213
|
+
|
|
214
|
+
# -- streaming -----------------------------------------------------------
|
|
215
|
+
def open_read(self, path: str) -> Descriptor:
|
|
216
|
+
return ops.open_read(self._db, self.id, path)
|
|
217
|
+
|
|
218
|
+
def open_write(self, path: str, mode: WriteMode = WriteMode.TRUNCATE) -> Descriptor:
|
|
219
|
+
if not self.exists(path) and mode is not WriteMode.APPEND:
|
|
220
|
+
ops.create_entry(self._db, self.id, path)
|
|
221
|
+
return ops.open_write(self._db, self.id, path, mode)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
__all__ = ["AloeLite", "Mount"]
|
|
225
|
+
# Copyright Michael Godfrey 2026 | aloecraft.org <michael@aloecraft.org>
|
|
226
|
+
#
|
|
227
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
228
|
+
# you may not use this file except in compliance with the License.
|
|
229
|
+
# You may obtain a copy of the License at
|
|
230
|
+
#
|
|
231
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
232
|
+
#
|
|
233
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
234
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
235
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
236
|
+
# See the License for the specific language governing permissions and
|
|
237
|
+
# limitations under the License.
|