htmlgraph 0.9.3__py3-none-any.whl → 0.27.5__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/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/.htmlgraph/agents.json +72 -0
- htmlgraph/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/__init__.py +173 -17
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +127 -0
- htmlgraph/agent_registry.py +45 -30
- htmlgraph/agents.py +160 -107
- htmlgraph/analytics/__init__.py +9 -2
- htmlgraph/analytics/cli.py +190 -51
- htmlgraph/analytics/cost_analyzer.py +391 -0
- htmlgraph/analytics/cost_monitor.py +664 -0
- htmlgraph/analytics/cost_reporter.py +675 -0
- htmlgraph/analytics/cross_session.py +617 -0
- htmlgraph/analytics/dependency.py +192 -100
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/analytics/strategic/__init__.py +80 -0
- htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
- htmlgraph/analytics/strategic/pattern_detector.py +876 -0
- htmlgraph/analytics/strategic/preference_manager.py +709 -0
- htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
- htmlgraph/analytics/work_type.py +190 -14
- htmlgraph/analytics_index.py +135 -51
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/cost_alerts_websocket.py +416 -0
- htmlgraph/api/main.py +2498 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +1366 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1100 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +578 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +198 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/api/websocket.py +538 -0
- htmlgraph/archive/__init__.py +24 -0
- htmlgraph/archive/bloom.py +234 -0
- htmlgraph/archive/fts.py +297 -0
- htmlgraph/archive/manager.py +583 -0
- htmlgraph/archive/search.py +244 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/attribute_index.py +208 -0
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/__init__.py +14 -0
- htmlgraph/builders/base.py +118 -29
- htmlgraph/builders/bug.py +150 -0
- htmlgraph/builders/chore.py +119 -0
- htmlgraph/builders/epic.py +150 -0
- htmlgraph/builders/feature.py +31 -6
- htmlgraph/builders/insight.py +195 -0
- htmlgraph/builders/metric.py +217 -0
- htmlgraph/builders/pattern.py +202 -0
- htmlgraph/builders/phase.py +162 -0
- htmlgraph/builders/spike.py +52 -19
- htmlgraph/builders/track.py +148 -72
- htmlgraph/cigs/__init__.py +81 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +1424 -0
- htmlgraph/cli/base.py +685 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +954 -0
- htmlgraph/cli/main.py +147 -0
- htmlgraph/cli/models.py +475 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +399 -0
- htmlgraph/cli/work/__init__.py +239 -0
- htmlgraph/cli/work/browse.py +115 -0
- htmlgraph/cli/work/features.py +568 -0
- htmlgraph/cli/work/orchestration.py +676 -0
- htmlgraph/cli/work/report.py +728 -0
- htmlgraph/cli/work/sessions.py +466 -0
- htmlgraph/cli/work/snapshot.py +559 -0
- htmlgraph/cli/work/tracks.py +486 -0
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +18 -0
- htmlgraph/collections/base.py +415 -98
- htmlgraph/collections/bug.py +53 -0
- htmlgraph/collections/chore.py +53 -0
- htmlgraph/collections/epic.py +53 -0
- htmlgraph/collections/feature.py +12 -26
- htmlgraph/collections/insight.py +100 -0
- htmlgraph/collections/metric.py +92 -0
- htmlgraph/collections/pattern.py +97 -0
- htmlgraph/collections/phase.py +53 -0
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +56 -16
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +511 -0
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +344 -0
- htmlgraph/converter.py +216 -28
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2406 -307
- htmlgraph/dashboard.html.backup +6592 -0
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1788 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/dependency_models.py +19 -2
- htmlgraph/deploy.py +142 -125
- htmlgraph/deployment_models.py +474 -0
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
- htmlgraph/docs/README.md +532 -0
- htmlgraph/docs/__init__.py +77 -0
- htmlgraph/docs/docs_version.py +55 -0
- htmlgraph/docs/metadata.py +93 -0
- htmlgraph/docs/migrations.py +232 -0
- htmlgraph/docs/template_engine.py +143 -0
- htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
- htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
- htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
- htmlgraph/docs/templates/base_agents.md.j2 +78 -0
- htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
- htmlgraph/docs/version_check.py +163 -0
- htmlgraph/edge_index.py +182 -27
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +100 -52
- htmlgraph/event_migration.py +13 -4
- htmlgraph/exceptions.py +49 -0
- htmlgraph/file_watcher.py +101 -28
- htmlgraph/find_api.py +75 -63
- htmlgraph/git_events.py +145 -63
- htmlgraph/graph.py +1122 -106
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/__init__.py +45 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +350 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +1314 -0
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/hooks-config.example.json +12 -0
- htmlgraph/hooks/installer.py +343 -0
- htmlgraph/hooks/orchestrator.py +674 -0
- htmlgraph/hooks/orchestrator_reflector.py +223 -0
- htmlgraph/hooks/post-checkout.sh +28 -0
- htmlgraph/hooks/post-commit.sh +24 -0
- htmlgraph/hooks/post-merge.sh +26 -0
- htmlgraph/hooks/post_tool_use_failure.py +273 -0
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +408 -0
- htmlgraph/hooks/pre-commit.sh +94 -0
- htmlgraph/hooks/pre-push.sh +28 -0
- htmlgraph/hooks/pretooluse.py +819 -0
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +668 -0
- htmlgraph/hooks/session_summary.py +395 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +369 -0
- htmlgraph/hooks/task_enforcer.py +255 -0
- htmlgraph/hooks/task_validator.py +177 -0
- htmlgraph/hooks/validator.py +628 -0
- htmlgraph/ids.py +41 -27
- htmlgraph/index.d.ts +286 -0
- htmlgraph/learning.py +767 -0
- htmlgraph/mcp_server.py +69 -23
- htmlgraph/models.py +1586 -87
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +79 -0
- htmlgraph/operations/analytics.py +339 -0
- htmlgraph/operations/bootstrap.py +289 -0
- htmlgraph/operations/events.py +244 -0
- htmlgraph/operations/fastapi_server.py +231 -0
- htmlgraph/operations/hooks.py +350 -0
- htmlgraph/operations/initialization.py +597 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/operations/server.py +303 -0
- htmlgraph/orchestration/__init__.py +58 -0
- htmlgraph/orchestration/claude_launcher.py +179 -0
- htmlgraph/orchestration/command_builder.py +72 -0
- htmlgraph/orchestration/headless_spawner.py +281 -0
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/orchestration/model_selection.py +327 -0
- htmlgraph/orchestration/plugin_manager.py +140 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +173 -0
- htmlgraph/orchestration/spawners/codex.py +435 -0
- htmlgraph/orchestration/spawners/copilot.py +294 -0
- htmlgraph/orchestration/spawners/gemini.py +471 -0
- htmlgraph/orchestration/subprocess_runner.py +36 -0
- htmlgraph/orchestration/task_coordination.py +343 -0
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +669 -0
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +328 -0
- htmlgraph/orchestrator_validator.py +133 -0
- htmlgraph/parallel.py +646 -0
- htmlgraph/parser.py +160 -35
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/planning.py +147 -52
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +109 -72
- htmlgraph/query_composer.py +509 -0
- htmlgraph/reflection.py +443 -0
- htmlgraph/refs.py +344 -0
- htmlgraph/repo_hash.py +512 -0
- htmlgraph/repositories/__init__.py +292 -0
- htmlgraph/repositories/analytics_repository.py +455 -0
- htmlgraph/repositories/analytics_repository_standard.py +628 -0
- htmlgraph/repositories/feature_repository.py +581 -0
- htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
- htmlgraph/repositories/feature_repository_memory.py +607 -0
- htmlgraph/repositories/feature_repository_sqlite.py +858 -0
- htmlgraph/repositories/filter_service.py +620 -0
- htmlgraph/repositories/filter_service_standard.py +445 -0
- htmlgraph/repositories/shared_cache.py +621 -0
- htmlgraph/repositories/shared_cache_memory.py +395 -0
- htmlgraph/repositories/track_repository.py +552 -0
- htmlgraph/repositories/track_repository_htmlfile.py +619 -0
- htmlgraph/repositories/track_repository_memory.py +508 -0
- htmlgraph/repositories/track_repository_sqlite.py +711 -0
- htmlgraph/routing.py +8 -19
- htmlgraph/scripts/deploy.py +1 -2
- htmlgraph/sdk/__init__.py +398 -0
- htmlgraph/sdk/__init__.pyi +14 -0
- htmlgraph/sdk/analytics/__init__.py +19 -0
- htmlgraph/sdk/analytics/engine.py +155 -0
- htmlgraph/sdk/analytics/helpers.py +178 -0
- htmlgraph/sdk/analytics/registry.py +109 -0
- htmlgraph/sdk/base.py +484 -0
- htmlgraph/sdk/constants.py +216 -0
- htmlgraph/sdk/core.pyi +308 -0
- htmlgraph/sdk/discovery.py +120 -0
- htmlgraph/sdk/help/__init__.py +12 -0
- htmlgraph/sdk/help/mixin.py +699 -0
- htmlgraph/sdk/mixins/__init__.py +15 -0
- htmlgraph/sdk/mixins/attribution.py +113 -0
- htmlgraph/sdk/mixins/mixin.py +410 -0
- htmlgraph/sdk/operations/__init__.py +12 -0
- htmlgraph/sdk/operations/mixin.py +427 -0
- htmlgraph/sdk/orchestration/__init__.py +17 -0
- htmlgraph/sdk/orchestration/coordinator.py +203 -0
- htmlgraph/sdk/orchestration/spawner.py +204 -0
- htmlgraph/sdk/planning/__init__.py +19 -0
- htmlgraph/sdk/planning/bottlenecks.py +93 -0
- htmlgraph/sdk/planning/mixin.py +211 -0
- htmlgraph/sdk/planning/parallel.py +186 -0
- htmlgraph/sdk/planning/queue.py +210 -0
- htmlgraph/sdk/planning/recommendations.py +87 -0
- htmlgraph/sdk/planning/smart_planning.py +319 -0
- htmlgraph/sdk/session/__init__.py +19 -0
- htmlgraph/sdk/session/continuity.py +57 -0
- htmlgraph/sdk/session/handoff.py +110 -0
- htmlgraph/sdk/session/info.py +309 -0
- htmlgraph/sdk/session/manager.py +103 -0
- htmlgraph/sdk/strategic/__init__.py +26 -0
- htmlgraph/sdk/strategic/mixin.py +563 -0
- htmlgraph/server.py +685 -180
- htmlgraph/services/__init__.py +10 -0
- htmlgraph/services/claiming.py +199 -0
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +1392 -175
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +201 -0
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/setup.py +34 -17
- htmlgraph/spike_index.py +143 -0
- htmlgraph/sync_docs.py +12 -15
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/AGENTS.md.template +366 -0
- htmlgraph/templates/CLAUDE.md.template +97 -0
- htmlgraph/templates/GEMINI.md.template +87 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +146 -15
- htmlgraph/track_manager.py +69 -21
- htmlgraph/transcript.py +890 -0
- htmlgraph/transcript_analytics.py +699 -0
- htmlgraph/types.py +323 -0
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +8 -5
- htmlgraph/work_type_utils.py +3 -2
- {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2406 -307
- htmlgraph-0.27.5.data/data/htmlgraph/templates/AGENTS.md.template +366 -0
- htmlgraph-0.27.5.data/data/htmlgraph/templates/CLAUDE.md.template +97 -0
- htmlgraph-0.27.5.data/data/htmlgraph/templates/GEMINI.md.template +87 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +97 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -2688
- htmlgraph/sdk.py +0 -709
- htmlgraph-0.9.3.dist-info/RECORD +0 -61
- {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
htmlgraph/path_query.py
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PathQuery DSL - Declarative Path Expression Language for HtmlGraph.
|
|
3
|
+
|
|
4
|
+
Provides a SQL/PGQ-inspired MATCH syntax for expressing graph traversal
|
|
5
|
+
patterns as declarative path expressions. Compiles path expressions into
|
|
6
|
+
calls against existing HtmlGraph graph methods and edge index lookups.
|
|
7
|
+
|
|
8
|
+
Syntax examples:
|
|
9
|
+
# Single-hop: find all features blocked by another feature
|
|
10
|
+
"(Feature)-[blocked_by]->(Feature)"
|
|
11
|
+
|
|
12
|
+
# Variable-length: find all transitive dependencies
|
|
13
|
+
"(Feature)-[depends_on]->+(Feature)"
|
|
14
|
+
|
|
15
|
+
# With node filter: find blocked high-priority features
|
|
16
|
+
"(Feature WHERE status='blocked')-[blocked_by]->(Feature WHERE priority='high')"
|
|
17
|
+
|
|
18
|
+
# Reverse direction
|
|
19
|
+
"(Feature)<-[blocks]-(Feature)"
|
|
20
|
+
|
|
21
|
+
# Multi-hop pattern
|
|
22
|
+
"(Session)-[touches]->(Feature)<-[blocked_by]-(Feature)"
|
|
23
|
+
|
|
24
|
+
# Any shortest path
|
|
25
|
+
"(Feature)-[blocked_by]->*(Feature)"
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
from htmlgraph.path_query import PathQueryEngine
|
|
29
|
+
|
|
30
|
+
engine = PathQueryEngine()
|
|
31
|
+
results = engine.execute(graph, "(Feature)-[blocked_by]->(Feature)")
|
|
32
|
+
for result in results:
|
|
33
|
+
print(result.nodes, result.path_length)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import re
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from typing import TYPE_CHECKING
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from htmlgraph.graph import HtmlGraph
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Data models
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class WhereClause:
|
|
53
|
+
"""A single WHERE filter condition on a node pattern.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
attribute: The node attribute name to filter on (e.g. 'status', 'priority').
|
|
57
|
+
value: The expected value (string comparison).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
attribute: str
|
|
61
|
+
value: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class NodePattern:
|
|
66
|
+
"""Parsed representation of a node pattern in a path expression.
|
|
67
|
+
|
|
68
|
+
A node pattern looks like ``(Label)`` or ``(Label WHERE attr='val')``.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
label: Optional node type label (e.g. 'Feature', 'Session').
|
|
72
|
+
An empty string means *any* node type.
|
|
73
|
+
filters: List of WHERE clause conditions to apply.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
label: str = ""
|
|
77
|
+
filters: list[WhereClause] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class EdgePattern:
|
|
82
|
+
"""Parsed representation of an edge pattern in a path expression.
|
|
83
|
+
|
|
84
|
+
An edge pattern looks like ``-[rel_type]->`` or ``<-[rel_type]-``.
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
relationship: The edge relationship type (e.g. 'blocked_by').
|
|
88
|
+
direction: ``'outgoing'`` for ``->``, ``'incoming'`` for ``<-``.
|
|
89
|
+
quantifier: ``None`` for single-hop, ``'+'`` for one-or-more
|
|
90
|
+
(transitive), ``'*'`` for zero-or-more (shortest path),
|
|
91
|
+
``'?'`` for zero-or-one (optional).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
relationship: str
|
|
95
|
+
direction: str = "outgoing" # "outgoing" (->) or "incoming" (<-)
|
|
96
|
+
quantifier: str | None = None # None, "+", "*", "?"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class PathExpression:
|
|
101
|
+
"""Fully parsed path expression.
|
|
102
|
+
|
|
103
|
+
A path expression is an alternating sequence of *node patterns* and
|
|
104
|
+
*edge patterns*: ``node edge node [edge node ...]``.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
nodes: Ordered list of node patterns.
|
|
108
|
+
edges: Ordered list of edge patterns. ``len(edges) == len(nodes) - 1``.
|
|
109
|
+
raw: The original expression string.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
nodes: list[NodePattern] = field(default_factory=list)
|
|
113
|
+
edges: list[EdgePattern] = field(default_factory=list)
|
|
114
|
+
raw: str = ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class PathResult:
|
|
119
|
+
"""A single result from executing a path query.
|
|
120
|
+
|
|
121
|
+
Attributes:
|
|
122
|
+
nodes: The list of node IDs that form the matched path.
|
|
123
|
+
path_length: Number of hops (edges) in the path.
|
|
124
|
+
bindings: Mapping from pattern index to matched node ID(s).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
nodes: list[str] = field(default_factory=list)
|
|
128
|
+
path_length: int = 0
|
|
129
|
+
bindings: dict[int, list[str]] = field(default_factory=dict)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Parser
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
# Regex patterns for tokenising the DSL string.
|
|
137
|
+
_NODE_PATTERN = re.compile(
|
|
138
|
+
r"""\(
|
|
139
|
+
\s*
|
|
140
|
+
(?P<label>[A-Za-z_][A-Za-z0-9_]*)? # optional label
|
|
141
|
+
\s*
|
|
142
|
+
(?:WHERE\s+(?P<where>.+?))? # optional WHERE clause
|
|
143
|
+
\s*
|
|
144
|
+
\)""",
|
|
145
|
+
re.VERBOSE,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
_WHERE_CONDITION = re.compile(
|
|
149
|
+
r"""(?P<attr>[A-Za-z_][A-Za-z0-9_.]*)\s*=\s*'(?P<val>[^']*)'""",
|
|
150
|
+
re.VERBOSE,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Edge patterns. Arrows can appear in two forms:
|
|
154
|
+
# outgoing: -[rel]-> with optional quantifier after >
|
|
155
|
+
# incoming: <-[rel]- with optional quantifier after second -
|
|
156
|
+
_EDGE_OUTGOING = re.compile(
|
|
157
|
+
r"""-\[
|
|
158
|
+
\s*(?P<rel>[A-Za-z_][A-Za-z0-9_]*)\s*
|
|
159
|
+
\]->(?P<quant>[+*?])?""",
|
|
160
|
+
re.VERBOSE,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
_EDGE_INCOMING = re.compile(
|
|
164
|
+
r"""<-\[
|
|
165
|
+
\s*(?P<rel>[A-Za-z_][A-Za-z0-9_]*)\s*
|
|
166
|
+
\]-(?P<quant>[+*?])?""",
|
|
167
|
+
re.VERBOSE,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class PathQueryError(Exception):
|
|
172
|
+
"""Raised when a path expression cannot be parsed or executed."""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class PathQueryParser:
|
|
176
|
+
"""Parses a path expression string into a :class:`PathExpression`.
|
|
177
|
+
|
|
178
|
+
The parser works by tokenising the input from left to right, alternating
|
|
179
|
+
between node patterns and edge patterns.
|
|
180
|
+
|
|
181
|
+
Example::
|
|
182
|
+
|
|
183
|
+
parser = PathQueryParser()
|
|
184
|
+
expr = parser.parse("(Feature)-[blocked_by]->(Feature)")
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def parse(self, expression: str) -> PathExpression:
|
|
188
|
+
"""Parse a path expression string.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
expression: The path expression DSL string.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
A fully parsed :class:`PathExpression`.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
PathQueryError: If the expression is syntactically invalid.
|
|
198
|
+
"""
|
|
199
|
+
expression = expression.strip()
|
|
200
|
+
if not expression:
|
|
201
|
+
raise PathQueryError("Empty path expression")
|
|
202
|
+
|
|
203
|
+
result = PathExpression(raw=expression)
|
|
204
|
+
pos = 0
|
|
205
|
+
expecting_node = True
|
|
206
|
+
|
|
207
|
+
while pos < len(expression):
|
|
208
|
+
# Skip whitespace
|
|
209
|
+
while pos < len(expression) and expression[pos].isspace():
|
|
210
|
+
pos += 1
|
|
211
|
+
if pos >= len(expression):
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
if expecting_node:
|
|
215
|
+
node, end = self._parse_node(expression, pos)
|
|
216
|
+
result.nodes.append(node)
|
|
217
|
+
pos = end
|
|
218
|
+
expecting_node = False
|
|
219
|
+
else:
|
|
220
|
+
edge, end = self._parse_edge(expression, pos)
|
|
221
|
+
result.edges.append(edge)
|
|
222
|
+
pos = end
|
|
223
|
+
expecting_node = True
|
|
224
|
+
|
|
225
|
+
# Validate structure: must have at least 2 nodes and 1 edge
|
|
226
|
+
if len(result.nodes) < 2:
|
|
227
|
+
raise PathQueryError(
|
|
228
|
+
f"Path expression must have at least two node patterns "
|
|
229
|
+
f"and one edge pattern, got {len(result.nodes)} node(s) "
|
|
230
|
+
f"and {len(result.edges)} edge(s): {expression!r}"
|
|
231
|
+
)
|
|
232
|
+
if len(result.edges) != len(result.nodes) - 1:
|
|
233
|
+
raise PathQueryError(
|
|
234
|
+
f"Mismatched nodes and edges: {len(result.nodes)} nodes, "
|
|
235
|
+
f"{len(result.edges)} edges in: {expression!r}"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
# -- private helpers ---------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def _parse_node(self, expr: str, pos: int) -> tuple[NodePattern, int]:
|
|
243
|
+
"""Parse a node pattern starting at *pos*.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Tuple of (NodePattern, end_position).
|
|
247
|
+
"""
|
|
248
|
+
m = _NODE_PATTERN.match(expr, pos)
|
|
249
|
+
if not m:
|
|
250
|
+
raise PathQueryError(
|
|
251
|
+
f"Expected node pattern at position {pos}: {expr[pos : pos + 30]!r}..."
|
|
252
|
+
)
|
|
253
|
+
label = m.group("label") or ""
|
|
254
|
+
where_str = m.group("where") or ""
|
|
255
|
+
filters = self._parse_where(where_str)
|
|
256
|
+
return NodePattern(label=label, filters=filters), m.end()
|
|
257
|
+
|
|
258
|
+
def _parse_edge(self, expr: str, pos: int) -> tuple[EdgePattern, int]:
|
|
259
|
+
"""Parse an edge pattern starting at *pos*.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Tuple of (EdgePattern, end_position).
|
|
263
|
+
"""
|
|
264
|
+
# Try outgoing first
|
|
265
|
+
m = _EDGE_OUTGOING.match(expr, pos)
|
|
266
|
+
if m:
|
|
267
|
+
return (
|
|
268
|
+
EdgePattern(
|
|
269
|
+
relationship=m.group("rel"),
|
|
270
|
+
direction="outgoing",
|
|
271
|
+
quantifier=m.group("quant") or None,
|
|
272
|
+
),
|
|
273
|
+
m.end(),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Try incoming
|
|
277
|
+
m = _EDGE_INCOMING.match(expr, pos)
|
|
278
|
+
if m:
|
|
279
|
+
return (
|
|
280
|
+
EdgePattern(
|
|
281
|
+
relationship=m.group("rel"),
|
|
282
|
+
direction="incoming",
|
|
283
|
+
quantifier=m.group("quant") or None,
|
|
284
|
+
),
|
|
285
|
+
m.end(),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
raise PathQueryError(
|
|
289
|
+
f"Expected edge pattern at position {pos}: {expr[pos : pos + 30]!r}..."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _parse_where(where_str: str) -> list[WhereClause]:
|
|
294
|
+
"""Parse the body of a WHERE clause into a list of conditions.
|
|
295
|
+
|
|
296
|
+
Supports ``AND``-separated conditions like
|
|
297
|
+
``status='blocked' AND priority='high'``.
|
|
298
|
+
"""
|
|
299
|
+
if not where_str.strip():
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
clauses: list[WhereClause] = []
|
|
303
|
+
# Split on AND (case-insensitive)
|
|
304
|
+
parts = re.split(r"\s+AND\s+", where_str, flags=re.IGNORECASE)
|
|
305
|
+
for part in parts:
|
|
306
|
+
m = _WHERE_CONDITION.match(part.strip())
|
|
307
|
+
if not m:
|
|
308
|
+
raise PathQueryError(
|
|
309
|
+
f"Invalid WHERE condition: {part.strip()!r}. "
|
|
310
|
+
f"Expected format: attribute='value'"
|
|
311
|
+
)
|
|
312
|
+
clauses.append(WhereClause(attribute=m.group("attr"), value=m.group("val")))
|
|
313
|
+
return clauses
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# Engine
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class PathQueryEngine:
|
|
322
|
+
"""Executes parsed :class:`PathExpression` objects against an HtmlGraph.
|
|
323
|
+
|
|
324
|
+
The engine maps DSL constructs to existing HtmlGraph operations:
|
|
325
|
+
|
|
326
|
+
* **Single-hop** edges use ``EdgeIndex.get_outgoing`` /
|
|
327
|
+
``EdgeIndex.get_incoming`` for O(1) neighbour lookups.
|
|
328
|
+
* **Variable-length ``+``** (one-or-more) uses
|
|
329
|
+
``HtmlGraph.transitive_deps`` or ``HtmlGraph.ancestors``.
|
|
330
|
+
* **Variable-length ``*``** (zero-or-more / shortest) uses
|
|
331
|
+
``HtmlGraph.shortest_path``.
|
|
332
|
+
* **Optional ``?``** returns the direct hop if it exists, or the
|
|
333
|
+
source node alone otherwise.
|
|
334
|
+
|
|
335
|
+
Example::
|
|
336
|
+
|
|
337
|
+
engine = PathQueryEngine()
|
|
338
|
+
results = engine.execute(graph, "(Feature)-[blocked_by]->(Feature)")
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
def __init__(self) -> None:
|
|
342
|
+
self._parser = PathQueryParser()
|
|
343
|
+
|
|
344
|
+
def execute(
|
|
345
|
+
self, graph: HtmlGraph, expression: str | PathExpression
|
|
346
|
+
) -> list[PathResult]:
|
|
347
|
+
"""Execute a path expression against a graph.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
graph: The :class:`HtmlGraph` instance to query.
|
|
351
|
+
expression: Either a DSL string or a pre-parsed
|
|
352
|
+
:class:`PathExpression`.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
List of :class:`PathResult` objects for every matching path.
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
PathQueryError: If the expression is invalid or cannot be
|
|
359
|
+
executed.
|
|
360
|
+
"""
|
|
361
|
+
if isinstance(expression, str):
|
|
362
|
+
expr = self._parser.parse(expression)
|
|
363
|
+
else:
|
|
364
|
+
expr = expression
|
|
365
|
+
|
|
366
|
+
# Ensure graph data is loaded
|
|
367
|
+
graph._ensure_loaded() # noqa: SLF001 — accessing private for lazy-load
|
|
368
|
+
|
|
369
|
+
# Seed: find all candidate start nodes
|
|
370
|
+
start_candidates = self._match_node_pattern(graph, expr.nodes[0])
|
|
371
|
+
|
|
372
|
+
# Walk each hop, expanding candidates
|
|
373
|
+
results: list[PathResult] = []
|
|
374
|
+
for start_id in start_candidates:
|
|
375
|
+
partial_paths: list[list[str]] = [[start_id]]
|
|
376
|
+
for hop_idx, edge_pat in enumerate(expr.edges):
|
|
377
|
+
target_node_pat = expr.nodes[hop_idx + 1]
|
|
378
|
+
next_partial: list[list[str]] = []
|
|
379
|
+
|
|
380
|
+
for path in partial_paths:
|
|
381
|
+
current_id = path[-1]
|
|
382
|
+
expanded = self._expand_hop(
|
|
383
|
+
graph, current_id, edge_pat, target_node_pat
|
|
384
|
+
)
|
|
385
|
+
for ext in expanded:
|
|
386
|
+
next_partial.append(path + ext)
|
|
387
|
+
|
|
388
|
+
partial_paths = next_partial
|
|
389
|
+
|
|
390
|
+
# Convert successful full paths to PathResult
|
|
391
|
+
for path in partial_paths:
|
|
392
|
+
results.append(
|
|
393
|
+
PathResult(
|
|
394
|
+
nodes=path,
|
|
395
|
+
path_length=len(path) - 1,
|
|
396
|
+
bindings={i: [nid] for i, nid in enumerate(path)},
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
return results
|
|
401
|
+
|
|
402
|
+
# -- private helpers ---------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def _match_node_pattern(self, graph: HtmlGraph, pattern: NodePattern) -> list[str]:
|
|
405
|
+
"""Return IDs of all nodes matching a :class:`NodePattern`."""
|
|
406
|
+
candidates: list[str] = []
|
|
407
|
+
for node_id, node in graph.nodes.items():
|
|
408
|
+
if pattern.label and node.type.lower() != pattern.label.lower():
|
|
409
|
+
continue
|
|
410
|
+
if not self._check_filters(node, pattern.filters):
|
|
411
|
+
continue
|
|
412
|
+
candidates.append(node_id)
|
|
413
|
+
return candidates
|
|
414
|
+
|
|
415
|
+
@staticmethod
|
|
416
|
+
def _check_filters(node: object, filters: list[WhereClause]) -> bool:
|
|
417
|
+
"""Check whether *node* satisfies all WHERE filters."""
|
|
418
|
+
for f in filters:
|
|
419
|
+
val = _resolve_attribute(node, f.attribute)
|
|
420
|
+
if val is None or str(val) != f.value:
|
|
421
|
+
return False
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
def _expand_hop(
|
|
425
|
+
self,
|
|
426
|
+
graph: HtmlGraph,
|
|
427
|
+
current_id: str,
|
|
428
|
+
edge_pat: EdgePattern,
|
|
429
|
+
target_pat: NodePattern,
|
|
430
|
+
) -> list[list[str]]:
|
|
431
|
+
"""Expand one hop from *current_id* following *edge_pat*.
|
|
432
|
+
|
|
433
|
+
Returns a list of path *extensions* (each a list of node IDs
|
|
434
|
+
**not** including *current_id* itself).
|
|
435
|
+
"""
|
|
436
|
+
quant = edge_pat.quantifier
|
|
437
|
+
|
|
438
|
+
if quant is None:
|
|
439
|
+
return self._expand_single(graph, current_id, edge_pat, target_pat)
|
|
440
|
+
elif quant == "+":
|
|
441
|
+
return self._expand_transitive(graph, current_id, edge_pat, target_pat)
|
|
442
|
+
elif quant == "*":
|
|
443
|
+
return self._expand_shortest(graph, current_id, edge_pat, target_pat)
|
|
444
|
+
elif quant == "?":
|
|
445
|
+
return self._expand_optional(graph, current_id, edge_pat, target_pat)
|
|
446
|
+
else:
|
|
447
|
+
raise PathQueryError(f"Unknown quantifier: {quant!r}")
|
|
448
|
+
|
|
449
|
+
# -- single hop --------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
def _expand_single(
|
|
452
|
+
self,
|
|
453
|
+
graph: HtmlGraph,
|
|
454
|
+
current_id: str,
|
|
455
|
+
edge_pat: EdgePattern,
|
|
456
|
+
target_pat: NodePattern,
|
|
457
|
+
) -> list[list[str]]:
|
|
458
|
+
"""Expand a single-hop (no quantifier) edge."""
|
|
459
|
+
neighbor_ids = self._direct_neighbors(graph, current_id, edge_pat)
|
|
460
|
+
|
|
461
|
+
extensions: list[list[str]] = []
|
|
462
|
+
for nid in neighbor_ids:
|
|
463
|
+
node = graph.get(nid)
|
|
464
|
+
if node is None:
|
|
465
|
+
continue
|
|
466
|
+
if target_pat.label and node.type.lower() != target_pat.label.lower():
|
|
467
|
+
continue
|
|
468
|
+
if not self._check_filters(node, target_pat.filters):
|
|
469
|
+
continue
|
|
470
|
+
extensions.append([nid])
|
|
471
|
+
return extensions
|
|
472
|
+
|
|
473
|
+
# -- transitive (one-or-more "+") --------------------------------------
|
|
474
|
+
|
|
475
|
+
def _expand_transitive(
|
|
476
|
+
self,
|
|
477
|
+
graph: HtmlGraph,
|
|
478
|
+
current_id: str,
|
|
479
|
+
edge_pat: EdgePattern,
|
|
480
|
+
target_pat: NodePattern,
|
|
481
|
+
) -> list[list[str]]:
|
|
482
|
+
"""Expand a variable-length ``+`` (one-or-more) edge.
|
|
483
|
+
|
|
484
|
+
Uses ``HtmlGraph.transitive_deps`` for outgoing edges and
|
|
485
|
+
``HtmlGraph.ancestors`` for incoming (reversed) edges.
|
|
486
|
+
"""
|
|
487
|
+
if edge_pat.direction == "outgoing":
|
|
488
|
+
reachable = graph.transitive_deps(current_id, edge_pat.relationship)
|
|
489
|
+
else:
|
|
490
|
+
reachable = set(graph.ancestors(current_id, edge_pat.relationship))
|
|
491
|
+
|
|
492
|
+
extensions: list[list[str]] = []
|
|
493
|
+
for nid in reachable:
|
|
494
|
+
node = graph.get(nid)
|
|
495
|
+
if node is None:
|
|
496
|
+
continue
|
|
497
|
+
if target_pat.label and node.type.lower() != target_pat.label.lower():
|
|
498
|
+
continue
|
|
499
|
+
if not self._check_filters(node, target_pat.filters):
|
|
500
|
+
continue
|
|
501
|
+
extensions.append([nid])
|
|
502
|
+
return extensions
|
|
503
|
+
|
|
504
|
+
# -- shortest (zero-or-more "*") ---------------------------------------
|
|
505
|
+
|
|
506
|
+
def _expand_shortest(
|
|
507
|
+
self,
|
|
508
|
+
graph: HtmlGraph,
|
|
509
|
+
current_id: str,
|
|
510
|
+
edge_pat: EdgePattern,
|
|
511
|
+
target_pat: NodePattern,
|
|
512
|
+
) -> list[list[str]]:
|
|
513
|
+
"""Expand ``*`` using ``HtmlGraph.shortest_path``.
|
|
514
|
+
|
|
515
|
+
Returns the shortest path to every reachable target node that
|
|
516
|
+
matches *target_pat*.
|
|
517
|
+
"""
|
|
518
|
+
# Collect all candidates matching the target pattern
|
|
519
|
+
target_ids = self._match_node_pattern(graph, target_pat)
|
|
520
|
+
|
|
521
|
+
extensions: list[list[str]] = []
|
|
522
|
+
for tid in target_ids:
|
|
523
|
+
if tid == current_id:
|
|
524
|
+
# zero-length path (allowed by *)
|
|
525
|
+
extensions.append([tid])
|
|
526
|
+
continue
|
|
527
|
+
|
|
528
|
+
path = graph.shortest_path(
|
|
529
|
+
current_id, tid, relationship=edge_pat.relationship
|
|
530
|
+
)
|
|
531
|
+
if path is not None and len(path) >= 2:
|
|
532
|
+
# path includes current_id as first element; strip it
|
|
533
|
+
extensions.append(path[1:])
|
|
534
|
+
|
|
535
|
+
return extensions
|
|
536
|
+
|
|
537
|
+
# -- optional (zero-or-one "?") ----------------------------------------
|
|
538
|
+
|
|
539
|
+
def _expand_optional(
|
|
540
|
+
self,
|
|
541
|
+
graph: HtmlGraph,
|
|
542
|
+
current_id: str,
|
|
543
|
+
edge_pat: EdgePattern,
|
|
544
|
+
target_pat: NodePattern,
|
|
545
|
+
) -> list[list[str]]:
|
|
546
|
+
"""Expand ``?`` — match zero or one hop.
|
|
547
|
+
|
|
548
|
+
Returns direct-hop matches plus *current_id* itself if it matches
|
|
549
|
+
the target pattern (zero-hop case).
|
|
550
|
+
"""
|
|
551
|
+
results = self._expand_single(graph, current_id, edge_pat, target_pat)
|
|
552
|
+
|
|
553
|
+
# Zero-hop: current node must match target pattern
|
|
554
|
+
current_node = graph.get(current_id)
|
|
555
|
+
if current_node is not None:
|
|
556
|
+
matches = True
|
|
557
|
+
if (
|
|
558
|
+
target_pat.label
|
|
559
|
+
and current_node.type.lower() != target_pat.label.lower()
|
|
560
|
+
):
|
|
561
|
+
matches = False
|
|
562
|
+
if not self._check_filters(current_node, target_pat.filters):
|
|
563
|
+
matches = False
|
|
564
|
+
if matches:
|
|
565
|
+
results.append([current_id])
|
|
566
|
+
|
|
567
|
+
return results
|
|
568
|
+
|
|
569
|
+
# -- helpers -----------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
@staticmethod
|
|
572
|
+
def _direct_neighbors(
|
|
573
|
+
graph: HtmlGraph,
|
|
574
|
+
node_id: str,
|
|
575
|
+
edge_pat: EdgePattern,
|
|
576
|
+
) -> list[str]:
|
|
577
|
+
"""Get direct neighbour IDs for a single-hop edge pattern."""
|
|
578
|
+
if edge_pat.direction == "outgoing":
|
|
579
|
+
refs = graph.edge_index.get_outgoing(node_id, edge_pat.relationship)
|
|
580
|
+
return [r.target_id for r in refs]
|
|
581
|
+
else:
|
|
582
|
+
refs = graph.edge_index.get_incoming(node_id, edge_pat.relationship)
|
|
583
|
+
return [r.source_id for r in refs]
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# ---------------------------------------------------------------------------
|
|
587
|
+
# Helpers
|
|
588
|
+
# ---------------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _resolve_attribute(node: object, attr: str) -> object | None:
|
|
592
|
+
"""Resolve a possibly-dotted attribute path on *node*.
|
|
593
|
+
|
|
594
|
+
Supports plain attributes (``status``), nested dotted paths
|
|
595
|
+
(``properties.effort``), and dictionary access.
|
|
596
|
+
"""
|
|
597
|
+
parts = attr.split(".")
|
|
598
|
+
current: object = node
|
|
599
|
+
for part in parts:
|
|
600
|
+
if current is None:
|
|
601
|
+
return None
|
|
602
|
+
if hasattr(current, part):
|
|
603
|
+
current = getattr(current, part)
|
|
604
|
+
elif isinstance(current, dict):
|
|
605
|
+
current = current.get(part)
|
|
606
|
+
else:
|
|
607
|
+
return None
|
|
608
|
+
return current
|