graph-lcb 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.
- cli/__init__.py +244 -0
- commands/__init__.py +1 -0
- commands/admin.py +103 -0
- commands/common.py +167 -0
- commands/mutations.py +85 -0
- commands/queries.py +69 -0
- commands/records.py +129 -0
- commands/visualization-templates/graphs.template.html +821 -0
- commands/visualization.py +44 -0
- graph_cli.py +15 -0
- graph_lcb-0.1.0.dist-info/METADATA +605 -0
- graph_lcb-0.1.0.dist-info/RECORD +21 -0
- graph_lcb-0.1.0.dist-info/WHEEL +5 -0
- graph_lcb-0.1.0.dist-info/entry_points.txt +2 -0
- graph_lcb-0.1.0.dist-info/licenses/LICENSE +21 -0
- graph_lcb-0.1.0.dist-info/top_level.txt +5 -0
- graphs/engine.py +754 -0
- graphs/types/__init__.py +105 -0
- schemas/__init__.py +1 -0
- schemas/edges.schema.json +91 -0
- schemas/entities.schema.json +76 -0
cli/__init__.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""A small, dependency-free command-line interface helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TypeVar, overload
|
|
11
|
+
|
|
12
|
+
__all__ = ["CLIError", "Context", "CLI"]
|
|
13
|
+
|
|
14
|
+
_MISSING = object()
|
|
15
|
+
_COMMAND_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z")
|
|
16
|
+
_FLAG_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_-]*\Z")
|
|
17
|
+
_T = TypeVar("_T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CLIError(ValueError):
|
|
21
|
+
"""An expected command-line usage error."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Context:
|
|
25
|
+
"""Arguments and options for one command invocation."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
command: str,
|
|
30
|
+
positionals: Sequence[str],
|
|
31
|
+
options: Sequence[tuple[str, str | bool]],
|
|
32
|
+
) -> None:
|
|
33
|
+
self.command = command
|
|
34
|
+
self.args = tuple(positionals)
|
|
35
|
+
self._options = tuple(options)
|
|
36
|
+
|
|
37
|
+
@overload
|
|
38
|
+
def positional(self, index: int) -> str: ...
|
|
39
|
+
|
|
40
|
+
@overload
|
|
41
|
+
def positional(self, index: int, default: _T) -> str | _T: ...
|
|
42
|
+
|
|
43
|
+
def positional(self, index: int, default: object = _MISSING) -> str | object:
|
|
44
|
+
"""Return a positional argument, or its default if one was provided."""
|
|
45
|
+
if index < 0:
|
|
46
|
+
raise ValueError("positional indexes must not be negative")
|
|
47
|
+
if index < len(self.args):
|
|
48
|
+
return self.args[index]
|
|
49
|
+
if default is not _MISSING:
|
|
50
|
+
return default
|
|
51
|
+
raise CLIError(f"missing positional argument {index + 1}")
|
|
52
|
+
|
|
53
|
+
@overload
|
|
54
|
+
def flag(self, name: str, default: None = None) -> str | bool | None: ...
|
|
55
|
+
|
|
56
|
+
@overload
|
|
57
|
+
def flag(self, name: str, default: _T = ...) -> str | bool | _T: ...
|
|
58
|
+
|
|
59
|
+
def flag(
|
|
60
|
+
self,
|
|
61
|
+
name: str,
|
|
62
|
+
default: object = None,
|
|
63
|
+
) -> str | bool | object:
|
|
64
|
+
"""Return an option's value by its long name."""
|
|
65
|
+
normalized_name = name.removeprefix("--").removeprefix("-")
|
|
66
|
+
if not _FLAG_PATTERN.fullmatch(normalized_name):
|
|
67
|
+
raise ValueError(f"invalid flag name: {name!r}")
|
|
68
|
+
|
|
69
|
+
for option_name, value in reversed(self._options):
|
|
70
|
+
if option_name == normalized_name:
|
|
71
|
+
return value
|
|
72
|
+
return default
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class _Handler:
|
|
77
|
+
description: str
|
|
78
|
+
callback: Callable[[Context], int | None]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CLI:
|
|
82
|
+
"""Register commands and dispatch arguments without third-party packages."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, prog: str | None = None) -> None:
|
|
85
|
+
self.prog = prog
|
|
86
|
+
self._handlers: dict[str, _Handler] = {}
|
|
87
|
+
self._before_run_callbacks: list[Callable[[], None]] = []
|
|
88
|
+
|
|
89
|
+
@overload
|
|
90
|
+
def before_run(self, callback: Callable[[], None]) -> Callable[[], None]: ...
|
|
91
|
+
|
|
92
|
+
@overload
|
|
93
|
+
def before_run(self) -> Callable[[Callable[[], None]], Callable[[], None]]: ...
|
|
94
|
+
|
|
95
|
+
def before_run(
|
|
96
|
+
self, callback: Callable[[], None] | None = None
|
|
97
|
+
) -> Callable[[], None] | Callable[[Callable[[], None]], Callable[[], None]]:
|
|
98
|
+
"""Register a no-argument callback to run before a command handler.
|
|
99
|
+
|
|
100
|
+
Supports both ``@cli.before_run`` and ``@cli.before_run()``.
|
|
101
|
+
Callbacks run in registration order after command selection and argument
|
|
102
|
+
parsing, so help and usage paths do not trigger command setup.
|
|
103
|
+
"""
|
|
104
|
+
def register(function: Callable[[], None]) -> Callable[[], None]:
|
|
105
|
+
self._before_run_callbacks.append(function)
|
|
106
|
+
return function
|
|
107
|
+
|
|
108
|
+
return register(callback) if callback is not None else register
|
|
109
|
+
|
|
110
|
+
def new_handler(
|
|
111
|
+
self,
|
|
112
|
+
name: str,
|
|
113
|
+
description: str,
|
|
114
|
+
handler: Callable[[Context], int | None],
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Register a command and the function that handles it."""
|
|
117
|
+
if not _COMMAND_PATTERN.fullmatch(name):
|
|
118
|
+
raise ValueError(f"invalid command name: {name!r}")
|
|
119
|
+
if name in {"help"} or name in self._handlers:
|
|
120
|
+
raise ValueError(f"command already registered or reserved: {name!r}")
|
|
121
|
+
self._handlers[name] = _Handler(description, handler)
|
|
122
|
+
|
|
123
|
+
def handler(
|
|
124
|
+
self, name: str, description: str
|
|
125
|
+
) -> Callable[[Callable[[Context], int | None]], Callable[[Context], int | None]]:
|
|
126
|
+
"""Register a command using a decorator and return its handler unchanged."""
|
|
127
|
+
|
|
128
|
+
def register(
|
|
129
|
+
callback: Callable[[Context], int | None],
|
|
130
|
+
) -> Callable[[Context], int | None]:
|
|
131
|
+
self.new_handler(name, description, callback)
|
|
132
|
+
return callback
|
|
133
|
+
|
|
134
|
+
return register
|
|
135
|
+
|
|
136
|
+
def run(self, argv: Sequence[str] | None = None) -> int:
|
|
137
|
+
"""Run the CLI with arguments, defaulting to ``sys.argv[1:]``."""
|
|
138
|
+
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
139
|
+
program = self.prog or os.path.basename(sys.argv[0]) or "cli"
|
|
140
|
+
|
|
141
|
+
if not arguments or arguments[0] in {"-h", "--help", "help"}:
|
|
142
|
+
if arguments and arguments[0] == "help":
|
|
143
|
+
if len(arguments) > 2:
|
|
144
|
+
return self._usage_error("help accepts at most one command", program)
|
|
145
|
+
if len(arguments) == 2:
|
|
146
|
+
return self._show_command_help(arguments[1], program)
|
|
147
|
+
self._show_help(program)
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
command = arguments[0]
|
|
151
|
+
registered = self._handlers.get(command)
|
|
152
|
+
if registered is None:
|
|
153
|
+
return self._usage_error(f"unknown command: {command}", program)
|
|
154
|
+
command_arguments = arguments[1:]
|
|
155
|
+
help_arguments = (
|
|
156
|
+
command_arguments[: command_arguments.index("--")]
|
|
157
|
+
if "--" in command_arguments
|
|
158
|
+
else command_arguments
|
|
159
|
+
)
|
|
160
|
+
if any(argument in {"-h", "--help"} for argument in help_arguments):
|
|
161
|
+
self._show_command_help(command, program)
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
positionals, options = self._parse_arguments(command_arguments)
|
|
166
|
+
for callback in self._before_run_callbacks:
|
|
167
|
+
callback()
|
|
168
|
+
result = registered.callback(Context(command, positionals, options))
|
|
169
|
+
except CLIError as error:
|
|
170
|
+
return self._usage_error(str(error), program, command)
|
|
171
|
+
|
|
172
|
+
return 0 if result is None else result
|
|
173
|
+
|
|
174
|
+
def _parse_arguments(
|
|
175
|
+
self, arguments: Sequence[str]
|
|
176
|
+
) -> tuple[list[str], list[tuple[str, str | bool]]]:
|
|
177
|
+
positionals: list[str] = []
|
|
178
|
+
options: list[tuple[str, str | bool]] = []
|
|
179
|
+
parse_options = True
|
|
180
|
+
index = 0
|
|
181
|
+
|
|
182
|
+
while index < len(arguments):
|
|
183
|
+
argument = arguments[index]
|
|
184
|
+
if parse_options and argument == "--":
|
|
185
|
+
parse_options = False
|
|
186
|
+
index += 1
|
|
187
|
+
continue
|
|
188
|
+
if not parse_options or argument == "-" or not argument.startswith("-"):
|
|
189
|
+
positionals.append(argument)
|
|
190
|
+
index += 1
|
|
191
|
+
continue
|
|
192
|
+
if len(argument) > 1 and argument[1].isdigit():
|
|
193
|
+
positionals.append(argument)
|
|
194
|
+
index += 1
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
if not argument.startswith("--"):
|
|
198
|
+
raise CLIError(f"short options are not supported: {argument}")
|
|
199
|
+
option_name, equals, attached_value = argument[2:].partition("=")
|
|
200
|
+
if not option_name:
|
|
201
|
+
raise CLIError("empty option name")
|
|
202
|
+
if equals:
|
|
203
|
+
options.append((option_name, attached_value))
|
|
204
|
+
index += 1
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
if index + 1 < len(arguments) and not arguments[index + 1].startswith("-"):
|
|
208
|
+
options.append((option_name, arguments[index + 1]))
|
|
209
|
+
index += 2
|
|
210
|
+
else:
|
|
211
|
+
options.append((option_name, True))
|
|
212
|
+
index += 1
|
|
213
|
+
|
|
214
|
+
return positionals, options
|
|
215
|
+
|
|
216
|
+
def _show_help(self, program: str) -> None:
|
|
217
|
+
print(f"Usage: {program} <command> [arguments]", file=sys.stdout)
|
|
218
|
+
print("\nCommands:", file=sys.stdout)
|
|
219
|
+
if not self._handlers:
|
|
220
|
+
print(" (no commands registered)", file=sys.stdout)
|
|
221
|
+
for name, handler in self._handlers.items():
|
|
222
|
+
print(f" {name:<16} {handler.description}", file=sys.stdout)
|
|
223
|
+
print(f"\nRun '{program} help <command>' for command details.", file=sys.stdout)
|
|
224
|
+
|
|
225
|
+
def _show_command_help(self, command: str, program: str) -> int:
|
|
226
|
+
if command == "help":
|
|
227
|
+
self._show_help(program)
|
|
228
|
+
return 0
|
|
229
|
+
registered = self._handlers.get(command)
|
|
230
|
+
if registered is None:
|
|
231
|
+
return self._usage_error(f"unknown command: {command}", program)
|
|
232
|
+
print(f"Usage: {program} {command} [arguments]", file=sys.stdout)
|
|
233
|
+
if registered.description:
|
|
234
|
+
print(f"\n{registered.description}", file=sys.stdout)
|
|
235
|
+
print("\nOptions: -h, --help Show this help.", file=sys.stdout)
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
def _usage_error(self, message: str, program: str, command: str | None = None) -> int:
|
|
239
|
+
print(f"{program}: error: {message}", file=sys.stderr)
|
|
240
|
+
if command is None:
|
|
241
|
+
print(f"Try '{program} --help' for usage.", file=sys.stderr)
|
|
242
|
+
else:
|
|
243
|
+
print(f"Try '{program} {command} --help' for usage.", file=sys.stderr)
|
|
244
|
+
return 2
|
commands/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands, grouped by domain."""
|
commands/admin.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from cli import CLIError, Context
|
|
4
|
+
from graphs.engine import Graph, GraphError, GraphNotInitializedError, InvalidContractError
|
|
5
|
+
from commands.common import cli, emit, starter_contracts
|
|
6
|
+
|
|
7
|
+
@cli.handler("validate", "Validate stored graph records against the current contracts (read-only).")
|
|
8
|
+
def validate_graph(ctx: Context) -> int:
|
|
9
|
+
if ctx.args or ctx._options:
|
|
10
|
+
raise CLIError("validate accepts no arguments or options")
|
|
11
|
+
try:
|
|
12
|
+
result = Graph(allow_invalid_contracts=True).validate()
|
|
13
|
+
except GraphNotInitializedError as exc:
|
|
14
|
+
result = {
|
|
15
|
+
"valid": False,
|
|
16
|
+
"errors": [{"kind": "workspace", "code": "not_initialized", "message": str(exc)}],
|
|
17
|
+
"summary": {"nodes": 0, "edges": 0, "errors": 1},
|
|
18
|
+
"unavailable": True,
|
|
19
|
+
}
|
|
20
|
+
except InvalidContractError as exc:
|
|
21
|
+
result = {
|
|
22
|
+
"valid": False,
|
|
23
|
+
"errors": [{"kind": "contract", "code": "invalid_contract", "message": str(exc)}],
|
|
24
|
+
"summary": {"nodes": 0, "edges": 0, "errors": 1},
|
|
25
|
+
}
|
|
26
|
+
emit(result)
|
|
27
|
+
return 3
|
|
28
|
+
except GraphError as exc:
|
|
29
|
+
result = {
|
|
30
|
+
"valid": False,
|
|
31
|
+
"errors": [{"kind": "contract", "code": "invalid_contract", "message": str(exc)}],
|
|
32
|
+
"summary": {"nodes": 0, "edges": 0, "errors": 1},
|
|
33
|
+
}
|
|
34
|
+
emit(result)
|
|
35
|
+
return 4
|
|
36
|
+
unavailable = result.pop("unavailable", False)
|
|
37
|
+
emit(result)
|
|
38
|
+
return 4 if unavailable else 0 if result["valid"] else 3
|
|
39
|
+
|
|
40
|
+
@cli.handler("contract", "Show entity and edge contracts: contract [entities|edges] [--compact]")
|
|
41
|
+
def show_schema(ctx: Context) -> None:
|
|
42
|
+
if len(ctx.args) > 1:
|
|
43
|
+
raise CLIError("use contract [entities|edges] [--compact]")
|
|
44
|
+
category = ctx.positional(0, "all").lower()
|
|
45
|
+
if category not in {"all", "entity", "entities", "edge", "edges"}:
|
|
46
|
+
raise CLIError("expected 'entities' or 'edges'")
|
|
47
|
+
|
|
48
|
+
contracts_directory = Path(".graph")
|
|
49
|
+
entity_contract_path = contracts_directory / "entities.json"
|
|
50
|
+
edge_contract_path = contracts_directory / "edges.json"
|
|
51
|
+
if (not contracts_directory.exists()
|
|
52
|
+
or (contracts_directory.is_dir()
|
|
53
|
+
and not entity_contract_path.exists()
|
|
54
|
+
and not edge_contract_path.exists())):
|
|
55
|
+
entities, edge_types = starter_contracts()
|
|
56
|
+
else:
|
|
57
|
+
graph = Graph(contracts_directory)
|
|
58
|
+
entities, edge_types = graph.entities, graph.edge_types
|
|
59
|
+
|
|
60
|
+
sections = []
|
|
61
|
+
if category in {"all", "entity", "entities"}:
|
|
62
|
+
sections.append(("entities", entities, False))
|
|
63
|
+
if category in {"all", "edge", "edges"}:
|
|
64
|
+
sections.append(("edges", edge_types, True))
|
|
65
|
+
compact = ctx.flag("compact")
|
|
66
|
+
json_output = ctx.flag("json")
|
|
67
|
+
for option, value in (("--compact", compact), ("--json", json_output)):
|
|
68
|
+
if value is not None and value is not True:
|
|
69
|
+
raise CLIError(f"{option} does not accept a value")
|
|
70
|
+
if not compact:
|
|
71
|
+
if len(sections) == 1:
|
|
72
|
+
emit(sections[0][1])
|
|
73
|
+
else:
|
|
74
|
+
emit({name: types for name, types, _ in sections})
|
|
75
|
+
return
|
|
76
|
+
summaries = []
|
|
77
|
+
for section_name, types, is_edge in sections:
|
|
78
|
+
section_summaries = []
|
|
79
|
+
for name, contract in types.items():
|
|
80
|
+
if name == "$schema":
|
|
81
|
+
continue
|
|
82
|
+
description = contract.get("description", "")
|
|
83
|
+
lines = [f"{name}: {description}" if description else name]
|
|
84
|
+
if is_edge:
|
|
85
|
+
lines.extend((
|
|
86
|
+
f"Source: {', '.join(contract.get('source', []))}",
|
|
87
|
+
f"Target: {', '.join(contract.get('target', []))}",
|
|
88
|
+
))
|
|
89
|
+
lines.append("Properties:")
|
|
90
|
+
for property_name, definition in contract.get("properties", {}).items():
|
|
91
|
+
if isinstance(definition, str):
|
|
92
|
+
property_type, required = definition, False
|
|
93
|
+
else:
|
|
94
|
+
property_type = definition.get("type", "unknown")
|
|
95
|
+
required = definition.get("required") is True
|
|
96
|
+
suffix = "; required" if required else ""
|
|
97
|
+
lines.append(f"- {property_name}: {property_type}{suffix}")
|
|
98
|
+
section_summaries.append("\n".join(lines))
|
|
99
|
+
summary = "\n\n".join(section_summaries)
|
|
100
|
+
if len(sections) > 1:
|
|
101
|
+
summary = f"# {section_name.title()}\n" + summary
|
|
102
|
+
summaries.append(summary)
|
|
103
|
+
print("\n\n".join(summaries))
|
commands/common.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Graph management CLI."""
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from cli import CLI, CLIError, Context
|
|
9
|
+
from graphs.engine import Graph
|
|
10
|
+
from graphs.types import Edge, EdgeResponse, Node
|
|
11
|
+
|
|
12
|
+
cli = CLI("graph")
|
|
13
|
+
|
|
14
|
+
STARTER_ENTITY_CONTRACT = {
|
|
15
|
+
"$schema": "./schemas/entities.schema.json",
|
|
16
|
+
"API": {
|
|
17
|
+
"description": "An API or application service",
|
|
18
|
+
"properties": {
|
|
19
|
+
"language": {"type": "string", "required": True},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
STARTER_EDGE_CONTRACT = {
|
|
24
|
+
"$schema": "./schemas/edges.schema.json",
|
|
25
|
+
"CALLS": {
|
|
26
|
+
"description": "A service synchronously invokes another service or API.",
|
|
27
|
+
"source": ["API"],
|
|
28
|
+
"target": ["API"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"protocol": {"type": "string", "required": False},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def edge_response(edge: Edge, nodes: list[Node]) -> EdgeResponse:
|
|
37
|
+
target = next((node for node in nodes if node.id == edge.target), None)
|
|
38
|
+
return EdgeResponse.from_edge(edge, target.type if target else "")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def json_default(value: object) -> dict:
|
|
42
|
+
if isinstance(value, (Node, Edge, EdgeResponse)):
|
|
43
|
+
return value.to_dict()
|
|
44
|
+
raise TypeError(f"cannot serialize {type(value).__name__}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def emit(value: object) -> None:
|
|
48
|
+
print(json.dumps(value, ensure_ascii=False, default=json_default, separators=(",", ":")))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def initialize_graph_contracts() -> None:
|
|
52
|
+
"""Create missing starter contracts and schemas for the explicit init command."""
|
|
53
|
+
directory = Path(".graph")
|
|
54
|
+
schema_directory = directory / "schemas"
|
|
55
|
+
bundled_schema_directory = Path(__file__).resolve().parents[1] / "schemas"
|
|
56
|
+
try:
|
|
57
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
schema_directory.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
for filename in ("entities.schema.json", "edges.schema.json"):
|
|
60
|
+
source = bundled_schema_directory / filename
|
|
61
|
+
destination = schema_directory / filename
|
|
62
|
+
if not destination.exists():
|
|
63
|
+
shutil.copyfile(source, destination)
|
|
64
|
+
elif not destination.is_file():
|
|
65
|
+
raise CLIError(f"cannot initialize graph schema: {destination} is not a file")
|
|
66
|
+
for filename, contract in (
|
|
67
|
+
("entities.json", STARTER_ENTITY_CONTRACT),
|
|
68
|
+
("edges.json", STARTER_EDGE_CONTRACT),
|
|
69
|
+
):
|
|
70
|
+
path = directory / filename
|
|
71
|
+
if path.exists() and not path.is_file():
|
|
72
|
+
raise CLIError(f"cannot initialize graph contract: {path} is not a file")
|
|
73
|
+
if not path.exists():
|
|
74
|
+
path.write_text(json.dumps(contract) + "\n", encoding="utf-8")
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
raise CLIError(f"cannot initialize graph contracts: {exc}") from exc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def starter_contracts() -> tuple[dict, dict]:
|
|
80
|
+
"""Return fresh starter contracts without the schema metadata."""
|
|
81
|
+
entities = deepcopy(STARTER_ENTITY_CONTRACT)
|
|
82
|
+
edges = deepcopy(STARTER_EDGE_CONTRACT)
|
|
83
|
+
entities.pop("$schema")
|
|
84
|
+
edges.pop("$schema")
|
|
85
|
+
return entities, edges
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@cli.handler("init", "Initialize a graph workspace in the current directory.")
|
|
89
|
+
def init_graph(ctx: Context) -> None:
|
|
90
|
+
if ctx.args or ctx._options:
|
|
91
|
+
raise CLIError("init accepts no arguments or options")
|
|
92
|
+
initialize_graph_contracts()
|
|
93
|
+
emit({"initialized": True, "directory": ".graph"})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def properties(ctx: Context) -> dict:
|
|
97
|
+
return parse_properties(ctx.flag("properties", default="{}")) or {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_properties(raw: object | None) -> dict | None:
|
|
101
|
+
if raw is None:
|
|
102
|
+
return None
|
|
103
|
+
if not isinstance(raw, str):
|
|
104
|
+
raise CLIError("--properties requires a JSON object")
|
|
105
|
+
try:
|
|
106
|
+
value = json.loads(raw)
|
|
107
|
+
except json.JSONDecodeError as exc:
|
|
108
|
+
raise CLIError(f"invalid properties JSON: {exc.msg}") from exc
|
|
109
|
+
if not isinstance(value, dict):
|
|
110
|
+
raise CLIError("--properties must be a JSON object")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_fields(raw: object | None, allowed: set[str]) -> list[str] | None:
|
|
115
|
+
if raw is None:
|
|
116
|
+
return None
|
|
117
|
+
if not isinstance(raw, str) or not raw:
|
|
118
|
+
raise CLIError("--fields requires comma-separated field names")
|
|
119
|
+
fields = [field.strip() for field in raw.split(",")]
|
|
120
|
+
if any(not field for field in fields):
|
|
121
|
+
raise CLIError("--fields requires comma-separated field names")
|
|
122
|
+
unknown = set(fields) - allowed
|
|
123
|
+
if unknown:
|
|
124
|
+
raise CLIError(f"unknown field: {', '.join(sorted(unknown))}")
|
|
125
|
+
return fields
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_edge_types(raw: object | None, graph: Graph) -> set[str] | None:
|
|
129
|
+
if raw is None:
|
|
130
|
+
return None
|
|
131
|
+
if not isinstance(raw, str):
|
|
132
|
+
raise CLIError("--edge requires comma-separated edge types")
|
|
133
|
+
values = {value.strip() for value in raw.split(",")}
|
|
134
|
+
if not values or "" in values:
|
|
135
|
+
raise CLIError("--edge requires comma-separated edge types")
|
|
136
|
+
canonical = {name.casefold(): name for name in graph.edge_types}
|
|
137
|
+
unknown = {value for value in values if value.casefold() not in canonical}
|
|
138
|
+
if unknown:
|
|
139
|
+
raise CLIError(f"unknown edge type: {', '.join(sorted(unknown))}; valid types: {', '.join(sorted(graph.edge_types)) or '(none)'}")
|
|
140
|
+
return {canonical[value.casefold()] for value in values}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def evidences(raw: object) -> list[dict]:
|
|
144
|
+
if not isinstance(raw, str):
|
|
145
|
+
raise CLIError("--evidences requires a JSON array of objects")
|
|
146
|
+
try:
|
|
147
|
+
value = json.loads(raw)
|
|
148
|
+
except json.JSONDecodeError as exc:
|
|
149
|
+
raise CLIError(f"invalid evidences JSON: {exc.msg}") from exc
|
|
150
|
+
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
|
|
151
|
+
raise CLIError("--evidences must be a JSON array of objects")
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def positive_limit(value: object, default: int | None = None) -> int | None:
|
|
156
|
+
"""Parse an optional --limit value, which must be a positive integer."""
|
|
157
|
+
if value is None:
|
|
158
|
+
return default
|
|
159
|
+
if not isinstance(value, str):
|
|
160
|
+
raise CLIError("--limit requires a positive integer")
|
|
161
|
+
try:
|
|
162
|
+
parsed = int(value)
|
|
163
|
+
except ValueError as exc:
|
|
164
|
+
raise CLIError("--limit requires a positive integer") from exc
|
|
165
|
+
if parsed < 1:
|
|
166
|
+
raise CLIError("--limit must be a positive integer")
|
|
167
|
+
return parsed
|
commands/mutations.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from cli import CLIError, Context
|
|
3
|
+
from graphs.engine import Graph, GraphError
|
|
4
|
+
from commands.common import cli, evidences, edge_response, emit, parse_properties
|
|
5
|
+
|
|
6
|
+
@cli.handler("edit", "Edit node fields or edge description/properties/evidences: edit node|edge ...")
|
|
7
|
+
def edit_record(ctx: Context) -> None:
|
|
8
|
+
target = ctx.positional(0).lower()
|
|
9
|
+
graph = Graph()
|
|
10
|
+
if target == "node":
|
|
11
|
+
if ctx.flag("id") is not None:
|
|
12
|
+
raise CLIError("edit node ID is positional: edit node ID --name=...")
|
|
13
|
+
if len(ctx.args) != 2:
|
|
14
|
+
raise CLIError("use edit node ID with --name, --properties, and/or --evidences")
|
|
15
|
+
reference = ctx.positional(1)
|
|
16
|
+
name = ctx.flag("name")
|
|
17
|
+
raw_properties = ctx.flag("properties")
|
|
18
|
+
raw_evidences = ctx.flag("evidences")
|
|
19
|
+
if name is None and raw_properties is None and raw_evidences is None:
|
|
20
|
+
raise CLIError("provide --name, --properties, and/or --evidences")
|
|
21
|
+
if name is not None and not isinstance(name, str):
|
|
22
|
+
raise CLIError("--name requires a value")
|
|
23
|
+
parsed_properties = parse_properties(raw_properties)
|
|
24
|
+
parsed_evidences = None
|
|
25
|
+
if raw_evidences is not None:
|
|
26
|
+
parsed_evidences = evidences(raw_evidences)
|
|
27
|
+
result = graph.edit_node(reference, name=name, properties=parsed_properties, evidences=parsed_evidences)
|
|
28
|
+
elif target == "edge":
|
|
29
|
+
if ctx.flag("id") is not None:
|
|
30
|
+
raise CLIError("edit edge ID is positional: edit edge ID --description=...")
|
|
31
|
+
if len(ctx.args) != 2:
|
|
32
|
+
raise CLIError("use edit edge ID with --description, --properties, and/or --evidences")
|
|
33
|
+
edge_id = ctx.positional(1)
|
|
34
|
+
description = ctx.flag("description")
|
|
35
|
+
raw_properties = ctx.flag("properties")
|
|
36
|
+
raw_evidences = ctx.flag("evidences")
|
|
37
|
+
if not isinstance(edge_id, str):
|
|
38
|
+
raise CLIError("edit edge requires an edge ID")
|
|
39
|
+
if description is not None and not isinstance(description, str):
|
|
40
|
+
raise CLIError("--description requires text")
|
|
41
|
+
if description is None and raw_properties is None and raw_evidences is None:
|
|
42
|
+
raise CLIError("provide --description, --properties, and/or --evidences")
|
|
43
|
+
parsed_properties = parse_properties(raw_properties)
|
|
44
|
+
parsed_evidences = None
|
|
45
|
+
if raw_evidences is not None:
|
|
46
|
+
parsed_evidences = evidences(raw_evidences)
|
|
47
|
+
result = graph.edit_edge(edge_id, description=description, properties=parsed_properties, evidences=parsed_evidences)
|
|
48
|
+
result = edge_response(result, graph.load()["nodes"])
|
|
49
|
+
else:
|
|
50
|
+
raise CLIError("expected 'node' or 'edge'")
|
|
51
|
+
emit(result)
|
|
52
|
+
|
|
53
|
+
@cli.handler("delete", "Delete a node and its edges, or delete an edge: delete node|edge ...")
|
|
54
|
+
def delete_record(ctx: Context) -> int | None:
|
|
55
|
+
target = ctx.positional(0).lower()
|
|
56
|
+
graph = Graph()
|
|
57
|
+
if target == "node":
|
|
58
|
+
if ctx.flag("id") is not None:
|
|
59
|
+
raise CLIError("delete node ID is positional: delete node ID")
|
|
60
|
+
if len(ctx.args) != 2:
|
|
61
|
+
raise CLIError("use delete node ID")
|
|
62
|
+
node_id = ctx.positional(1)
|
|
63
|
+
node, edge_count = graph.delete_node(node_id=node_id)
|
|
64
|
+
emit({"deleted_node": node, "deleted_edges": edge_count})
|
|
65
|
+
elif target == "edge":
|
|
66
|
+
if ctx.flag("id") is not None:
|
|
67
|
+
raise CLIError("delete edge ID is positional: delete edge ID")
|
|
68
|
+
if len(ctx.args) == 2:
|
|
69
|
+
edge_id = ctx.positional(1)
|
|
70
|
+
kind = source = destination = None
|
|
71
|
+
elif len(ctx.args) == 4:
|
|
72
|
+
edge_id = None
|
|
73
|
+
source, kind, destination = ctx.args[1:]
|
|
74
|
+
else:
|
|
75
|
+
raise CLIError("use delete edge ID or delete edge SOURCE_NODE_ID EDGE_TYPE TARGET_NODE_ID")
|
|
76
|
+
matches = graph.delete_edge(edge_id=edge_id, kind=kind, source=source, target=destination)
|
|
77
|
+
nodes = graph.load()["nodes"]
|
|
78
|
+
if len(matches) > 1:
|
|
79
|
+
print("Multiple matching edges found; none were deleted. Delete one by ID:", file=sys.stderr)
|
|
80
|
+
emit([edge_response(edge, nodes) for edge in matches])
|
|
81
|
+
print("Use: graph delete edge EDGE_ID", file=sys.stderr)
|
|
82
|
+
return 2
|
|
83
|
+
emit({"deleted_edge": edge_response(matches[0], nodes)})
|
|
84
|
+
else:
|
|
85
|
+
raise CLIError("expected 'node' or 'edge'")
|
commands/queries.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from cli import CLIError, Context
|
|
2
|
+
from graphs.engine import Graph, GraphError
|
|
3
|
+
from commands.common import cli, emit, positive_limit, parse_edge_types
|
|
4
|
+
|
|
5
|
+
@cli.handler("traverse", "Traverse from a node: traverse NODE_ID [--depth N] [--direction in|out|both] [--edge TYPE[,TYPE...]] [--limit N]")
|
|
6
|
+
def traverse(ctx: Context) -> None:
|
|
7
|
+
if len(ctx.args) != 1:
|
|
8
|
+
raise CLIError("traverse requires exactly one NODE_ID positional argument")
|
|
9
|
+
if ctx.flag("from") is not None or ctx.flag("to") is not None:
|
|
10
|
+
raise CLIError("traverse uses a positional NODE_ID; --from and --to are not supported")
|
|
11
|
+
anchor_id = ctx.positional(0)
|
|
12
|
+
|
|
13
|
+
def integer_flag(name: str, default: int | None) -> int | None:
|
|
14
|
+
value = ctx.flag(name)
|
|
15
|
+
if value is None:
|
|
16
|
+
return default
|
|
17
|
+
if not isinstance(value, str):
|
|
18
|
+
raise CLIError(f"--{name} requires an integer")
|
|
19
|
+
try:
|
|
20
|
+
parsed = int(value)
|
|
21
|
+
except ValueError as exc:
|
|
22
|
+
raise CLIError(f"--{name} requires an integer") from exc
|
|
23
|
+
if parsed < (1 if name == "depth" else 0):
|
|
24
|
+
raise CLIError(f"--{name} must be {'positive' if name == 'depth' else 'non-negative'}")
|
|
25
|
+
return parsed
|
|
26
|
+
|
|
27
|
+
depth = integer_flag("depth", 1)
|
|
28
|
+
limit = positive_limit(ctx.flag("limit"))
|
|
29
|
+
direction = ctx.flag("direction", default="both")
|
|
30
|
+
if direction is not None and not isinstance(direction, str):
|
|
31
|
+
raise CLIError("--direction requires in, out, or both")
|
|
32
|
+
graph = Graph()
|
|
33
|
+
edge_types = parse_edge_types(ctx.flag("edge"), graph)
|
|
34
|
+
result = graph.traverse(anchor_id, depth=depth, direction=direction,
|
|
35
|
+
edge_types=edge_types, limit=limit)
|
|
36
|
+
emit(result)
|
|
37
|
+
|
|
38
|
+
@cli.handler("path", "Find shortest paths between nodes: path SOURCE TARGET [--direction out|in|both] [--edge TYPE[,TYPE...]] [--max-depth N] [--limit N]")
|
|
39
|
+
def path(ctx: Context) -> None:
|
|
40
|
+
if len(ctx.args) != 2:
|
|
41
|
+
raise CLIError("path requires exactly SOURCE and TARGET")
|
|
42
|
+
source, target = ctx.positional(0), ctx.positional(1)
|
|
43
|
+
direction = ctx.flag("direction", default="out")
|
|
44
|
+
if not isinstance(direction, str):
|
|
45
|
+
raise CLIError("--direction requires out, in, or both")
|
|
46
|
+
graph = Graph()
|
|
47
|
+
edge_types = parse_edge_types(ctx.flag("edge"), graph)
|
|
48
|
+
|
|
49
|
+
def non_negative_option(name: str, default: int) -> int:
|
|
50
|
+
value = ctx.flag(name)
|
|
51
|
+
if value is None:
|
|
52
|
+
return default
|
|
53
|
+
if not isinstance(value, str):
|
|
54
|
+
raise CLIError(f"--{name} requires a non-negative integer")
|
|
55
|
+
try:
|
|
56
|
+
parsed = int(value)
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
raise CLIError(f"--{name} requires a non-negative integer") from exc
|
|
59
|
+
if parsed < 0:
|
|
60
|
+
raise CLIError(f"--{name} must be non-negative")
|
|
61
|
+
return parsed
|
|
62
|
+
|
|
63
|
+
max_depth = ctx.flag("max-depth")
|
|
64
|
+
if max_depth is not None:
|
|
65
|
+
max_depth = non_negative_option("max-depth", 0)
|
|
66
|
+
limit = positive_limit(ctx.flag("limit"), default=1)
|
|
67
|
+
result = graph.find_paths(source, target, direction=direction, edge_types=edge_types,
|
|
68
|
+
max_depth=max_depth, limit=limit)
|
|
69
|
+
emit(result)
|