ontodag-fs 0.0.1__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.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: ontodag-fs
3
+ Version: 0.0.1
4
+ Summary: Stateless fsspec adapter presenting an OntoDAG concept lattice as a browsable filesystem, with bytes on Ethereum Swarm via swarmfs.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: fsspec
7
+ Requires-Dist: ontodag
8
+ Requires-Dist: swarmfs
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == "dev"
11
+ Requires-Dist: pytest-asyncio; extra == "dev"
12
+ Requires-Dist: hypothesis; extra == "dev"
@@ -0,0 +1,125 @@
1
+ # ontodag-fs
2
+
3
+ **Browse your knowledge, not your folders.** ontodag-fs presents an
4
+ [OntoDAG](https://github.com/petfold/ontodag) category lattice as a real,
5
+ mountable filesystem, with file content stored on
6
+ [Ethereum Swarm](https://www.ethswarm.org/) via
7
+ [swarmfs](https://github.com/petfold/swarmfs).
8
+
9
+ It is a modern descendant of Gifford's *Semantic File System* (SOSP '91):
10
+ directory names are interpreted as **queries**, not locations — but with a
11
+ concept lattice instead of flat attributes, and content-addressed storage
12
+ instead of a local disk.
13
+
14
+ ```console
15
+ $ odag-fs tree /
16
+ /
17
+ ├── animal/
18
+ │ ├── dog/
19
+ │ │ └── rex.txt
20
+ │ ├── pet/
21
+ │ │ └── rex.txt
22
+ │ └── spider/
23
+ │ ├── document/
24
+ │ │ └── web-study.md
25
+ │ └── charlotte.txt
26
+ └── document/
27
+ ├── spider/
28
+ │ └── web-study.md
29
+ ├── DESIGN_DECISIONS.md
30
+ └── SPEC.md
31
+ ```
32
+
33
+ The same file appears under every path it *belongs* to — `web-study.md` is
34
+ both a spider thing and a document, so it lives at `/animal/spider/document/`,
35
+ `/document/spider/`, and every reordering of those. No copies, no symlinks:
36
+ one object, several true names.
37
+
38
+ ## The ideas in five lines
39
+
40
+ - **Paths are queries.** `/pet/dog` means "everything that is a pet AND a
41
+ dog". Order never matters: `/dog/pet` is the same place.
42
+ - **Directories are concepts.** Subdirectories are the categories that
43
+ meaningfully refine what you're looking at.
44
+ - **Files are classified objects.** A file's identity is its Swarm content
45
+ address; its name is just a display label.
46
+ - **The ontology does the work.** File something under `dog` and it is
47
+ automatically under `mammal`, `animal`, `pet` — subsumption is free.
48
+ - **References cannot dangle.** Content addressing means a classification
49
+ can never point at a file that "moved" — there is nowhere to move to.
50
+ (Tag filesystems on top of paths fight this forever; here it is
51
+ structurally impossible.)
52
+
53
+ ## Quick start
54
+
55
+ ```console
56
+ $ pip install swarmfs # on PyPI (as are recordstore and swarmlite)
57
+ $ pip install \
58
+ "ontodag[swarm] @ git+https://github.com/petfold/ontodag.git" \
59
+ "ontodag-fs @ git+https://github.com/petfold/ontodag-fs.git"
60
+ $ odag-fs set store swarm:my-store # once — the same setting odag uses
61
+ $ odag-fs tree /
62
+ $ odag-fs cat /pet/dog/rex.txt
63
+ $ odag-fs # interactive: cd/ls/cat with a > prompt
64
+ $ pip install fusepy && odag-fs mount ~/mnt
65
+ ```
66
+
67
+ New here? Read the **[User Guide](docs/USER_GUIDE.md)** — a tutorial that
68
+ takes you from an empty machine to a mounted, browsable ontology, with
69
+ worked examples of every capability.
70
+
71
+ ## Status
72
+
73
+ **v0 — read-only view.** Browsing (`ls`, `tree`, `cat`, `info`, FUSE mount)
74
+ is complete and tested. Filing through the filesystem (`cp` into a concept
75
+ directory, `rm` as reclassification, `mv` between concepts) is **v0.1**, in
76
+ progress; today filing is done with a short Python helper (see the User
77
+ Guide). The lattice itself (creating categories) is edited through OntoDAG's
78
+ own API, never through the mount. See [ROADMAP.md](ROADMAP.md).
79
+
80
+ ## Architecture
81
+
82
+ ```
83
+ FUSE mount (fsspec.fuse — a deployment mode, not architecture)
84
+ │
85
+ OntoDAGFileSystem (this repo: fsspec AbstractFileSystem, stateless glue)
86
+ │ │
87
+ OntoDAG swarmfs
88
+ (classifier/index) (bytestore: fsspec backend for Swarm)
89
+ │ │
90
+ recordstore Bee node
91
+ (persistence of the DAG)
92
+ ```
93
+
94
+ This repo owns **no state**: classifications live in OntoDAG, the DAG
95
+ persists through recordstore, bytes live on Swarm. `OntoDAGFileSystem` is a
96
+ pure [fsspec](https://filesystem-spec.readthedocs.io/) backend, so besides
97
+ the CLI and FUSE you also get the whole fsspec ecosystem (pandas, pyarrow,
98
+ DuckDB, …) for free.
99
+
100
+ | Repo | Role |
101
+ |---|---|
102
+ | [ontodag](https://github.com/petfold/ontodag) | the category DAG — index and classifier |
103
+ | [swarmfs](https://github.com/petfold/swarmfs) | fsspec backend for Swarm — the bytestore |
104
+ | [recordstore](https://github.com/petfold/recordstore) | versioned key→record store over Swarm — DAG persistence |
105
+ | [mdl-fca](https://github.com/petfold/mdl-fca) | MDL/FCA learning over the same DAG (upstream, not a dependency) |
106
+
107
+ ## Documentation
108
+
109
+ - [User Guide](docs/USER_GUIDE.md) — tutorial, setup, worked examples
110
+ - [SPEC.md](SPEC.md) — the precise v0/v0.1 contract, method by method
111
+ - [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) — prior art and every decision, with reasoning
112
+ - [ROADMAP.md](ROADMAP.md) — what's in scope now vs later
113
+
114
+ ## Development
115
+
116
+ ```console
117
+ $ git clone https://github.com/petfold/ontodag-fs && cd ontodag-fs
118
+ $ python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
119
+ $ .venv/bin/pytest
120
+ ```
121
+
122
+ The test suite runs entirely offline — no Bee node, no FUSE — and every
123
+ test runs against both the in-memory reference index and the real OntoDAG
124
+ adapter, so the two cannot drift apart. Path-resolution invariants are
125
+ property-based (hypothesis); see SPEC §6 for the invariant list.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ontodag-fs"
7
+ version = "0.0.1"
8
+ description = "Stateless fsspec adapter presenting an OntoDAG concept lattice as a browsable filesystem, with bytes on Ethereum Swarm via swarmfs."
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "fsspec",
12
+ "ontodag",
13
+ "swarmfs",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pytest",
19
+ "pytest-asyncio",
20
+ "hypothesis",
21
+ ]
22
+
23
+ [project.entry-points."fsspec.specs"]
24
+ ontodag = "ontodag_fs.fs:OntoDAGFileSystem"
25
+
26
+ [project.scripts]
27
+ odag-fs = "ontodag_fs.__main__:main"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+ markers = [
35
+ "fuse: FUSE integration tests (require libfuse; skipped by default)",
36
+ ]
37
+ addopts = "-m 'not fuse'"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,15 @@
1
+ """ontodag-fs: an OntoDAG concept lattice as an fsspec filesystem over Swarm."""
2
+
3
+ from .fs import OntoDAGFileSystem
4
+ from .index import ConceptIndex, ObjectInfo, UnknownAttributeError
5
+ from .memory import InMemoryIndex
6
+ from .ontodag_index import OntoDAGIndex
7
+
8
+ __all__ = [
9
+ "ConceptIndex",
10
+ "InMemoryIndex",
11
+ "ObjectInfo",
12
+ "OntoDAGFileSystem",
13
+ "OntoDAGIndex",
14
+ "UnknownAttributeError",
15
+ ]
@@ -0,0 +1,368 @@
1
+ """Thin browse CLI for the v0 manual milestone (the full CLI is ROADMAP v1).
2
+
3
+ Loads a DAG through odag's store machinery — the same store specs odag
4
+ uses: a path to a native/OWL file, or ``swarm:NAME`` — and serves it
5
+ read-only with OntoDAGFileSystem over swarmfs.
6
+
7
+ odag-fs [-s STORE] [--bee-api URL] [COMMAND [args]]
8
+
9
+ Commands: ls, tree, cat, info, cd, pwd, mount, set, help. With no command,
10
+ odag-fs follows odag's convention: it reads commands from a pipe, or opens
11
+ an interactive ``>`` prompt on a terminal — with a current directory, so
12
+ paths may be relative (``cd pet`` then ``ls``). The store is hydrated once
13
+ per session, so repeated commands are fast.
14
+
15
+ ``set`` reads and writes the same ``~/.ontodag/config`` as odag (keys:
16
+ store, bee_api, bee_batch), so ``odag set store swarm:NAME`` and
17
+ ``odag-fs set store swarm:NAME`` are interchangeable and no ``-s`` is
18
+ needed once a default store is set. With nothing configured the default
19
+ is the local ``~/.ontodag`` store. ``mount`` uses fsspec's generic FUSE
20
+ wrapper (needs fusepy; deployment mode, not architecture).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import os
27
+ import posixpath
28
+ import shlex
29
+ import sys
30
+
31
+ try:
32
+ from importlib.metadata import PackageNotFoundError, version
33
+
34
+ try:
35
+ __version__ = version("ontodag-fs")
36
+ except PackageNotFoundError:
37
+ __version__ = "dev"
38
+ except Exception: # pragma: no cover
39
+ __version__ = "dev"
40
+
41
+
42
+ HELP_TEXT = """\
43
+ Usage: odag-fs [-s STORE] [--bee-api URL] [<command> [args]]
44
+
45
+ Commands:
46
+ ls [-l] [PATH] list a concept directory
47
+ tree [PATH] [--depth N] recursive listing of the lattice
48
+ cat PATH print an object's bytes
49
+ info PATH show details for a path
50
+ cd [PATH] change the current directory (interactive mode)
51
+ pwd print the current directory
52
+ mount MOUNTPOINT FUSE-mount the view (needs fusepy)
53
+ set [KEY [VALUE]] show settings, or set one (store, bee_api, bee_batch)
54
+ help show this help
55
+
56
+ With no command odag-fs reads commands from a pipe, or opens an interactive
57
+ `>` prompt on a terminal — the same convention as odag. In that mode paths
58
+ may be relative to the current directory (`cd pet` then `ls`), and the
59
+ store is loaded once for the whole session.
60
+
61
+ Settings are shared with odag (~/.ontodag/config): `set store swarm:NAME`
62
+ in either tool makes -s unnecessary everywhere. With nothing configured
63
+ the default is the local ~/.ontodag store.
64
+
65
+ Options:
66
+ -s, --store STORE one-off store override: a file path or swarm:NAME
67
+ --bee-api URL Bee API URL (default: $BEE_API, configured bee_api,
68
+ or localhost)
69
+ """
70
+
71
+
72
+ # ----------------------------------------------------------------- session
73
+
74
+
75
+ class Session:
76
+ """One loaded store + a current directory, shared across commands."""
77
+
78
+ def __init__(self, store_spec: str | None, bee_api: str | None):
79
+ self.store_spec = store_spec
80
+ self.bee_api = bee_api
81
+ self.cwd = "/"
82
+ self._fs = None
83
+
84
+ @property
85
+ def fs(self):
86
+ if self._fs is None:
87
+ self._fs = _build_fs(self.store_spec, self.bee_api)
88
+ return self._fs
89
+
90
+ def switch(self) -> None:
91
+ """Drop the loaded store (after `set store`); reload lazily."""
92
+ self._fs = None
93
+ self.store_spec = None # the new config default takes over
94
+ self.cwd = "/"
95
+
96
+ def resolve(self, path: str) -> str:
97
+ """Make a path absolute against the current directory (supports
98
+ `.` and `..`, which operate on the typed path, not the lattice)."""
99
+ if not path.startswith("/"):
100
+ path = posixpath.join(self.cwd, path)
101
+ norm = posixpath.normpath(path)
102
+ return "/" if norm in (".", "//") else norm
103
+
104
+
105
+ def _build_fs(store_spec: str | None, bee_api: str | None):
106
+ # odag's CLI module is the authority on store specs/config; reusing its
107
+ # (private) helpers is accepted milestone tooling — the real CLI (v1)
108
+ # gets a public seam.
109
+ from ontodag.__main__ import _make_backend, _read_config, _resolve_store
110
+
111
+ from swarmfs import SwarmFileSystem
112
+
113
+ from . import OntoDAGFileSystem, OntoDAGIndex
114
+
115
+ dag = _make_backend(_resolve_store(store_spec)).load()
116
+ api = bee_api or os.environ.get("BEE_API") or _read_config().get("bee_api")
117
+ swarm = SwarmFileSystem(api_url=api) if api else SwarmFileSystem()
118
+ return OntoDAGFileSystem(index=OntoDAGIndex(dag), swarm=swarm)
119
+
120
+
121
+ # ---------------------------------------------------------------- commands
122
+
123
+
124
+ def _basename(entry: dict) -> str:
125
+ return entry["name"].rstrip("/").rsplit("/", 1)[-1]
126
+
127
+
128
+ def cmd_ls(session: Session, args) -> None:
129
+ for e in session.fs.ls(session.resolve(args.path), detail=True):
130
+ base = _basename(e)
131
+ if e["type"] == "directory":
132
+ print(base + "/")
133
+ elif args.long:
134
+ print(f"{base} [{e.get('swarm_ref', '')[:8]}] {sorted(e.get('intent', []))}")
135
+ else:
136
+ print(base)
137
+
138
+
139
+ def cmd_tree(session: Session, args) -> None:
140
+ path = session.resolve(args.path)
141
+ print(path)
142
+ _tree(session.fs, path, args.depth)
143
+
144
+
145
+ def _tree(fs, path, depth, prefix="") -> None:
146
+ entries = fs.ls(path, detail=True)
147
+ entries.sort(key=lambda e: (e["type"] != "directory", e["name"]))
148
+ for i, e in enumerate(entries):
149
+ last = i == len(entries) - 1
150
+ connector = "└── " if last else "├── "
151
+ base = _basename(e)
152
+ if e["type"] == "directory":
153
+ print(prefix + connector + base + "/")
154
+ # .swarm is unenumerable; .all repeats what the tree already
155
+ # shows — descend the lattice and .unfiled only
156
+ if depth > 1 and base not in (".all", ".swarm"):
157
+ _tree(fs, e["name"], depth - 1, prefix + (" " if last else "│ "))
158
+ else:
159
+ ref = e.get("swarm_ref", "")
160
+ print(prefix + connector + base + (f" [{ref[:8]}]" if ref else ""))
161
+
162
+
163
+ def cmd_cat(session: Session, args) -> None:
164
+ sys.stdout.buffer.write(session.fs.cat_file(session.resolve(args.path)))
165
+ sys.stdout.buffer.flush()
166
+
167
+
168
+ def cmd_info(session: Session, args) -> None:
169
+ for key, value in session.fs.info(session.resolve(args.path)).items():
170
+ print(f"{key}: {value}")
171
+
172
+
173
+ def cmd_cd(session: Session, args) -> None:
174
+ path = session.resolve(args.path)
175
+ if not session.fs.isdir(path):
176
+ raise FileNotFoundError(f"not a directory: {path}")
177
+ session.cwd = path
178
+
179
+
180
+ def cmd_pwd(session: Session, args) -> None:
181
+ print(session.cwd)
182
+
183
+
184
+ def cmd_mount(session: Session, args) -> None:
185
+ try:
186
+ from fsspec.fuse import run
187
+ except ImportError:
188
+ raise ValueError("odag-fs mount needs fusepy: pip install fusepy") from None
189
+ print(f"mounting ontodag view at {args.mountpoint} — Ctrl-C or "
190
+ f"`fusermount -u {args.mountpoint}` to unmount")
191
+ run(session.fs, "/", args.mountpoint)
192
+
193
+
194
+ def cmd_set(session: Session, args) -> None:
195
+ """Show or change settings — same keys and config file as odag's `set`
196
+ (~/.ontodag/config), so either tool's `set store` configures both."""
197
+ from ontodag.__main__ import (
198
+ _SETTINGS,
199
+ _normalize_spec,
200
+ _read_config,
201
+ _resolve_store,
202
+ _write_config,
203
+ )
204
+
205
+ def effective(key: str) -> str:
206
+ cfg = _read_config()
207
+ if key == "store":
208
+ return session.store_spec or _resolve_store(None)
209
+ if key == "bee_api":
210
+ return os.environ.get("BEE_API") or cfg.get("bee_api") or "http://localhost:1633"
211
+ if key == "bee_batch":
212
+ return os.environ.get("BEE_BATCH") or cfg.get("bee_batch") or ""
213
+ return cfg.get(key, "")
214
+
215
+ if not args.key:
216
+ for key in _SETTINGS:
217
+ print(f"{key} = {effective(key)}")
218
+ return
219
+ if args.key not in _SETTINGS:
220
+ raise ValueError(f"unknown setting: {args.key} "
221
+ f"(known: {', '.join(_SETTINGS)})")
222
+ if args.value is None:
223
+ print(f"{args.key} = {effective(args.key)}")
224
+ return
225
+ cfg = _read_config()
226
+ cfg[args.key] = _normalize_spec(args.value) if args.key == "store" else args.value
227
+ _write_config(cfg)
228
+ if args.key == "store":
229
+ session.switch()
230
+
231
+
232
+ def cmd_help(session: Session, args) -> None:
233
+ sys.stdout.write(HELP_TEXT)
234
+
235
+
236
+ # ------------------------------------------------------------------ parser
237
+
238
+
239
+ def _build_command_parser(with_globals: bool) -> argparse.ArgumentParser:
240
+ parser = argparse.ArgumentParser(prog="odag-fs", add_help=with_globals)
241
+ if with_globals:
242
+ parser.add_argument("-s", "--store", default=None,
243
+ help="one-off store override: a file path or "
244
+ "swarm:NAME (default: the configured store; "
245
+ "see `odag-fs set`)")
246
+ parser.add_argument("--bee-api", default=None,
247
+ help="Bee API URL for the bytestore (default: "
248
+ "$BEE_API, configured bee_api, or localhost)")
249
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
250
+
251
+ p = sub.add_parser("ls", help="list a concept directory")
252
+ p.add_argument("path", nargs="?", default=".")
253
+ p.add_argument("-l", "--long", action="store_true")
254
+ p.set_defaults(func=cmd_ls)
255
+
256
+ p = sub.add_parser("tree", help="recursive listing of the lattice")
257
+ p.add_argument("path", nargs="?", default=".")
258
+ p.add_argument("--depth", type=int, default=4)
259
+ p.set_defaults(func=cmd_tree)
260
+
261
+ p = sub.add_parser("cat", help="print an object's bytes")
262
+ p.add_argument("path")
263
+ p.set_defaults(func=cmd_cat)
264
+
265
+ p = sub.add_parser("info", help="show info for a path")
266
+ p.add_argument("path")
267
+ p.set_defaults(func=cmd_info)
268
+
269
+ p = sub.add_parser("cd", help="change the current directory")
270
+ p.add_argument("path", nargs="?", default="/")
271
+ p.set_defaults(func=cmd_cd)
272
+
273
+ p = sub.add_parser("pwd", help="print the current directory")
274
+ p.set_defaults(func=cmd_pwd)
275
+
276
+ p = sub.add_parser("mount", help="FUSE-mount the view (needs fusepy)")
277
+ p.add_argument("mountpoint")
278
+ p.set_defaults(func=cmd_mount)
279
+
280
+ p = sub.add_parser("set", help="show or change settings (shared with odag)")
281
+ p.add_argument("key", nargs="?")
282
+ p.add_argument("value", nargs="?")
283
+ p.set_defaults(func=cmd_set)
284
+
285
+ p = sub.add_parser("help", help="show help")
286
+ p.set_defaults(func=cmd_help)
287
+
288
+ return parser
289
+
290
+
291
+ _LINE_PARSER = _build_command_parser(with_globals=False)
292
+
293
+
294
+ def dispatch(tokens: list[str], session: Session) -> int:
295
+ """Parse one command line and run it. Returns a process-style exit code."""
296
+ try:
297
+ args = _LINE_PARSER.parse_args(tokens)
298
+ except SystemExit as exc: # argparse handled --help or a usage error
299
+ return exc.code or 0
300
+ if args.command is None:
301
+ return 0
302
+ try:
303
+ args.func(session, args)
304
+ return 0
305
+ except (ValueError, OSError) as exc:
306
+ print(f"odag-fs: {exc}", file=sys.stderr)
307
+ return 1
308
+
309
+
310
+ # ----------------------------------------------- interactive / batch modes
311
+
312
+
313
+ def run_stream(session: Session, stream, interactive: bool) -> int:
314
+ """Read commands line by line — `>` prompt on a tty, silently from a
315
+ pipe. Errors are reported and the loop continues (odag convention);
316
+ the exit code says whether every line succeeded."""
317
+ if interactive:
318
+ print(f"odag-fs {__version__} - type help for help")
319
+ failed = False
320
+ while True:
321
+ if interactive:
322
+ try:
323
+ line = input(f"{session.cwd}> " if session.cwd != "/" else "> ")
324
+ except EOFError:
325
+ print()
326
+ break
327
+ else:
328
+ line = stream.readline()
329
+ if not line:
330
+ break
331
+ line = line.strip()
332
+ if not line or line.startswith("#"):
333
+ continue
334
+ try:
335
+ tokens = shlex.split(line)
336
+ except ValueError as exc:
337
+ print(f"odag-fs: {exc}", file=sys.stderr)
338
+ failed = True
339
+ continue
340
+ if tokens[0] in ("quit", "exit"):
341
+ break
342
+ if dispatch(tokens, session) != 0:
343
+ failed = True
344
+ return 1 if failed and not interactive else 0
345
+
346
+
347
+ # ------------------------------------------------------------- entry point
348
+
349
+
350
+ def main(argv=None) -> None:
351
+ parser = _build_command_parser(with_globals=True)
352
+ args = parser.parse_args(argv)
353
+ session = Session(getattr(args, "store", None), getattr(args, "bee_api", None))
354
+
355
+ if args.command is None:
356
+ sys.exit(run_stream(session, sys.stdin, interactive=sys.stdin.isatty()))
357
+
358
+ code = 0
359
+ try:
360
+ args.func(session, args)
361
+ except (ValueError, OSError) as exc:
362
+ print(f"odag-fs: {exc}", file=sys.stderr)
363
+ code = 1
364
+ sys.exit(code)
365
+
366
+
367
+ if __name__ == "__main__":
368
+ main()