htmlgraph 0.27.2__py3-none-any.whl → 0.27.3__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.
- htmlgraph/__init__.py +1 -1
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/api/main.py +4 -4
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/query_composer.py +509 -0
- {htmlgraph-0.27.2.dist-info → htmlgraph-0.27.3.dist-info}/METADATA +2 -2
- {htmlgraph-0.27.2.dist-info → htmlgraph-0.27.3.dist-info}/RECORD +16 -11
- {htmlgraph-0.27.2.data → htmlgraph-0.27.3.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.27.2.data → htmlgraph-0.27.3.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.27.2.data → htmlgraph-0.27.3.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.27.2.data → htmlgraph-0.27.3.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.27.2.data → htmlgraph-0.27.3.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.27.2.dist-info → htmlgraph-0.27.3.dist-info}/WHEEL +0 -0
- {htmlgraph-0.27.2.dist-info → htmlgraph-0.27.3.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Graph Query Composer for HtmlGraph.
|
|
5
|
+
|
|
6
|
+
Extends QueryBuilder capabilities with graph traversal, enabling queries
|
|
7
|
+
that combine attribute filtering WITH edge traversal.
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
from htmlgraph import HtmlGraph
|
|
11
|
+
|
|
12
|
+
graph = HtmlGraph("features/")
|
|
13
|
+
|
|
14
|
+
# Find blocked features whose blockers are high-priority
|
|
15
|
+
results = graph.query_composer() \\
|
|
16
|
+
.where("status", "blocked") \\
|
|
17
|
+
.traverse("blocked_by", direction="outgoing") \\
|
|
18
|
+
.where("priority", "high") \\
|
|
19
|
+
.execute()
|
|
20
|
+
|
|
21
|
+
# Find all features transitively reachable via depends_on from a root
|
|
22
|
+
results = graph.query_composer() \\
|
|
23
|
+
.reachable_from("feat-001", "depends_on") \\
|
|
24
|
+
.where("status", "todo") \\
|
|
25
|
+
.execute()
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from collections import deque
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
31
|
+
|
|
32
|
+
from htmlgraph.query_builder import Condition, LogicalOp, Operator
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from htmlgraph.graph import HtmlGraph
|
|
36
|
+
from htmlgraph.models import Node
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Sentinel object to distinguish "no value passed" from None
|
|
40
|
+
_SENTINEL: Any = object()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class QueryStage:
|
|
45
|
+
"""A single stage in the query execution pipeline."""
|
|
46
|
+
|
|
47
|
+
stage_type: Literal["filter", "traverse", "traverse_recursive", "reachable_from"]
|
|
48
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GraphQueryComposer:
|
|
52
|
+
"""
|
|
53
|
+
Composes graph traversal with attribute filtering.
|
|
54
|
+
|
|
55
|
+
Wraps QueryBuilder condition logic and EdgeIndex traversal into
|
|
56
|
+
a pipeline of stages. Each stage narrows the working set of nodes.
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
composer = GraphQueryComposer(graph)
|
|
60
|
+
results = composer \\
|
|
61
|
+
.where("status", "blocked") \\
|
|
62
|
+
.traverse("blocked_by") \\
|
|
63
|
+
.where("priority", "high") \\
|
|
64
|
+
.execute()
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, graph: HtmlGraph) -> None:
|
|
68
|
+
self.graph = graph
|
|
69
|
+
self._stages: list[QueryStage] = []
|
|
70
|
+
# Accumulate filter conditions for the current filter group.
|
|
71
|
+
# When a traverse stage is added, pending conditions are flushed
|
|
72
|
+
# into a filter stage.
|
|
73
|
+
self._pending_conditions: list[Condition] = []
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Attribute filtering (delegates to QueryBuilder condition logic)
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def where(self, attr: str, value: Any = _SENTINEL) -> GraphQueryComposer:
|
|
80
|
+
"""Start or add an attribute filter on the current node set.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
attr: Attribute name (supports dot notation for nested access).
|
|
84
|
+
value: If provided, shorthand for equality check.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Self for chaining.
|
|
88
|
+
"""
|
|
89
|
+
if value is not _SENTINEL:
|
|
90
|
+
self._pending_conditions.append(
|
|
91
|
+
Condition(
|
|
92
|
+
attribute=attr,
|
|
93
|
+
operator=Operator.EQ,
|
|
94
|
+
value=value,
|
|
95
|
+
logical_op=LogicalOp.AND,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
# When no value is given, add a placeholder condition that must
|
|
100
|
+
# be completed by a subsequent operator call. For simplicity
|
|
101
|
+
# we store an IS_NOT_NULL condition which acts as a "has attr"
|
|
102
|
+
# check; callers who want other operators should use and_/or_.
|
|
103
|
+
self._pending_conditions.append(
|
|
104
|
+
Condition(
|
|
105
|
+
attribute=attr,
|
|
106
|
+
operator=Operator.IS_NOT_NULL,
|
|
107
|
+
value=None,
|
|
108
|
+
logical_op=LogicalOp.AND,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def and_(self, attr: str, value: Any = _SENTINEL) -> GraphQueryComposer:
|
|
114
|
+
"""Add an AND condition.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
attr: Attribute name.
|
|
118
|
+
value: If provided, shorthand for equality check.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Self for chaining.
|
|
122
|
+
"""
|
|
123
|
+
if value is not _SENTINEL:
|
|
124
|
+
self._pending_conditions.append(
|
|
125
|
+
Condition(
|
|
126
|
+
attribute=attr,
|
|
127
|
+
operator=Operator.EQ,
|
|
128
|
+
value=value,
|
|
129
|
+
logical_op=LogicalOp.AND,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
self._pending_conditions.append(
|
|
134
|
+
Condition(
|
|
135
|
+
attribute=attr,
|
|
136
|
+
operator=Operator.IS_NOT_NULL,
|
|
137
|
+
value=None,
|
|
138
|
+
logical_op=LogicalOp.AND,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def or_(self, attr: str, value: Any = _SENTINEL) -> GraphQueryComposer:
|
|
144
|
+
"""Add an OR condition.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
attr: Attribute name.
|
|
148
|
+
value: If provided, shorthand for equality check.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Self for chaining.
|
|
152
|
+
"""
|
|
153
|
+
if value is not _SENTINEL:
|
|
154
|
+
self._pending_conditions.append(
|
|
155
|
+
Condition(
|
|
156
|
+
attribute=attr,
|
|
157
|
+
operator=Operator.EQ,
|
|
158
|
+
value=value,
|
|
159
|
+
logical_op=LogicalOp.OR,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
self._pending_conditions.append(
|
|
164
|
+
Condition(
|
|
165
|
+
attribute=attr,
|
|
166
|
+
operator=Operator.IS_NOT_NULL,
|
|
167
|
+
value=None,
|
|
168
|
+
logical_op=LogicalOp.OR,
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
# Relationship traversal
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def traverse(
|
|
178
|
+
self, relationship: str, direction: str = "outgoing"
|
|
179
|
+
) -> GraphQueryComposer:
|
|
180
|
+
"""Follow edges of given relationship type from current result set.
|
|
181
|
+
|
|
182
|
+
For each node in the working set, collect the nodes reachable
|
|
183
|
+
by one hop via *relationship* in the specified *direction*.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
relationship: Edge relationship type (e.g. ``"blocked_by"``).
|
|
187
|
+
direction: ``"outgoing"``, ``"incoming"``, or ``"both"``.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Self for chaining.
|
|
191
|
+
"""
|
|
192
|
+
self._flush_conditions()
|
|
193
|
+
self._stages.append(
|
|
194
|
+
QueryStage(
|
|
195
|
+
stage_type="traverse",
|
|
196
|
+
params={"relationship": relationship, "direction": direction},
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
def traverse_recursive(
|
|
202
|
+
self,
|
|
203
|
+
relationship: str,
|
|
204
|
+
direction: str = "outgoing",
|
|
205
|
+
max_depth: int = 10,
|
|
206
|
+
) -> GraphQueryComposer:
|
|
207
|
+
"""Follow edges recursively (transitive closure) up to *max_depth*.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
relationship: Edge relationship type.
|
|
211
|
+
direction: ``"outgoing"``, ``"incoming"``, or ``"both"``.
|
|
212
|
+
max_depth: Maximum traversal depth.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Self for chaining.
|
|
216
|
+
"""
|
|
217
|
+
self._flush_conditions()
|
|
218
|
+
self._stages.append(
|
|
219
|
+
QueryStage(
|
|
220
|
+
stage_type="traverse_recursive",
|
|
221
|
+
params={
|
|
222
|
+
"relationship": relationship,
|
|
223
|
+
"direction": direction,
|
|
224
|
+
"max_depth": max_depth,
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
return self
|
|
229
|
+
|
|
230
|
+
def reachable_from(
|
|
231
|
+
self,
|
|
232
|
+
node_id: str,
|
|
233
|
+
relationship: str,
|
|
234
|
+
direction: str = "outgoing",
|
|
235
|
+
max_depth: int = 10,
|
|
236
|
+
) -> GraphQueryComposer:
|
|
237
|
+
"""Filter to nodes reachable from *node_id* via *relationship*.
|
|
238
|
+
|
|
239
|
+
This resets the working set to all nodes transitively reachable
|
|
240
|
+
from the given starting node (excluding the starting node itself).
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
node_id: Starting node ID.
|
|
244
|
+
relationship: Edge relationship type.
|
|
245
|
+
direction: ``"outgoing"``, ``"incoming"``, or ``"both"``.
|
|
246
|
+
max_depth: Maximum traversal depth.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Self for chaining.
|
|
250
|
+
"""
|
|
251
|
+
self._flush_conditions()
|
|
252
|
+
self._stages.append(
|
|
253
|
+
QueryStage(
|
|
254
|
+
stage_type="reachable_from",
|
|
255
|
+
params={
|
|
256
|
+
"node_id": node_id,
|
|
257
|
+
"relationship": relationship,
|
|
258
|
+
"direction": direction,
|
|
259
|
+
"max_depth": max_depth,
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return self
|
|
264
|
+
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
# Convenience methods
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def blocked_by_chain(self, feature_id: str) -> GraphQueryComposer:
|
|
270
|
+
"""Find all features in the ``blocked_by`` chain from *feature_id*.
|
|
271
|
+
|
|
272
|
+
Equivalent to ``reachable_from(feature_id, "blocked_by", "outgoing")``.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
feature_id: Starting feature node ID.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Self for chaining.
|
|
279
|
+
"""
|
|
280
|
+
return self.reachable_from(feature_id, "blocked_by", direction="outgoing")
|
|
281
|
+
|
|
282
|
+
def dependency_chain(self, feature_id: str) -> GraphQueryComposer:
|
|
283
|
+
"""Find all features in the ``depends_on`` chain from *feature_id*.
|
|
284
|
+
|
|
285
|
+
Equivalent to ``reachable_from(feature_id, "depends_on", "outgoing")``.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
feature_id: Starting feature node ID.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Self for chaining.
|
|
292
|
+
"""
|
|
293
|
+
return self.reachable_from(feature_id, "depends_on", direction="outgoing")
|
|
294
|
+
|
|
295
|
+
# ------------------------------------------------------------------
|
|
296
|
+
# Execution
|
|
297
|
+
# ------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
def execute(self) -> list[Node]:
|
|
300
|
+
"""Execute all stages and return matching nodes.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
List of nodes matching the composed query.
|
|
304
|
+
"""
|
|
305
|
+
# Flush any remaining pending conditions
|
|
306
|
+
self._flush_conditions()
|
|
307
|
+
|
|
308
|
+
self.graph._ensure_loaded() # noqa: SLF001
|
|
309
|
+
|
|
310
|
+
# Start with all node IDs
|
|
311
|
+
working_set: set[str] = set(self.graph._nodes.keys()) # noqa: SLF001
|
|
312
|
+
|
|
313
|
+
for stage in self._stages:
|
|
314
|
+
if stage.stage_type == "filter":
|
|
315
|
+
working_set = self._apply_filter(working_set, stage)
|
|
316
|
+
elif stage.stage_type == "traverse":
|
|
317
|
+
working_set = self._apply_traverse(working_set, stage)
|
|
318
|
+
elif stage.stage_type == "traverse_recursive":
|
|
319
|
+
working_set = self._apply_traverse_recursive(working_set, stage)
|
|
320
|
+
elif stage.stage_type == "reachable_from":
|
|
321
|
+
working_set = self._apply_reachable_from(working_set, stage)
|
|
322
|
+
|
|
323
|
+
# Resolve IDs to Node objects, preserving only nodes that still exist
|
|
324
|
+
nodes = self.graph._nodes # noqa: SLF001
|
|
325
|
+
return [nodes[nid] for nid in working_set if nid in nodes]
|
|
326
|
+
|
|
327
|
+
def count(self) -> int:
|
|
328
|
+
"""Execute and return count of matches.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
Number of matching nodes.
|
|
332
|
+
"""
|
|
333
|
+
return len(self.execute())
|
|
334
|
+
|
|
335
|
+
def first(self) -> Node | None:
|
|
336
|
+
"""Execute and return first match or ``None``.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
First matching node or ``None``.
|
|
340
|
+
"""
|
|
341
|
+
results = self.execute()
|
|
342
|
+
return results[0] if results else None
|
|
343
|
+
|
|
344
|
+
def ids(self) -> list[str]:
|
|
345
|
+
"""Execute and return just node IDs.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
List of matching node IDs.
|
|
349
|
+
"""
|
|
350
|
+
return [node.id for node in self.execute()]
|
|
351
|
+
|
|
352
|
+
# ------------------------------------------------------------------
|
|
353
|
+
# Internal helpers
|
|
354
|
+
# ------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def _flush_conditions(self) -> None:
|
|
357
|
+
"""Flush accumulated conditions into a filter stage."""
|
|
358
|
+
if self._pending_conditions:
|
|
359
|
+
self._stages.append(
|
|
360
|
+
QueryStage(
|
|
361
|
+
stage_type="filter",
|
|
362
|
+
params={"conditions": list(self._pending_conditions)},
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
self._pending_conditions = []
|
|
366
|
+
|
|
367
|
+
def _apply_filter(self, working_set: set[str], stage: QueryStage) -> set[str]:
|
|
368
|
+
"""Apply attribute filter conditions to the working set."""
|
|
369
|
+
conditions: list[Condition] = stage.params["conditions"]
|
|
370
|
+
nodes = self.graph._nodes # noqa: SLF001
|
|
371
|
+
result: set[str] = set()
|
|
372
|
+
|
|
373
|
+
for nid in working_set:
|
|
374
|
+
node = nodes.get(nid)
|
|
375
|
+
if node is None:
|
|
376
|
+
continue
|
|
377
|
+
if self._evaluate_conditions(node, conditions):
|
|
378
|
+
result.add(nid)
|
|
379
|
+
|
|
380
|
+
return result
|
|
381
|
+
|
|
382
|
+
def _apply_traverse(self, working_set: set[str], stage: QueryStage) -> set[str]:
|
|
383
|
+
"""Follow one hop of edges from working set."""
|
|
384
|
+
relationship: str = stage.params["relationship"]
|
|
385
|
+
direction: str = stage.params["direction"]
|
|
386
|
+
edge_index = self.graph._edge_index # noqa: SLF001
|
|
387
|
+
result: set[str] = set()
|
|
388
|
+
|
|
389
|
+
for nid in working_set:
|
|
390
|
+
if direction in ("outgoing", "both"):
|
|
391
|
+
for ref in edge_index.get_outgoing(nid, relationship):
|
|
392
|
+
result.add(ref.target_id)
|
|
393
|
+
if direction in ("incoming", "both"):
|
|
394
|
+
for ref in edge_index.get_incoming(nid, relationship):
|
|
395
|
+
result.add(ref.source_id)
|
|
396
|
+
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
def _apply_traverse_recursive(
|
|
400
|
+
self, working_set: set[str], stage: QueryStage
|
|
401
|
+
) -> set[str]:
|
|
402
|
+
"""Follow edges recursively from working set with depth limit."""
|
|
403
|
+
relationship: str = stage.params["relationship"]
|
|
404
|
+
direction: str = stage.params["direction"]
|
|
405
|
+
max_depth: int = stage.params["max_depth"]
|
|
406
|
+
edge_index = self.graph._edge_index # noqa: SLF001
|
|
407
|
+
|
|
408
|
+
result: set[str] = set()
|
|
409
|
+
# BFS from every node in working set
|
|
410
|
+
queue: deque[tuple[str, int]] = deque()
|
|
411
|
+
for nid in working_set:
|
|
412
|
+
queue.append((nid, 0))
|
|
413
|
+
|
|
414
|
+
visited: set[str] = set(working_set)
|
|
415
|
+
|
|
416
|
+
while queue:
|
|
417
|
+
current, depth = queue.popleft()
|
|
418
|
+
if depth >= max_depth:
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
neighbors: set[str] = set()
|
|
422
|
+
if direction in ("outgoing", "both"):
|
|
423
|
+
for ref in edge_index.get_outgoing(current, relationship):
|
|
424
|
+
neighbors.add(ref.target_id)
|
|
425
|
+
if direction in ("incoming", "both"):
|
|
426
|
+
for ref in edge_index.get_incoming(current, relationship):
|
|
427
|
+
neighbors.add(ref.source_id)
|
|
428
|
+
|
|
429
|
+
for neighbor in neighbors:
|
|
430
|
+
if neighbor not in visited:
|
|
431
|
+
visited.add(neighbor)
|
|
432
|
+
result.add(neighbor)
|
|
433
|
+
queue.append((neighbor, depth + 1))
|
|
434
|
+
|
|
435
|
+
return result
|
|
436
|
+
|
|
437
|
+
def _apply_reachable_from(
|
|
438
|
+
self, working_set: set[str], stage: QueryStage
|
|
439
|
+
) -> set[str]:
|
|
440
|
+
"""Compute nodes reachable from a specific node, intersected with working set."""
|
|
441
|
+
node_id: str = stage.params["node_id"]
|
|
442
|
+
relationship: str = stage.params["relationship"]
|
|
443
|
+
direction: str = stage.params["direction"]
|
|
444
|
+
max_depth: int = stage.params["max_depth"]
|
|
445
|
+
edge_index = self.graph._edge_index # noqa: SLF001
|
|
446
|
+
|
|
447
|
+
reachable: set[str] = set()
|
|
448
|
+
visited: set[str] = {node_id}
|
|
449
|
+
queue: deque[tuple[str, int]] = deque([(node_id, 0)])
|
|
450
|
+
|
|
451
|
+
while queue:
|
|
452
|
+
current, depth = queue.popleft()
|
|
453
|
+
if depth >= max_depth:
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
neighbors: set[str] = set()
|
|
457
|
+
if direction in ("outgoing", "both"):
|
|
458
|
+
for ref in edge_index.get_outgoing(current, relationship):
|
|
459
|
+
neighbors.add(ref.target_id)
|
|
460
|
+
if direction in ("incoming", "both"):
|
|
461
|
+
for ref in edge_index.get_incoming(current, relationship):
|
|
462
|
+
neighbors.add(ref.source_id)
|
|
463
|
+
|
|
464
|
+
for neighbor in neighbors:
|
|
465
|
+
if neighbor not in visited:
|
|
466
|
+
visited.add(neighbor)
|
|
467
|
+
reachable.add(neighbor)
|
|
468
|
+
queue.append((neighbor, depth + 1))
|
|
469
|
+
|
|
470
|
+
# Intersect with the current working set
|
|
471
|
+
return working_set & reachable
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _evaluate_conditions(node: Node, conditions: list[Condition]) -> bool:
|
|
475
|
+
"""Evaluate a list of conditions against a node.
|
|
476
|
+
|
|
477
|
+
Reuses the ``Condition.evaluate`` method from ``query_builder.py``
|
|
478
|
+
so condition evaluation logic is not duplicated.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
node: Node to evaluate.
|
|
482
|
+
conditions: Conditions to check.
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
``True`` if the node satisfies the combined conditions.
|
|
486
|
+
"""
|
|
487
|
+
if not conditions:
|
|
488
|
+
return True
|
|
489
|
+
|
|
490
|
+
result: bool | None = None
|
|
491
|
+
|
|
492
|
+
for condition in conditions:
|
|
493
|
+
condition_result = condition.evaluate(node)
|
|
494
|
+
|
|
495
|
+
# Handle NOT operator
|
|
496
|
+
if condition.logical_op == LogicalOp.NOT:
|
|
497
|
+
condition_result = not condition_result
|
|
498
|
+
|
|
499
|
+
if result is None:
|
|
500
|
+
result = condition_result
|
|
501
|
+
elif condition.logical_op == LogicalOp.AND:
|
|
502
|
+
result = result and condition_result
|
|
503
|
+
elif condition.logical_op == LogicalOp.OR:
|
|
504
|
+
result = result or condition_result
|
|
505
|
+
elif condition.logical_op == LogicalOp.NOT:
|
|
506
|
+
# NOT combined with previous result (AND NOT)
|
|
507
|
+
result = result and condition_result
|
|
508
|
+
|
|
509
|
+
return result if result is not None else True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htmlgraph
|
|
3
|
-
Version: 0.27.
|
|
3
|
+
Version: 0.27.3
|
|
4
4
|
Summary: HTML is All You Need - Graph database on web standards
|
|
5
5
|
Project-URL: Homepage, https://github.com/Shakes-tzd/htmlgraph
|
|
6
6
|
Project-URL: Documentation, https://github.com/Shakes-tzd/htmlgraph#readme
|
|
@@ -24,6 +24,7 @@ Requires-Dist: fastapi>=0.104.0
|
|
|
24
24
|
Requires-Dist: jinja2>=3.1.0
|
|
25
25
|
Requires-Dist: justhtml>=0.6.0
|
|
26
26
|
Requires-Dist: pydantic>=2.0.0
|
|
27
|
+
Requires-Dist: pyyaml>=6.0
|
|
27
28
|
Requires-Dist: rich>=13.0.0
|
|
28
29
|
Requires-Dist: typing-extensions>=4.0.0; python_version < '3.11'
|
|
29
30
|
Requires-Dist: uvicorn>=0.24.0
|
|
@@ -38,7 +39,6 @@ Requires-Dist: pdoc>=14.0.0; extra == 'dev'
|
|
|
38
39
|
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
39
40
|
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
40
41
|
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
41
|
-
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
42
42
|
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
43
43
|
Requires-Dist: tomli>=2.0.0; (python_version < '3.11') and extra == 'dev'
|
|
44
44
|
Provides-Extra: sqlite
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
htmlgraph/__init__.py,sha256=
|
|
1
|
+
htmlgraph/__init__.py,sha256=pkSpF0qSd2RLviOruILG54FI3YUu0dHxiGtTt_vvGyA,6407
|
|
2
2
|
htmlgraph/__init__.pyi,sha256=8JuFVuDll9jMx9s8ZQHt2tXic-geOJHiXUMB2YjmHhU,6683
|
|
3
3
|
htmlgraph/agent_detection.py,sha256=wEmrDv4hssPX2OkEnJZBHPbalxcaloiJF_hOOow_5WE,3511
|
|
4
4
|
htmlgraph/agent_registry.py,sha256=80TPYr4P0YMizPUbTH4N5wH6D84IKs-HPBLHGeeP6bY,9449
|
|
@@ -6,6 +6,7 @@ htmlgraph/agents.py,sha256=Yvu6x1nOfrW2WhRTAHiCuSpvqoVJXx1Mkzd59kwEczw,33466
|
|
|
6
6
|
htmlgraph/analytics_index.py,sha256=fm0cnZjce2Ii4F9FQo1np_mWsFXuRgTTOzbrCJWGW3g,32793
|
|
7
7
|
htmlgraph/atomic_ops.py,sha256=f0ULZoJThfC-A8xORvRv5OuFuhEnnH7uJrZslnZwmuw,18781
|
|
8
8
|
htmlgraph/attribute_index.py,sha256=oF4TwbFGdtg1_W5AUJ-Ka4VcH-aYz-qldtiPvStJlfA,6594
|
|
9
|
+
htmlgraph/bounded_paths.py,sha256=4HHXiT8q2fhGkrGsGrACFnDrnAzwJ-Tk_4V9h-voenM,18832
|
|
9
10
|
htmlgraph/cli_framework.py,sha256=YfAjTGIECmv_uNpkenYyc9_iKd3jXIG5OstEKpIGWJM,3551
|
|
10
11
|
htmlgraph/config.py,sha256=dhOSfMfZCT-APe_uAvqABWyQ0nEhL6F0NS-15KLPZNg,6888
|
|
11
12
|
htmlgraph/context_analytics.py,sha256=fGuIhGCM-500wH_pTKE24k0Rl01QvQ8Kznoj61KpjiU,11345
|
|
@@ -41,10 +42,13 @@ htmlgraph/orchestrator_mode.py,sha256=DXkG89uHe2VPz8wSx44JENn70lQiI5BLihKGpcPnNx
|
|
|
41
42
|
htmlgraph/orchestrator_validator.py,sha256=gd_KbHsRsNEIF7EElwcxbMYqOMlyeuYIZwClASp-L-E,4699
|
|
42
43
|
htmlgraph/parallel.py,sha256=txiCgNtPu9hpBsIkwHHZhKBrPrdN6W3ysA9Q0_YjSa0,22419
|
|
43
44
|
htmlgraph/parser.py,sha256=mPcQ3WQdDCpflhjk8397TPbHnQcU3SzefNk-JE406UA,16570
|
|
45
|
+
htmlgraph/path_query.py,sha256=51ttIPsLM1RsYgRSYEpLW6lwgsFuZ9OTaIUfju3M6XU,20034
|
|
46
|
+
htmlgraph/pattern_matcher.py,sha256=QOEWN5QFZqUaaTywSxLVyWsjBRUBUOjBCI7fpF7Uu8M,22161
|
|
44
47
|
htmlgraph/planning.py,sha256=iqPF9mCVQwOfJ4vuqcF2Y3-yhx9koJZw0cID7CknIug,35903
|
|
45
48
|
htmlgraph/pydantic_models.py,sha256=4vGAp2n59Hf8v5mZG-Tx2vOSm3lcE_PypBprcejJUXE,15832
|
|
46
49
|
htmlgraph/quality_gates.py,sha256=ovPrjGgAwK5MjMzEIda4a6M5_S-yGiRJYy8KhzdyTZ0,11686
|
|
47
50
|
htmlgraph/query_builder.py,sha256=GBrjf_6Qoq98JxJr8cYeAMV4QpdmrfFyadJpDjH4clM,18093
|
|
51
|
+
htmlgraph/query_composer.py,sha256=ilasmZBc8_mLS1iq45Kcw2Ig1zCN04-r1HVKr8f9YP0,17332
|
|
48
52
|
htmlgraph/reflection.py,sha256=agNfHsI1ynayTG3kgt5fiQj30LkG9b-v3wtnDsu4-Gk,15663
|
|
49
53
|
htmlgraph/refs.py,sha256=jgsPWRTfPDxhH_dr4EeYdU1ly-e3HpuHgAPnMXMbaF4,10011
|
|
50
54
|
htmlgraph/repo_hash.py,sha256=wYQlgYf0W1LfU95PA6vViA-MlUxA6ry_xDh7kyCB4v4,14888
|
|
@@ -79,6 +83,7 @@ htmlgraph/analytics/cost_reporter.py,sha256=_KumnYUYTr3yEHwBGGgjXSHR5IGPLDqjfuZH
|
|
|
79
83
|
htmlgraph/analytics/cross_session.py,sha256=sD_i209uMdKzGhHb3sX7QCll-0E4JwZiZYlbcuNzdgo,20590
|
|
80
84
|
htmlgraph/analytics/dependency.py,sha256=DhO5XGIZXg83w7wRw8QKSTRFyU0CwKvK9QixJ-PlLHU,25835
|
|
81
85
|
htmlgraph/analytics/pattern_learning.py,sha256=TVP-kSzM7i9PCeNUYhxv8L4UUm37baunKzUFz0lUays,26689
|
|
86
|
+
htmlgraph/analytics/session_graph.py,sha256=3zeNH3MimcSDj5j43c2OdVh42xWTPRw6iq5xdaodR2c,23197
|
|
82
87
|
htmlgraph/analytics/work_type.py,sha256=fjCEbCaHEhhzp3iyOQdOd1id5SAlnc8Y5NEhP5g5hug,19413
|
|
83
88
|
htmlgraph/analytics/strategic/__init__.py,sha256=qAOjBJIrZvrGg3egKIiZDW8ZHL4OqVKUTrmX1RElHZk,2103
|
|
84
89
|
htmlgraph/analytics/strategic/cost_optimizer.py,sha256=X7HuBUDkPT0-4lf6uuaCXDK0N7dOGpFYyBUgRwSYwYk,19460
|
|
@@ -87,7 +92,7 @@ htmlgraph/analytics/strategic/preference_manager.py,sha256=uO8cX-kaK7EIJh-ovXh5c
|
|
|
87
92
|
htmlgraph/analytics/strategic/suggestion_engine.py,sha256=KV43DF6PETJkmrMA_Ni34heSdQqS_GXKeeGsq5zO6ck,26093
|
|
88
93
|
htmlgraph/api/__init__.py,sha256=PPFJ5QSv-Zei8U0yGOSs8dJKtUQMeloEqsxVBfzNbbA,105
|
|
89
94
|
htmlgraph/api/cost_alerts_websocket.py,sha256=6e_2pKn0-Hcz2ClFyNuJEhVBffqmcUBOkbseSgSkAqQ,14839
|
|
90
|
-
htmlgraph/api/main.py,sha256=
|
|
95
|
+
htmlgraph/api/main.py,sha256=rQ9758JJ3qqabr8qmQDWuW2T7XlfnDWdRCydPyD9R6o,98121
|
|
91
96
|
htmlgraph/api/websocket.py,sha256=L8a_p9cSLdmG1dcVQLZSfxS994Dm-caNPnYQWZ_a-Z0,17592
|
|
92
97
|
htmlgraph/api/static/htmx.min.js,sha256=0VEHzH8ECp6DsbZhdv2SetQLXgJVgToD-Mz-7UbuQrA,48036
|
|
93
98
|
htmlgraph/api/static/style-redesign.css,sha256=zp0ggDJHz81FN5SW5tRcWXPK_jVNCAJyqKhwlZkBfnQ,26759
|
|
@@ -321,12 +326,12 @@ htmlgraph/templates/AGENTS.md.template,sha256=f96h7V6ygwj-v-fanVI48eYMxR6t_se4be
|
|
|
321
326
|
htmlgraph/templates/CLAUDE.md.template,sha256=h1kG2hTX2XYig2KszsHBfzrwa_4Cfcq2Pj4SwqzeDlM,1984
|
|
322
327
|
htmlgraph/templates/GEMINI.md.template,sha256=gAGzE53Avki87BM_otqy5HdcYCoLsHgqaKjVzNzPMX8,1622
|
|
323
328
|
htmlgraph/templates/orchestration-view.html,sha256=DlS7LlcjH0oO_KYILjuF1X42t8QhKLH4F85rkO54alY,10472
|
|
324
|
-
htmlgraph-0.27.
|
|
325
|
-
htmlgraph-0.27.
|
|
326
|
-
htmlgraph-0.27.
|
|
327
|
-
htmlgraph-0.27.
|
|
328
|
-
htmlgraph-0.27.
|
|
329
|
-
htmlgraph-0.27.
|
|
330
|
-
htmlgraph-0.27.
|
|
331
|
-
htmlgraph-0.27.
|
|
332
|
-
htmlgraph-0.27.
|
|
329
|
+
htmlgraph-0.27.3.data/data/htmlgraph/dashboard.html,sha256=MUT6SaYnazoyDcvHz5hN1omYswyIoUfeoZLf2M_iblo,251268
|
|
330
|
+
htmlgraph-0.27.3.data/data/htmlgraph/styles.css,sha256=oDUSC8jG-V-hKojOBO9J88hxAeY2wJrBYTq0uCwX_Y4,7135
|
|
331
|
+
htmlgraph-0.27.3.data/data/htmlgraph/templates/AGENTS.md.template,sha256=f96h7V6ygwj-v-fanVI48eYMxR6t_se4bet1H4ZsDpI,7642
|
|
332
|
+
htmlgraph-0.27.3.data/data/htmlgraph/templates/CLAUDE.md.template,sha256=h1kG2hTX2XYig2KszsHBfzrwa_4Cfcq2Pj4SwqzeDlM,1984
|
|
333
|
+
htmlgraph-0.27.3.data/data/htmlgraph/templates/GEMINI.md.template,sha256=gAGzE53Avki87BM_otqy5HdcYCoLsHgqaKjVzNzPMX8,1622
|
|
334
|
+
htmlgraph-0.27.3.dist-info/METADATA,sha256=0pT6ydHGg3sccbKDYNDSzel1xXtjFH9-KwYnBxIQhsI,10220
|
|
335
|
+
htmlgraph-0.27.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
336
|
+
htmlgraph-0.27.3.dist-info/entry_points.txt,sha256=Wmdo5cx8pt6NoMsssVE2mZH1CZLSUsrg_3iSWatiyn0,103
|
|
337
|
+
htmlgraph-0.27.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|