deepagents-graph-memory 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.
@@ -0,0 +1,253 @@
1
+ # - Makes sense of graph file addresses and rejects unsafe names or paths.
2
+ # - Tests: test_paths.py covers valid addresses, bad names, and attempts to escape the graph folder.
3
+
4
+ """Virtual graph path parsing and validation."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import Literal
12
+ from urllib.parse import quote, unquote
13
+
14
+ from deepagents_graph_memory.errors import GraphMemoryPathError, GraphMemoryValidationError
15
+
16
+ PathKind = Literal["root", "schema", "index", "node", "search"]
17
+
18
+ IDENTIFIER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
19
+ NAMESPACE_COMPONENT_RE = re.compile(r"^[A-Za-z0-9\-_.@+:~]+$")
20
+ MAX_ID_LENGTH = 256
21
+ MAX_QUERY_LENGTH = 512
22
+
23
+
24
+ def validate_subject(value: str) -> str:
25
+ """Validate a trace subject using the backend's existing text rules."""
26
+ if not isinstance(value, str):
27
+ raise GraphMemoryValidationError("subject must be a string.")
28
+ subject = value.strip()
29
+ if not subject:
30
+ raise GraphMemoryValidationError("subject must not be empty.")
31
+ if any(ord(char) < 32 and char not in "\n\r\t" for char in subject):
32
+ raise GraphMemoryValidationError("subject must not contain NUL bytes or unsafe control characters.")
33
+ if len(subject) > 512:
34
+ raise GraphMemoryValidationError("subject must be at most 512 characters.")
35
+ return subject
36
+
37
+
38
+ def make_graph_subject(entity: str, aspect: str, environment: str) -> str:
39
+ """Build one stable subject key for an entity, aspect, and environment."""
40
+ parts = []
41
+ for field, value in (("entity", entity), ("aspect", aspect), ("environment", environment)):
42
+ if not isinstance(value, str) or not value.strip():
43
+ raise GraphMemoryValidationError(f"{field} must be a nonempty string.")
44
+ part = value.strip()
45
+ if any(ord(char) < 32 or ord(char) == 127 for char in part):
46
+ raise GraphMemoryValidationError(f"{field} must not contain control characters.")
47
+ parts.append(part)
48
+ return validate_subject(json.dumps(parts, separators=(",", ":")))
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ParsedGraphPath:
53
+ """Parsed graph-memory virtual path."""
54
+
55
+ kind: PathKind
56
+ path: str
57
+ label: str | None = None
58
+ node_id: str | None = None
59
+ query: str | None = None
60
+ had_graph_prefix: bool = False
61
+
62
+
63
+ def validate_identifier(value: str, *, field: str) -> str:
64
+ """Validate a graph label or relationship identifier.
65
+
66
+ Args:
67
+ value: Identifier to validate.
68
+ field: User-facing field name for error messages.
69
+
70
+ Returns:
71
+ The validated value.
72
+
73
+ Raises:
74
+ GraphMemoryValidationError: If the identifier is unsafe.
75
+ """
76
+ if not isinstance(value, str) or not IDENTIFIER_RE.fullmatch(value):
77
+ msg = f"{field} must start with a letter and contain only letters, numbers, and underscores."
78
+ raise GraphMemoryValidationError(msg)
79
+ return value
80
+
81
+
82
+ def validate_node_id(value: str) -> str:
83
+ """Validate a graph node id for use in virtual paths and graph writes.
84
+
85
+ Args:
86
+ value: Node id to validate.
87
+
88
+ Returns:
89
+ The validated node id.
90
+
91
+ Raises:
92
+ GraphMemoryValidationError: If the id is unsafe.
93
+ """
94
+ if not isinstance(value, str) or not value or len(value) > MAX_ID_LENGTH:
95
+ msg = f"node_id must be between 1 and {MAX_ID_LENGTH} characters."
96
+ raise GraphMemoryValidationError(msg)
97
+ if any(char in value for char in ("\x00", "/", "\\")) or value in {".", ".."}:
98
+ msg = "node_id must not contain path separators, NUL bytes, or traversal segments."
99
+ raise GraphMemoryValidationError(msg)
100
+ if any(ord(char) < 32 for char in value):
101
+ msg = "node_id must not contain control characters."
102
+ raise GraphMemoryValidationError(msg)
103
+ return value
104
+
105
+
106
+ def validate_search_query(value: str) -> str:
107
+ """Validate a virtual search query.
108
+
109
+ Args:
110
+ value: Search query.
111
+
112
+ Returns:
113
+ The validated query.
114
+
115
+ Raises:
116
+ GraphMemoryPathError: If the query is unsafe.
117
+ """
118
+ if not value or len(value) > MAX_QUERY_LENGTH:
119
+ msg = f"search query must be between 1 and {MAX_QUERY_LENGTH} characters."
120
+ raise GraphMemoryPathError(msg)
121
+ if any(char in value for char in ("\x00", "/", "\\")) or value in {".", ".."}:
122
+ msg = "search query must not contain path separators, NUL bytes, or traversal segments."
123
+ raise GraphMemoryPathError(msg)
124
+ return value
125
+
126
+
127
+ def validate_namespace(namespace: tuple[str, ...]) -> tuple[str, ...]:
128
+ """Validate a Deep Agents-style namespace tuple.
129
+
130
+ Args:
131
+ namespace: Namespace components.
132
+
133
+ Returns:
134
+ The validated namespace tuple.
135
+
136
+ Raises:
137
+ GraphMemoryValidationError: If a component is unsafe.
138
+ """
139
+ if not namespace:
140
+ msg = "namespace must not be empty."
141
+ raise GraphMemoryValidationError(msg)
142
+ for index, component in enumerate(namespace):
143
+ if not isinstance(component, str):
144
+ msg = f"namespace component {index} must be a string."
145
+ raise GraphMemoryValidationError(msg)
146
+ if not component:
147
+ msg = f"namespace component {index} must not be empty."
148
+ raise GraphMemoryValidationError(msg)
149
+ if not NAMESPACE_COMPONENT_RE.fullmatch(component):
150
+ msg = (
151
+ f"namespace component {index} contains disallowed characters. "
152
+ "Only alphanumeric characters, hyphens, underscores, dots, @, +, colons, and tildes are allowed."
153
+ )
154
+ raise GraphMemoryValidationError(msg)
155
+ return namespace
156
+
157
+
158
+ def encode_path_segment(value: str) -> str:
159
+ """Encode a graph path segment.
160
+
161
+ Args:
162
+ value: Segment value.
163
+
164
+ Returns:
165
+ Percent-encoded segment safe for a virtual path.
166
+ """
167
+ return quote(value, safe="-_.~")
168
+
169
+
170
+ def normalize_graph_path(path: str) -> tuple[str, bool]:
171
+ """Normalize a graph-memory path and strip an optional `/graph` prefix.
172
+
173
+ Args:
174
+ path: User-facing path.
175
+
176
+ Returns:
177
+ A tuple of normalized internal path and whether `/graph` was present.
178
+
179
+ Raises:
180
+ GraphMemoryPathError: If the path is unsafe.
181
+ """
182
+ if not path:
183
+ msg = "path must not be empty."
184
+ raise GraphMemoryPathError(msg)
185
+ if "\x00" in path or "\\" in path:
186
+ msg = "path must not contain NUL bytes or backslashes."
187
+ raise GraphMemoryPathError(msg)
188
+ normalized = path if path.startswith("/") else f"/{path}"
189
+ parts = [part for part in normalized.split("/") if part]
190
+ if any(part in {".", ".."} for part in parts):
191
+ msg = "path traversal is not allowed."
192
+ raise GraphMemoryPathError(msg)
193
+
194
+ had_graph_prefix = bool(parts and parts[0] == "graph")
195
+ if had_graph_prefix:
196
+ parts = parts[1:]
197
+
198
+ internal = "/" + "/".join(parts)
199
+ if internal != "/" and path.endswith("/"):
200
+ internal += "/"
201
+ return internal, had_graph_prefix
202
+
203
+
204
+ def parse_graph_path(path: str) -> ParsedGraphPath:
205
+ """Parse a read target in the virtual graph filesystem.
206
+
207
+ Args:
208
+ path: Path to parse.
209
+
210
+ Returns:
211
+ Parsed path data.
212
+
213
+ Raises:
214
+ GraphMemoryPathError: If the path is not a supported graph-memory file.
215
+ """
216
+ normalized, had_graph_prefix = normalize_graph_path(path)
217
+ if normalized == "/":
218
+ return ParsedGraphPath(kind="root", path=normalized, had_graph_prefix=had_graph_prefix)
219
+ if normalized == "/schema.md":
220
+ return ParsedGraphPath(kind="schema", path=normalized, had_graph_prefix=had_graph_prefix)
221
+ if normalized == "/index.md":
222
+ return ParsedGraphPath(kind="index", path=normalized, had_graph_prefix=had_graph_prefix)
223
+
224
+ parts = [part for part in normalized.split("/") if part]
225
+ if len(parts) == 3 and parts[0] == "nodes" and parts[2].endswith(".md"):
226
+ label = unquote(parts[1])
227
+ node_id = unquote(parts[2][:-3])
228
+ try:
229
+ validate_identifier(label, field="label")
230
+ validate_node_id(node_id)
231
+ except GraphMemoryValidationError as exc:
232
+ raise GraphMemoryPathError(str(exc)) from exc
233
+ return ParsedGraphPath(kind="node", path=normalized, label=label, node_id=node_id, had_graph_prefix=had_graph_prefix)
234
+
235
+ if len(parts) == 2 and parts[0] == "search" and parts[1].endswith(".md"):
236
+ query = validate_search_query(unquote(parts[1][:-3]))
237
+ return ParsedGraphPath(kind="search", path=normalized, query=query, had_graph_prefix=had_graph_prefix)
238
+
239
+ msg = f"Unsupported graph memory path: {path}"
240
+ raise GraphMemoryPathError(msg)
241
+
242
+
243
+ def node_path(label: str, node_id: str) -> str:
244
+ """Return the virtual path for a node page.
245
+
246
+ Args:
247
+ label: Node label.
248
+ node_id: Node id.
249
+
250
+ Returns:
251
+ Virtual node path.
252
+ """
253
+ return f"/nodes/{encode_path_segment(label)}/{encode_path_segment(node_id)}.md"
@@ -0,0 +1 @@
1
+