ontodag 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ontodag/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ from ontodag.dag import DAG, OntoDAG, Item, OntoDAGVisualizer
2
+
3
+
4
+ def __getattr__(name):
5
+ # OWL support needs owlready2; import it only when actually used so the
6
+ # core data structure works without it.
7
+ if name == "OWLOntology":
8
+ from ontodag.owl import OWLOntology
9
+
10
+ return OWLOntology
11
+ # The Swarm adapter is optional persistence; keep plain `import ontodag`
12
+ # free of it (tests/test_boundaries.py, B1).
13
+ if name == "SwarmOntoDAG":
14
+ from ontodag.swarm_adapter import SwarmOntoDAG
15
+
16
+ return SwarmOntoDAG
17
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
ontodag/__main__.py ADDED
@@ -0,0 +1,590 @@
1
+ """odag — a Unix-style command line for OntoDAG.
2
+
3
+ (The command is `odag`, not `od`: `od` is the standard octal-dump utility.)
4
+
5
+ Design goals (see the module docstring history in CLAUDE.md):
6
+ * silent on success, errors on stderr with a non-zero exit code;
7
+ * a persistent default store in ~/.ontodag so `odag put cat` / `odag get cat`
8
+ work with no file argument;
9
+ * stdin / stdout / pipes: `odag` with no command reads commands from a pipe,
10
+ or drops into an interactive prompt on a tty;
11
+ * `-o FILE` redirects query output; `-f STORE` picks the store for one run;
12
+ * `set store PATH` changes the persistent default.
13
+
14
+ The core (`ontodag.dag`) has no heavy dependencies, so the common native-store
15
+ path imports nothing else; OWL/Manchester support and the visualizer are
16
+ imported lazily only when a command actually needs them.
17
+ """
18
+
19
+ import argparse
20
+ import os
21
+ import shlex
22
+ import sys
23
+
24
+ from ontodag.dag import OntoDAG, Item
25
+
26
+ try:
27
+ from importlib.metadata import version, PackageNotFoundError
28
+ try:
29
+ __version__ = version("ontodag")
30
+ except PackageNotFoundError:
31
+ __version__ = "0.1.0"
32
+ except Exception: # pragma: no cover - importlib.metadata always present on 3.8+
33
+ __version__ = "0.1.0"
34
+
35
+
36
+ # --------------------------------------------------------------------------- #
37
+ # Home directory, config and store resolution
38
+ # --------------------------------------------------------------------------- #
39
+
40
+ def _home_dir():
41
+ return os.environ.get("ONTODAG_HOME") or os.path.join(
42
+ os.path.expanduser("~"), ".ontodag"
43
+ )
44
+
45
+
46
+ def _config_path():
47
+ return os.path.join(_home_dir(), "config")
48
+
49
+
50
+ def _read_config():
51
+ cfg = {}
52
+ path = _config_path()
53
+ if not os.path.exists(path):
54
+ return cfg
55
+ with open(path) as fh:
56
+ for line in fh:
57
+ line = line.strip()
58
+ if not line or line.startswith("#") or "=" not in line:
59
+ continue
60
+ key, _, value = line.partition("=")
61
+ cfg[key.strip()] = value.strip()
62
+ return cfg
63
+
64
+
65
+ def _write_config(cfg):
66
+ os.makedirs(_home_dir(), exist_ok=True)
67
+ with open(_config_path(), "w") as fh:
68
+ for key in sorted(cfg):
69
+ fh.write(f"{key} = {cfg[key]}\n")
70
+
71
+
72
+ def _abspath(path):
73
+ return os.path.abspath(os.path.expanduser(path))
74
+
75
+
76
+ def _is_swarm(spec):
77
+ return spec.startswith("swarm:")
78
+
79
+
80
+ def _normalize_spec(spec):
81
+ """A store spec is either a `swarm:NAME` URI or a filesystem path.
82
+
83
+ Swarm specs are kept verbatim; file paths are made absolute so a spec
84
+ saved to config resolves the same from any working directory."""
85
+ return spec if _is_swarm(spec) else _abspath(spec)
86
+
87
+
88
+ def _resolve_store(override):
89
+ """Store precedence: -f flag > $ONTODAG_STORE > config > default."""
90
+ if override:
91
+ return _normalize_spec(override)
92
+ env = os.environ.get("ONTODAG_STORE")
93
+ if env:
94
+ return _normalize_spec(env)
95
+ cfg = _read_config()
96
+ if cfg.get("store"):
97
+ return _normalize_spec(cfg["store"])
98
+ return os.path.join(_home_dir(), "store.od")
99
+
100
+
101
+ # --------------------------------------------------------------------------- #
102
+ # Serialization: native line format by default, OWL/Manchester by extension
103
+ # --------------------------------------------------------------------------- #
104
+
105
+ def _detect_format(path):
106
+ ext = os.path.splitext(path)[1].lower()
107
+ if ext == ".omn":
108
+ return "manchester"
109
+ if ext == ".owl":
110
+ return "owl"
111
+ return "native"
112
+
113
+
114
+ def _load_native(path):
115
+ """Read the native store: one line per node, `name parent1 parent2 ...`.
116
+
117
+ A missing file is an empty DAG (the default store need not exist yet).
118
+ The format is canonical (nodes and parents sorted on save) and the graph
119
+ is rebuilt via add_edge, so even a hand-edited, non-reduced file loads as
120
+ its unique transitive reduction.
121
+ """
122
+ dag = OntoDAG()
123
+ if not os.path.exists(path):
124
+ return dag
125
+ edges = []
126
+ with open(path) as fh:
127
+ for line in fh:
128
+ line = line.strip()
129
+ if not line or line.startswith("#"):
130
+ continue
131
+ tokens = shlex.split(line)
132
+ name = tokens[0]
133
+ if name not in dag.nodes:
134
+ dag.add_node(Item(name))
135
+ for parent in tokens[1:]:
136
+ if parent not in dag.nodes:
137
+ dag.add_node(Item(parent))
138
+ edges.append((parent, name))
139
+ for parent, child in edges:
140
+ dag.add_edge(dag.nodes[parent], dag.nodes[child])
141
+ return dag
142
+
143
+
144
+ def _save_native(dag, path):
145
+ lines = ["# ontodag store v1"]
146
+ for name in sorted(dag.nodes):
147
+ if name == dag.root.name:
148
+ continue
149
+ node = dag.nodes[name]
150
+ parents = sorted(
151
+ p.name for p in node.parents if dag.nodes.get(p.name) is p
152
+ )
153
+ lines.append(" ".join(shlex.quote(t) for t in [name] + parents))
154
+ with open(path, "w") as fh:
155
+ fh.write("\n".join(lines) + "\n")
156
+
157
+
158
+ def _load(path):
159
+ fmt = _detect_format(path)
160
+ if fmt == "native":
161
+ return _load_native(path)
162
+ from ontodag.owl import OWLOntology
163
+ if fmt == "manchester":
164
+ return OWLOntology.import_dag_manchester(file_name=path)
165
+ return OWLOntology(f"file://{_abspath(path)}").import_dag(file_name=path)
166
+
167
+
168
+ def _save(dag, path):
169
+ parent = os.path.dirname(path)
170
+ if parent:
171
+ os.makedirs(parent, exist_ok=True)
172
+ fmt = _detect_format(path)
173
+ if fmt == "native":
174
+ _save_native(dag, path)
175
+ return
176
+ from ontodag.owl import OWLOntology
177
+ if fmt == "manchester":
178
+ OWLOntology.export_dag_manchester(dag, path)
179
+ else:
180
+ OWLOntology.export_dag(dag, path)
181
+
182
+
183
+ # --------------------------------------------------------------------------- #
184
+ # Storage backends
185
+ #
186
+ # A backend hides *where* the store lives behind load()/save(dag)/describe().
187
+ # The default is a local file (native/OWL/Manchester by extension). A
188
+ # `swarm:NAME` spec persists through the SwarmOntoDAG adapter over a
189
+ # RecordStore: content blobs on a Bee node, the mutable "latest root" in a
190
+ # local FilePointer (no signing key needed). recordstore and the adapter are
191
+ # imported lazily here, so `import ontodag` and the native path stay
192
+ # dependency-free (tests/test_boundaries.py B1).
193
+ # --------------------------------------------------------------------------- #
194
+
195
+ class FileBackend:
196
+ def __init__(self, path):
197
+ self.path = path
198
+
199
+ def load(self):
200
+ return _load(self.path)
201
+
202
+ def save(self, dag):
203
+ _save(dag, self.path)
204
+
205
+ def describe(self):
206
+ return self.path
207
+
208
+
209
+ class SwarmBackend:
210
+ def __init__(self, name, store_factory=None):
211
+ if not name:
212
+ raise ValueError("swarm store needs a name, e.g. swarm:mydag")
213
+ if os.sep in name or (os.altsep and os.altsep in name) or name == "..":
214
+ raise ValueError(f"invalid swarm store name: {name!r}")
215
+ self.name = name
216
+ # Injection seam: tests pass a factory returning a RecordStore over an
217
+ # in-memory bytes store, exercising the whole wiring without a node.
218
+ self._store_factory = store_factory
219
+
220
+ def pointer_path(self):
221
+ return os.path.join(_home_dir(), self.name + ".root")
222
+
223
+ def _record_store(self):
224
+ if self._store_factory is not None:
225
+ return self._store_factory()
226
+ cfg = _read_config()
227
+ api = os.environ.get("BEE_API") or cfg.get("bee_api") or "http://localhost:1633"
228
+ batch = os.environ.get("BEE_BATCH") or cfg.get("bee_batch") or ""
229
+ try:
230
+ # BeeBytesStore imports `requests` in its constructor, so a missing
231
+ # optional dependency surfaces here rather than at module import.
232
+ from recordstore import RecordStore, BeeBytesStore, FilePointer
233
+ os.makedirs(_home_dir(), exist_ok=True)
234
+ return RecordStore(BeeBytesStore(api, batch),
235
+ pointer=FilePointer(self.pointer_path()))
236
+ except ImportError as exc:
237
+ missing = exc.name or "requests"
238
+ raise ValueError(
239
+ f"the swarm backend needs an optional dependency that is not "
240
+ f"installed ({missing!r}); install the swarm extra with: "
241
+ f"pip install -e \".[swarm]\" (or: pip install requests)"
242
+ ) from exc
243
+
244
+ def load(self):
245
+ from ontodag.swarm_adapter import SwarmOntoDAG
246
+ return SwarmOntoDAG(self._record_store())
247
+
248
+ def save(self, dag):
249
+ dag.commit()
250
+
251
+ def describe(self):
252
+ return f"swarm:{self.name}"
253
+
254
+
255
+ def _make_backend(spec):
256
+ if _is_swarm(spec):
257
+ return SwarmBackend(spec[len("swarm:"):])
258
+ return FileBackend(spec)
259
+
260
+
261
+ # --------------------------------------------------------------------------- #
262
+ # The in-memory session (the loaded store)
263
+ # --------------------------------------------------------------------------- #
264
+
265
+ class Session:
266
+ def __init__(self, spec):
267
+ self.switch(spec)
268
+
269
+ def switch(self, spec):
270
+ self.spec = spec
271
+ self.backend = _make_backend(spec)
272
+ self.dag = self.backend.load()
273
+
274
+ def save(self):
275
+ self.backend.save(self.dag)
276
+
277
+ def describe(self):
278
+ return self.backend.describe()
279
+
280
+ def import_from(self, incoming):
281
+ """Replace the store's contents with `incoming`, in place.
282
+
283
+ Mutating the live DAG (rather than rebinding self.dag) keeps a
284
+ SwarmOntoDAG's identity, so its commit() still diffs against what it
285
+ hydrated. Works for either backend via the public API alone: clearing
286
+ to the root then merging reproduces `incoming` exactly (remove
287
+ reconnects children upward, never deletes siblings)."""
288
+ for name in list(self.dag.nodes):
289
+ if name != self.dag.root.name and name in self.dag.nodes:
290
+ self.dag.remove(name)
291
+ self.dag.merge(incoming)
292
+ self.save()
293
+
294
+
295
+ # --------------------------------------------------------------------------- #
296
+ # Command handlers — (args, session, out); silent on success
297
+ # --------------------------------------------------------------------------- #
298
+
299
+ def _print_dag(dag, out):
300
+ for node in dag.topological_sort():
301
+ children = sorted(n.name for n in node.neighbors)
302
+ if node.name == dag.root.name:
303
+ print(f"{node.name} [root] -> {' '.join(children)}".rstrip(), file=out)
304
+ else:
305
+ parents = sorted(
306
+ p.name for p in node.parents if dag.nodes.get(p.name) is p
307
+ )
308
+ print(f"{node.name} ({' '.join(parents)}) -> {' '.join(children)}".rstrip(),
309
+ file=out)
310
+
311
+
312
+ def cmd_put(args, session, out):
313
+ session.dag.put(args.item, args.parents, optimized=args.optimized)
314
+ session.save()
315
+
316
+
317
+ def cmd_get(args, session, out):
318
+ for name in sorted(item.name for item in session.dag.get(args.categories)):
319
+ print(name, file=out)
320
+
321
+
322
+ def cmd_remove(args, session, out):
323
+ session.dag.remove(args.item)
324
+ session.save()
325
+
326
+
327
+ def cmd_show(args, session, out):
328
+ _print_dag(session.dag, out)
329
+
330
+
331
+ def cmd_list(args, session, out):
332
+ for name in sorted(n for n in session.dag.nodes if n != session.dag.root.name):
333
+ print(name, file=out)
334
+
335
+
336
+ def cmd_merge(args, session, out):
337
+ session.dag.merge(_load(args.file))
338
+ session.save()
339
+
340
+
341
+ def cmd_import(args, session, out):
342
+ session.import_from(_load(args.file))
343
+
344
+
345
+ def cmd_export(args, session, out):
346
+ _save(session.dag, args.file)
347
+
348
+
349
+ def cmd_visualize(args, session, out):
350
+ from ontodag.dag import OntoDAGVisualizer
351
+ base = args.out or os.path.splitext(session.path)[0]
352
+ OntoDAGVisualizer(format=args.format).visualize(session.dag, filename=base)
353
+
354
+
355
+ # Settings `set` can show and change. `store` is the active store spec;
356
+ # bee_api/bee_batch configure the Swarm backend's Bee node.
357
+ _SETTINGS = ("store", "bee_api", "bee_batch")
358
+
359
+
360
+ def _effective_setting(session, key):
361
+ """The value currently in effect, honoring env/config precedence."""
362
+ if key == "store":
363
+ return session.describe()
364
+ cfg = _read_config()
365
+ if key == "bee_api":
366
+ return os.environ.get("BEE_API") or cfg.get("bee_api") or "http://localhost:1633"
367
+ if key == "bee_batch":
368
+ return os.environ.get("BEE_BATCH") or cfg.get("bee_batch") or ""
369
+ return cfg.get(key, "")
370
+
371
+
372
+ def cmd_set(args, session, out):
373
+ # No key: show every setting. Key but no value: show that one. Both:
374
+ # change it. Displaying on a missing value never errors.
375
+ if not args.key:
376
+ for key in _SETTINGS:
377
+ print(f"{key} = {_effective_setting(session, key)}", file=out)
378
+ return
379
+ if args.key not in _SETTINGS:
380
+ raise ValueError(f"unknown setting: {args.key} "
381
+ f"(known: {', '.join(_SETTINGS)})")
382
+ if args.value is None:
383
+ print(f"{args.key} = {_effective_setting(session, args.key)}", file=out)
384
+ return
385
+ if args.key == "store":
386
+ spec = _normalize_spec(args.value)
387
+ cfg = _read_config()
388
+ cfg["store"] = spec
389
+ _write_config(cfg)
390
+ session.switch(spec)
391
+ else:
392
+ cfg = _read_config()
393
+ cfg[args.key] = args.value
394
+ _write_config(cfg)
395
+
396
+
397
+ HELP_TEXT = """\
398
+ Usage: odag [-f STORE] <command> [args]
399
+
400
+ Commands:
401
+ put SUB [PARENT...] add SUB under the PARENT categories (or the root)
402
+ get CAT [CAT...] print items below all of the CATs, one per line
403
+ remove ITEM remove ITEM from the store
404
+ show print the DAG structure
405
+ list print every item name
406
+ merge FILE merge FILE into the store
407
+ import FILE replace the store with the contents of FILE
408
+ export FILE write the store to FILE
409
+ visualize [--out B] render the DAG to an image
410
+ set [KEY [VALUE]] show settings, or set one (store, bee_api, bee_batch)
411
+ help show this help
412
+
413
+ With no command odag reads commands from a pipe, or opens an interactive
414
+ prompt on a terminal. Files ending in .owl/.omn use OWL/Manchester syntax;
415
+ any other path is the native line format.
416
+
417
+ A store may also be `swarm:NAME`, persisted on Ethereum Swarm (content on a
418
+ Bee node, latest root in ~/.ontodag/NAME.root). `set store swarm:NAME` makes
419
+ it the default, so every later command uses Swarm. Needs the swarm extra
420
+ (`pip install -e ".[swarm]"`). Configure the node with $BEE_API / $BEE_BATCH
421
+ or `bee_api` / `bee_batch` in ~/.ontodag/config.
422
+
423
+ The store can also be browsed as a filesystem (paths as category queries,
424
+ FUSE-mountable) with `odag-fs`, which shares these settings — see
425
+ https://github.com/petfold/ontodag-fs.
426
+
427
+ Options:
428
+ -f, --store PATH use PATH (or swarm:NAME) as the store for this run
429
+ -o, --output FILE write output to FILE instead of stdout (get/show/list)
430
+ """
431
+
432
+
433
+ def cmd_help(args, session, out):
434
+ print(HELP_TEXT, file=out, end="")
435
+
436
+
437
+ # --------------------------------------------------------------------------- #
438
+ # Parser
439
+ # --------------------------------------------------------------------------- #
440
+
441
+ def build_parser():
442
+ parser = argparse.ArgumentParser(prog="odag", add_help=False,
443
+ description="Manipulate an OntoDAG store.")
444
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
445
+ sub.required = True
446
+
447
+ p = sub.add_parser("put", add_help=True, help="add an item")
448
+ p.add_argument("item")
449
+ p.add_argument("parents", nargs="*")
450
+ p.add_argument("--optimized", action="store_true",
451
+ help="infer most-specific parents")
452
+ p.set_defaults(func=cmd_put)
453
+
454
+ p = sub.add_parser("get", add_help=True, help="query common subcategories")
455
+ p.add_argument("categories", nargs="+")
456
+ p.add_argument("-o", "--output")
457
+ p.set_defaults(func=cmd_get, stream_output=True)
458
+
459
+ p = sub.add_parser("remove", add_help=True, help="remove an item")
460
+ p.add_argument("item")
461
+ p.set_defaults(func=cmd_remove)
462
+
463
+ p = sub.add_parser("show", add_help=True, help="print the DAG structure")
464
+ p.add_argument("-o", "--output")
465
+ p.set_defaults(func=cmd_show, stream_output=True)
466
+
467
+ p = sub.add_parser("list", add_help=True, help="print all item names")
468
+ p.add_argument("-o", "--output")
469
+ p.set_defaults(func=cmd_list, stream_output=True)
470
+
471
+ p = sub.add_parser("merge", add_help=True, help="merge a file into the store")
472
+ p.add_argument("file")
473
+ p.set_defaults(func=cmd_merge)
474
+
475
+ p = sub.add_parser("import", add_help=True, help="replace the store with a file")
476
+ p.add_argument("file")
477
+ p.set_defaults(func=cmd_import)
478
+
479
+ p = sub.add_parser("export", add_help=True, help="write the store to a file")
480
+ p.add_argument("file")
481
+ p.set_defaults(func=cmd_export)
482
+
483
+ p = sub.add_parser("visualize", add_help=True, help="render an image")
484
+ p.add_argument("--out", help="output filename without extension")
485
+ p.add_argument("--format", default="png", choices=["png", "svg", "pdf"])
486
+ p.set_defaults(func=cmd_visualize)
487
+
488
+ p = sub.add_parser("set", add_help=True,
489
+ help="show settings, or change one")
490
+ p.add_argument("key", nargs="?")
491
+ p.add_argument("value", nargs="?")
492
+ p.set_defaults(func=cmd_set)
493
+
494
+ p = sub.add_parser("help", add_help=True, help="show help")
495
+ p.set_defaults(func=cmd_help)
496
+
497
+ return parser
498
+
499
+
500
+ PARSER = build_parser()
501
+
502
+
503
+ def dispatch(argv, session):
504
+ """Parse one command line and run it. Returns a process-style exit code."""
505
+ try:
506
+ args = PARSER.parse_args(argv)
507
+ except SystemExit as exc: # argparse handled --help or a usage error
508
+ return exc.code or 0
509
+
510
+ out = sys.stdout
511
+ handle = None
512
+ outpath = getattr(args, "output", None)
513
+ try:
514
+ if outpath and getattr(args, "stream_output", False):
515
+ handle = open(outpath, "w")
516
+ out = handle
517
+ args.func(args, session, out)
518
+ return 0
519
+ except (ValueError, OSError) as exc:
520
+ print(f"odag: {exc}", file=sys.stderr)
521
+ return 1
522
+ finally:
523
+ if handle is not None:
524
+ handle.close()
525
+
526
+
527
+ # --------------------------------------------------------------------------- #
528
+ # Interactive and batch (stdin) modes
529
+ # --------------------------------------------------------------------------- #
530
+
531
+ def _run_stream(session, stream, interactive):
532
+ if interactive:
533
+ print(f"Ontodag {__version__} - type help for help")
534
+ while True:
535
+ if interactive:
536
+ try:
537
+ line = input("> ")
538
+ except EOFError:
539
+ print()
540
+ break
541
+ else:
542
+ line = stream.readline()
543
+ if not line:
544
+ break
545
+ line = line.strip()
546
+ if not line or line.startswith("#"):
547
+ continue
548
+ try:
549
+ tokens = shlex.split(line)
550
+ except ValueError as exc:
551
+ print(f"odag: {exc}", file=sys.stderr)
552
+ continue
553
+ if tokens[0] in ("quit", "exit"):
554
+ break
555
+ dispatch(tokens, session)
556
+
557
+
558
+ # --------------------------------------------------------------------------- #
559
+ # Entry point
560
+ # --------------------------------------------------------------------------- #
561
+
562
+ def main(argv=None):
563
+ argv = list(sys.argv[1:] if argv is None else argv)
564
+
565
+ store_override = None
566
+ while argv and argv[0] in ("-f", "--store", "--file"):
567
+ if len(argv) < 2:
568
+ print("odag: option requires a path", file=sys.stderr)
569
+ sys.exit(2)
570
+ store_override = argv[1]
571
+ argv = argv[2:]
572
+
573
+ if argv and argv[0] in ("-V", "--version"):
574
+ print(__version__)
575
+ sys.exit(0)
576
+ if argv and argv[0] in ("-h", "--help"):
577
+ sys.stdout.write(HELP_TEXT)
578
+ sys.exit(0)
579
+
580
+ session = Session(_resolve_store(store_override))
581
+
582
+ if not argv:
583
+ _run_stream(session, sys.stdin, interactive=sys.stdin.isatty())
584
+ sys.exit(0)
585
+
586
+ sys.exit(dispatch(argv, session))
587
+
588
+
589
+ if __name__ == "__main__":
590
+ main()