katalyst-plugin-context 0.1.1a1__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.
- katalyst_plugin_context/__init__.py +8 -0
- katalyst_plugin_context/adapters/__init__.py +0 -0
- katalyst_plugin_context/adapters/runtime_graph.py +126 -0
- katalyst_plugin_context/cli.py +139 -0
- katalyst_plugin_context/composition.py +49 -0
- katalyst_plugin_context/domain/__init__.py +0 -0
- katalyst_plugin_context/domain/context.py +762 -0
- katalyst_plugin_context/domain/graph.py +97 -0
- katalyst_plugin_context/domain/markdown.py +201 -0
- katalyst_plugin_context/domain/paths.py +114 -0
- katalyst_plugin_context/domain/policy.py +345 -0
- katalyst_plugin_context/domain/settings.py +92 -0
- katalyst_plugin_context/lint.py +149 -0
- katalyst_plugin_context/mcp_tools.py +132 -0
- katalyst_plugin_context/plugin.py +64 -0
- katalyst_plugin_context/py.typed +0 -0
- katalyst_plugin_context/rest.py +133 -0
- katalyst_plugin_context/schema.py +200 -0
- katalyst_plugin_context/service.py +319 -0
- katalyst_plugin_context-0.1.1a1.dist-info/METADATA +13 -0
- katalyst_plugin_context-0.1.1a1.dist-info/RECORD +23 -0
- katalyst_plugin_context-0.1.1a1.dist-info/WHEEL +4 -0
- katalyst_plugin_context-0.1.1a1.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The relationship graph one call walks: visible nodes and the edges between them.
|
|
2
|
+
|
|
3
|
+
Built once per call from what the profile's backends hold
|
|
4
|
+
(:mod:`katalyst_plugin_context.adapters.runtime_graph`) and walked in memory. Every node is
|
|
5
|
+
keyed by its qualified name, ``backend:fqtn``. The graph holds only nodes the profile may see,
|
|
6
|
+
and only edges whose both ends it may see, so a walk can never pass through a hidden node.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from katalyst_plugin_context.domain.paths import IN, OUT
|
|
17
|
+
|
|
18
|
+
__all__ = ["Edge", "Graph", "GraphNode", "Neighbour"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class GraphNode:
|
|
23
|
+
"""One visible node."""
|
|
24
|
+
|
|
25
|
+
key: str
|
|
26
|
+
"""``backend:fqtn``: the node's qualified name."""
|
|
27
|
+
kind: str
|
|
28
|
+
name: str
|
|
29
|
+
fqn: str
|
|
30
|
+
backend: str
|
|
31
|
+
spec: Mapping[str, Any] = field(default_factory=lambda: dict[str, Any]())
|
|
32
|
+
description: str = ""
|
|
33
|
+
body: str | None = None
|
|
34
|
+
extensions: Mapping[str, Any] = field(default_factory=lambda: dict[str, Any]())
|
|
35
|
+
"""The node's ``spec.extensions``: each plugin's section."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Edge:
|
|
40
|
+
"""A relationship between two visible nodes."""
|
|
41
|
+
|
|
42
|
+
rel: str
|
|
43
|
+
source: str
|
|
44
|
+
target: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class Neighbour:
|
|
49
|
+
"""An edge seen from one of its ends."""
|
|
50
|
+
|
|
51
|
+
rel: str
|
|
52
|
+
direction: str
|
|
53
|
+
other: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Graph:
|
|
57
|
+
"""Visible nodes and the edges between them, indexed both ways."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self, nodes: Iterable[GraphNode], edges: Iterable[Edge], *, dropped: int = 0
|
|
61
|
+
) -> None:
|
|
62
|
+
self.nodes: dict[str, GraphNode] = {node.key: node for node in nodes}
|
|
63
|
+
self._out: dict[str, list[Edge]] = defaultdict(list)
|
|
64
|
+
self._in: dict[str, list[Edge]] = defaultdict(list)
|
|
65
|
+
seen: set[Edge] = set()
|
|
66
|
+
self.dropped = dropped
|
|
67
|
+
"""Edges left out because an end is not a visible node (hidden, or not read)."""
|
|
68
|
+
for edge in edges:
|
|
69
|
+
if edge.source not in self.nodes or edge.target not in self.nodes:
|
|
70
|
+
self.dropped += 1
|
|
71
|
+
continue
|
|
72
|
+
if edge in seen:
|
|
73
|
+
continue
|
|
74
|
+
seen.add(edge)
|
|
75
|
+
self._out[edge.source].append(edge)
|
|
76
|
+
self._in[edge.target].append(edge)
|
|
77
|
+
|
|
78
|
+
def __contains__(self, key: object) -> bool:
|
|
79
|
+
return key in self.nodes
|
|
80
|
+
|
|
81
|
+
def neighbours(self, key: str) -> Iterator[Neighbour]:
|
|
82
|
+
"""Every edge at *key*, outgoing first, in a stable order."""
|
|
83
|
+
for edge in sorted(self._out.get(key, ()), key=lambda e: (e.rel, e.target)):
|
|
84
|
+
yield Neighbour(edge.rel, OUT, edge.target)
|
|
85
|
+
for edge in sorted(self._in.get(key, ()), key=lambda e: (e.rel, e.source)):
|
|
86
|
+
yield Neighbour(edge.rel, IN, edge.source)
|
|
87
|
+
|
|
88
|
+
def ancestors(self, key: str, parent_rel: str) -> tuple[str, ...]:
|
|
89
|
+
"""*key*'s ancestors, nearest first, along *parent_rel* (outgoing), stopping at a cycle."""
|
|
90
|
+
chain: list[str] = []
|
|
91
|
+
current = key
|
|
92
|
+
while True:
|
|
93
|
+
parents = sorted(e.target for e in self._out.get(current, ()) if e.rel == parent_rel)
|
|
94
|
+
if not parents or parents[0] in chain or parents[0] == key:
|
|
95
|
+
return tuple(chain)
|
|
96
|
+
current = parents[0]
|
|
97
|
+
chain.append(current)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Agent-ready markdown for a context result or map, built from the same payload as JSON.
|
|
2
|
+
|
|
3
|
+
The two formats always agree because the markdown is rendered from the result object.
|
|
4
|
+
Item blocks are also what the budget measures when the markdown format is asked for
|
|
5
|
+
(:func:`item_size`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
from katalyst_plugin_context.domain.context import estimate_tokens
|
|
14
|
+
|
|
15
|
+
__all__ = ["item_block", "item_size", "render_map", "render_result"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _blocking_marker(item: Mapping[str, Any]) -> str:
|
|
19
|
+
blocking = item.get("blocking")
|
|
20
|
+
entries = (
|
|
21
|
+
cast("list[Mapping[str, Any]]", blocking)
|
|
22
|
+
if isinstance(blocking, list)
|
|
23
|
+
else ([cast("Mapping[str, Any]", blocking)] if isinstance(blocking, Mapping) else [])
|
|
24
|
+
)
|
|
25
|
+
if not entries:
|
|
26
|
+
return ""
|
|
27
|
+
if any(e.get("blocking") for e in entries):
|
|
28
|
+
return "⛔ blocking · "
|
|
29
|
+
return "_advisory_ · "
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def item_block(item: Mapping[str, Any]) -> str:
|
|
33
|
+
"""One item as a markdown list entry."""
|
|
34
|
+
head = f"- {_blocking_marker(item)}**{item['title']}** `{item['qualifiedName']}`"
|
|
35
|
+
fields = cast("Mapping[str, Any]", item.get("fields") or {})
|
|
36
|
+
shown = [f"{k}: {v}" for k, v in fields.items() if not isinstance(v, (list, dict))]
|
|
37
|
+
lines = [head + (" · " + " · ".join(shown) if shown else "")]
|
|
38
|
+
description = item.get("description")
|
|
39
|
+
if description:
|
|
40
|
+
length = int(item.get("descriptionLength") or 0)
|
|
41
|
+
more = (
|
|
42
|
+
f" _({length:,} chars; detail=full for all)_"
|
|
43
|
+
if item.get("detail") != "full" and length > len(str(description))
|
|
44
|
+
else ""
|
|
45
|
+
)
|
|
46
|
+
lines.append(f" {str(description).strip()}{more}")
|
|
47
|
+
for why in cast("list[Mapping[str, Any]]", item.get("why") or []):
|
|
48
|
+
lines.append(f" why: {why['text']}")
|
|
49
|
+
if item.get("detail") == "full":
|
|
50
|
+
spec = cast("Mapping[str, Any]", item.get("spec") or {})
|
|
51
|
+
extra = {k: v for k, v in spec.items() if k not in fields}
|
|
52
|
+
for key, value in extra.items():
|
|
53
|
+
lines.append(f" {key}: {value}")
|
|
54
|
+
body = item.get("body")
|
|
55
|
+
if body:
|
|
56
|
+
lines.append("")
|
|
57
|
+
lines.extend(f" {line}" for line in str(body).strip().splitlines())
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def item_size(item: dict[str, Any]) -> int:
|
|
62
|
+
"""The item's markdown block, in estimated tokens."""
|
|
63
|
+
return estimate_tokens(item_block(item))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _heading(node: Mapping[str, Any]) -> list[str]:
|
|
67
|
+
lines = [f"# Context: {node['kind']} `{node['qualifiedName']}`"]
|
|
68
|
+
if node.get("description"):
|
|
69
|
+
lines.append(str(node["description"]))
|
|
70
|
+
return lines
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _ancestors(ancestors: Sequence[Mapping[str, Any]]) -> list[str]:
|
|
74
|
+
if not ancestors:
|
|
75
|
+
return []
|
|
76
|
+
chain = " ← ".join(f"{a['kind']} `{a['qualifiedName']}`" for a in ancestors)
|
|
77
|
+
return [f"Ancestors: {chain}"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def render_result(
|
|
81
|
+
result: Mapping[str, Any],
|
|
82
|
+
*,
|
|
83
|
+
views: Mapping[str, Mapping[str, Any]] | None = None,
|
|
84
|
+
ancestors: Sequence[Mapping[str, Any]] = (),
|
|
85
|
+
level: int = 1,
|
|
86
|
+
) -> str:
|
|
87
|
+
"""A context result as markdown: grouped by view (then kind), then by asked path."""
|
|
88
|
+
lines: list[str] = []
|
|
89
|
+
if level == 1:
|
|
90
|
+
lines += _heading(cast("Mapping[str, Any]", result["node"]))
|
|
91
|
+
lines += _ancestors(ancestors)
|
|
92
|
+
items = cast("list[Mapping[str, Any]]", result.get("items") or [])
|
|
93
|
+
groups: dict[tuple[str | None, str], list[Mapping[str, Any]]] = {}
|
|
94
|
+
for item in items:
|
|
95
|
+
key = (item.get("view"), str(item.get("path")) if not item.get("view") else "")
|
|
96
|
+
groups.setdefault(key, []).append(item)
|
|
97
|
+
h = "#" * (level + 1)
|
|
98
|
+
for (view, path), members in groups.items():
|
|
99
|
+
lines.append("")
|
|
100
|
+
if view:
|
|
101
|
+
declared = (views or {}).get(view, {})
|
|
102
|
+
title = declared.get("title") or view
|
|
103
|
+
lines.append(f"{h} {title} _(view: {view})_")
|
|
104
|
+
if declared.get("description"):
|
|
105
|
+
lines.append(str(declared["description"]))
|
|
106
|
+
else:
|
|
107
|
+
lines.append(f"{h} `{path}`")
|
|
108
|
+
by_kind: dict[str, list[Mapping[str, Any]]] = {}
|
|
109
|
+
for item in members:
|
|
110
|
+
by_kind.setdefault(str(item["kind"]), []).append(item)
|
|
111
|
+
for kind, kind_items in by_kind.items():
|
|
112
|
+
lines.append(f"{h}# {kind}")
|
|
113
|
+
lines.extend(item_block(item) for item in kind_items)
|
|
114
|
+
if not items:
|
|
115
|
+
lines.append("")
|
|
116
|
+
lines.append("_Nothing here._")
|
|
117
|
+
truncated = cast("Mapping[str, Any] | None", result.get("truncated"))
|
|
118
|
+
if truncated:
|
|
119
|
+
lines.append("")
|
|
120
|
+
lines.append(
|
|
121
|
+
f"{h} Cut by the budget ({truncated['budget']:,} tokens; {truncated['used']:,} used)"
|
|
122
|
+
)
|
|
123
|
+
for group in cast("list[Mapping[str, Any]]", truncated.get("cut") or []):
|
|
124
|
+
names = ", ".join(f"`{n}`" for n in cast("list[str]", group["qualifiedNames"]))
|
|
125
|
+
where = f"view {group['view']}, " if group.get("view") else ""
|
|
126
|
+
lines.append(f"- {where}`{group['path']}`: {group['count']} cut — {names}")
|
|
127
|
+
downgraded = cast("list[Any]", truncated.get("downgraded") or [])
|
|
128
|
+
if downgraded:
|
|
129
|
+
lines.append(f"- {len(downgraded)} shown with less detail than asked")
|
|
130
|
+
hint = cast("Mapping[str, Any]", truncated.get("hint") or {})
|
|
131
|
+
if hint.get("budget"):
|
|
132
|
+
lines.append(f"More: ask again with budget {hint['budget']}.")
|
|
133
|
+
policy = cast("Mapping[str, Any]", result.get("policy") or {})
|
|
134
|
+
lines.extend(_policy_lines(policy))
|
|
135
|
+
return "\n".join(lines).strip() + "\n"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _policy_lines(policy: Mapping[str, Any]) -> list[str]:
|
|
139
|
+
said: list[str] = []
|
|
140
|
+
for key in ("notes", "clamped", "refused"):
|
|
141
|
+
for entry in cast("list[Mapping[str, Any]]", policy.get(key) or []):
|
|
142
|
+
text = entry.get("text") or (
|
|
143
|
+
f"{entry.get('control')}: asked {entry.get('requested')}, got "
|
|
144
|
+
f"{entry.get('effective')} ({entry.get('limitedBy')})"
|
|
145
|
+
)
|
|
146
|
+
said.append(f"- {text}")
|
|
147
|
+
layers = " → ".join(cast("list[str]", policy.get("layers") or []))
|
|
148
|
+
if not said:
|
|
149
|
+
return ["", f"_Policy: {layers}; nothing narrowed._"] if layers else []
|
|
150
|
+
return ["", f"_Policy: {layers}_", *said]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def render_map(context_map: Mapping[str, Any]) -> str:
|
|
154
|
+
"""A context map as markdown."""
|
|
155
|
+
lines = _heading(cast("Mapping[str, Any]", context_map["node"]))
|
|
156
|
+
lines += _ancestors(cast("list[Mapping[str, Any]]", context_map.get("ancestors") or []))
|
|
157
|
+
lines.append("")
|
|
158
|
+
lines.append("## Relationships")
|
|
159
|
+
rows = cast("list[Mapping[str, Any]]", context_map.get("relationships") or [])
|
|
160
|
+
if not rows:
|
|
161
|
+
lines.append("_None._")
|
|
162
|
+
for row in rows:
|
|
163
|
+
kinds = ", ".join(f"{k} ×{n}" for k, n in cast("Mapping[str, int]", row["kinds"]).items())
|
|
164
|
+
origin = "" if row["from"] == "self" else f" — inherited from `{row['from']}`"
|
|
165
|
+
lines.append(f"- `{row['path']}` → {kinds}{origin}")
|
|
166
|
+
hidden = cast("list[Mapping[str, Any]]", context_map.get("hiddenByPolicy") or [])
|
|
167
|
+
if hidden:
|
|
168
|
+
lines.append(
|
|
169
|
+
"- hidden by policy: " + ", ".join(f"`{h['path']}` ({h['limitedBy']})" for h in hidden)
|
|
170
|
+
)
|
|
171
|
+
view_rows = cast("list[Mapping[str, Any]]", context_map.get("views") or [])
|
|
172
|
+
if view_rows:
|
|
173
|
+
lines.append("")
|
|
174
|
+
lines.append("## Views")
|
|
175
|
+
for view in view_rows:
|
|
176
|
+
flag = " (always included)" if view.get("always") else ""
|
|
177
|
+
lines.append(f"- **{view['name']}**{flag}: {view['title']} — {view['count']} item(s)")
|
|
178
|
+
options = cast("Mapping[str, Any]", context_map.get("options") or {})
|
|
179
|
+
lines.append("")
|
|
180
|
+
lines.append("## Options")
|
|
181
|
+
steps = cast("list[str]", options.get("steps") or [])
|
|
182
|
+
lines.append(f"- steps: {', '.join(f'`{s}`' for s in steps) or 'none'}")
|
|
183
|
+
lines.append(
|
|
184
|
+
f"- max depth {options.get('maxDepth')}; detail {', '.join(options.get('detail') or [])}; "
|
|
185
|
+
f"budget {cast('Mapping[str, Any]', options.get('budget') or {}).get('default')} "
|
|
186
|
+
f"(max {cast('Mapping[str, Any]', options.get('budget') or {}).get('max')}); "
|
|
187
|
+
f"inheritance {'on' if options.get('inheritance') else 'off'}"
|
|
188
|
+
)
|
|
189
|
+
always = cast("Mapping[str, Any] | None", context_map.get("always"))
|
|
190
|
+
if always:
|
|
191
|
+
lines.append("")
|
|
192
|
+
lines.append("## Always included")
|
|
193
|
+
declared = {str(v["name"]): v for v in view_rows}
|
|
194
|
+
lines.append(render_result(always, views=declared, level=2).rstrip())
|
|
195
|
+
else:
|
|
196
|
+
lines.extend(_policy_lines(cast("Mapping[str, Any]", context_map.get("policy") or {})))
|
|
197
|
+
nxt = cast("list[str]", context_map.get("next") or [])
|
|
198
|
+
if nxt:
|
|
199
|
+
lines.append("")
|
|
200
|
+
lines.append("Next: " + "; ".join(f"`{n}`" for n in nxt))
|
|
201
|
+
return "\n".join(lines).strip() + "\n"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Steps, paths and patterns: how a walk through the relationship graph is written.
|
|
2
|
+
|
|
3
|
+
A **step** follows one relationship type in one direction, optionally only to some kinds:
|
|
4
|
+
``REL[:DIR[:KIND[,KIND...]]]``. ``REL`` is a graph label (``SOME_REL``) or ``*``. ``DIR`` is
|
|
5
|
+
``out`` (the node is the source) or ``in`` (the node is the target), and empty or omitted
|
|
6
|
+
means both. ``KIND`` restricts the node at the far end. A **path** is steps joined by
|
|
7
|
+
``/``, walked in order. A **pattern** is a single step used as a filter in a policy's
|
|
8
|
+
``paths.allow`` / ``paths.deny``.
|
|
9
|
+
|
|
10
|
+
The strings are data a bundle, a profile or an agent writes. Nothing here knows any
|
|
11
|
+
relationship or kind.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DIRECTIONS",
|
|
21
|
+
"IN",
|
|
22
|
+
"OUT",
|
|
23
|
+
"STEP_RE",
|
|
24
|
+
"PathSyntaxError",
|
|
25
|
+
"Step",
|
|
26
|
+
"parse_path",
|
|
27
|
+
"parse_step",
|
|
28
|
+
"path_text",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
OUT = "out"
|
|
32
|
+
IN = "in"
|
|
33
|
+
DIRECTIONS = (OUT, IN)
|
|
34
|
+
|
|
35
|
+
#: One step. Mirrored by the ``step`` definition of the plugin's JSON Schemas.
|
|
36
|
+
STEP_RE = r"(\*|[A-Z][A-Z0-9_]*)(:(in|out)?(:[a-z][a-z0-9_]*(,[a-z][a-z0-9_]*)*)?)?"
|
|
37
|
+
_STEP = re.compile(rf"^{STEP_RE}$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PathSyntaxError(ValueError):
|
|
41
|
+
"""A step or path that does not follow ``REL[:in|out[:KIND,...]]``, joined by ``/``."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class Step:
|
|
46
|
+
"""One relationship type, one direction (or both), optionally only to some kinds."""
|
|
47
|
+
|
|
48
|
+
rel: str
|
|
49
|
+
direction: str | None = None
|
|
50
|
+
kinds: frozenset[str] = frozenset()
|
|
51
|
+
|
|
52
|
+
def matches(self, rel: str, direction: str, kind: str) -> bool:
|
|
53
|
+
"""Whether traversing *rel* in *direction* to a node of *kind* is this step."""
|
|
54
|
+
return (
|
|
55
|
+
self.rel in ("*", rel)
|
|
56
|
+
and (self.direction is None or self.direction == direction)
|
|
57
|
+
and (not self.kinds or kind in self.kinds)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def overlaps(self, other: Step) -> bool:
|
|
61
|
+
"""Whether some traversal matches both steps."""
|
|
62
|
+
return (
|
|
63
|
+
(self.rel == "*" or other.rel == "*" or self.rel == other.rel)
|
|
64
|
+
and (
|
|
65
|
+
self.direction is None
|
|
66
|
+
or other.direction is None
|
|
67
|
+
or self.direction == other.direction
|
|
68
|
+
)
|
|
69
|
+
and (not self.kinds or not other.kinds or bool(self.kinds & other.kinds))
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def text(self) -> str:
|
|
74
|
+
"""The step written back: ``REL``, ``REL:out``, ``REL::kind`` or ``REL:in:a,b``."""
|
|
75
|
+
if self.kinds:
|
|
76
|
+
return f"{self.rel}:{self.direction or ''}:{','.join(sorted(self.kinds))}"
|
|
77
|
+
if self.direction:
|
|
78
|
+
return f"{self.rel}:{self.direction}"
|
|
79
|
+
return self.rel
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_step(text: str) -> Step:
|
|
83
|
+
"""Parse ``REL[:DIR[:KIND,...]]``.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
PathSyntaxError: If *text* is not a step.
|
|
87
|
+
"""
|
|
88
|
+
if not isinstance(text, str) or not _STEP.match(text.strip()): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
89
|
+
raise PathSyntaxError(
|
|
90
|
+
f"{text!r} is not a step: write REL[:in|out[:kind,...]], e.g. 'SOME_REL:out'"
|
|
91
|
+
)
|
|
92
|
+
rel, _, rest = text.strip().partition(":")
|
|
93
|
+
direction, _, kinds = rest.partition(":")
|
|
94
|
+
return Step(
|
|
95
|
+
rel=rel,
|
|
96
|
+
direction=direction or None,
|
|
97
|
+
kinds=frozenset(k for k in kinds.split(",") if k),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_path(text: str) -> tuple[Step, ...]:
|
|
102
|
+
"""Parse steps joined by ``/``.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
PathSyntaxError: If any part is not a step.
|
|
106
|
+
"""
|
|
107
|
+
if not isinstance(text, str) or not text.strip(): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
108
|
+
raise PathSyntaxError(f"{text!r} is not a path: write steps joined by '/'")
|
|
109
|
+
return tuple(parse_step(part) for part in text.strip().split("/"))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def path_text(steps: tuple[Step, ...]) -> str:
|
|
113
|
+
"""A path written back."""
|
|
114
|
+
return "/".join(step.text for step in steps)
|