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
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Graph Pattern Matching Engine for HtmlGraph.
|
|
3
|
+
|
|
4
|
+
Provides a declarative API inspired by SQL/PGQ's MATCH clause for finding
|
|
5
|
+
structural patterns across the graph. Patterns are built using a fluent
|
|
6
|
+
builder and executed against an HtmlGraph instance.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from htmlgraph.graph import HtmlGraph
|
|
10
|
+
from htmlgraph.pattern_matcher import GraphPattern
|
|
11
|
+
|
|
12
|
+
graph = HtmlGraph("features/", auto_load=True)
|
|
13
|
+
|
|
14
|
+
# Find features blocked by high-priority work
|
|
15
|
+
pattern = GraphPattern()
|
|
16
|
+
pattern.node("f1", label="feature", filters={"status": "blocked"})
|
|
17
|
+
pattern.edge("b", source="f1", target="f2", relationship="blocked_by")
|
|
18
|
+
pattern.node("f2", label="feature", filters={"priority": "high"})
|
|
19
|
+
|
|
20
|
+
results = pattern.match(graph)
|
|
21
|
+
for result in results:
|
|
22
|
+
blocker = result.get_node("f1")
|
|
23
|
+
blocking = result.get_node("f2")
|
|
24
|
+
print(f"{blocker.title} is blocked by {blocking.title}")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from htmlgraph.edge_index import EdgeRef
|
|
34
|
+
from htmlgraph.graph import HtmlGraph
|
|
35
|
+
from htmlgraph.models import Node
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class NodePattern:
|
|
40
|
+
"""
|
|
41
|
+
A pattern for matching a single graph node.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
variable: Binding variable name used to reference this node in
|
|
45
|
+
results and edge patterns (e.g., "f1", "src").
|
|
46
|
+
label: Optional node type filter. When set, only nodes whose
|
|
47
|
+
``type`` attribute matches this value are considered candidates.
|
|
48
|
+
filters: Optional attribute filters. Keys are attribute names
|
|
49
|
+
(supporting dot notation for nested access, e.g.,
|
|
50
|
+
"properties.effort") and values are expected values. All
|
|
51
|
+
filters must match for a node to be a candidate.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
variable: str
|
|
55
|
+
label: str | None = None
|
|
56
|
+
filters: dict[str, Any] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
def matches(self, node: Node) -> bool:
|
|
59
|
+
"""
|
|
60
|
+
Check whether a node satisfies this pattern.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
node: The node to check.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if the node matches the label and all attribute filters.
|
|
67
|
+
"""
|
|
68
|
+
# Check label (maps to node.type)
|
|
69
|
+
if self.label is not None and node.type != self.label:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
# Check attribute filters
|
|
73
|
+
for attr, expected in self.filters.items():
|
|
74
|
+
actual = _get_nested_attr(node, attr)
|
|
75
|
+
if actual != expected:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class EdgePattern:
|
|
83
|
+
"""
|
|
84
|
+
A pattern for matching a graph edge between two node variables.
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
variable: Binding variable name for this edge in results.
|
|
88
|
+
source: Variable name of the source node pattern.
|
|
89
|
+
target: Variable name of the target node pattern.
|
|
90
|
+
relationship: Optional edge relationship type filter.
|
|
91
|
+
direction: Edge traversal direction relative to the source node.
|
|
92
|
+
``"outgoing"`` means source -> target, ``"incoming"`` means
|
|
93
|
+
target -> source, ``"both"`` matches either direction.
|
|
94
|
+
quantifier: Edge repetition quantifier.
|
|
95
|
+
``"one"`` matches exactly one edge hop,
|
|
96
|
+
``"one_or_more"`` matches one or more hops (transitive),
|
|
97
|
+
``"zero_or_more"`` matches zero or more hops (reflexive transitive).
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
variable: str
|
|
101
|
+
source: str
|
|
102
|
+
target: str
|
|
103
|
+
relationship: str | None = None
|
|
104
|
+
direction: Literal["outgoing", "incoming", "both"] = "outgoing"
|
|
105
|
+
quantifier: Literal["one", "one_or_more", "zero_or_more"] = "one"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class MatchResult:
|
|
110
|
+
"""
|
|
111
|
+
A single result from pattern matching.
|
|
112
|
+
|
|
113
|
+
Contains the bindings from pattern variables to matched graph entities
|
|
114
|
+
(nodes and edges).
|
|
115
|
+
|
|
116
|
+
Attributes:
|
|
117
|
+
bindings: Mapping from variable names to matched Node or EdgeRef
|
|
118
|
+
instances.
|
|
119
|
+
path_length: Total number of edges traversed in this match.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
bindings: dict[str, Node | EdgeRef] = field(default_factory=dict)
|
|
123
|
+
path_length: int = 0
|
|
124
|
+
|
|
125
|
+
def get_node(self, variable: str) -> Node:
|
|
126
|
+
"""
|
|
127
|
+
Retrieve a matched node by its pattern variable name.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
variable: The variable name assigned in the pattern.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The matched Node instance.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
KeyError: If the variable is not found in bindings.
|
|
137
|
+
TypeError: If the binding is not a Node.
|
|
138
|
+
"""
|
|
139
|
+
from htmlgraph.models import Node as NodeModel
|
|
140
|
+
|
|
141
|
+
value = self.bindings[variable]
|
|
142
|
+
if not isinstance(value, NodeModel):
|
|
143
|
+
raise TypeError(
|
|
144
|
+
f"Binding '{variable}' is not a Node, got {type(value).__name__}"
|
|
145
|
+
)
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
def get_edge(self, variable: str) -> EdgeRef:
|
|
149
|
+
"""
|
|
150
|
+
Retrieve a matched edge reference by its pattern variable name.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
variable: The variable name assigned in the pattern.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The matched EdgeRef instance.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
KeyError: If the variable is not found in bindings.
|
|
160
|
+
TypeError: If the binding is not an EdgeRef.
|
|
161
|
+
"""
|
|
162
|
+
from htmlgraph.edge_index import EdgeRef as EdgeRefClass
|
|
163
|
+
|
|
164
|
+
value = self.bindings[variable]
|
|
165
|
+
if not isinstance(value, EdgeRefClass):
|
|
166
|
+
raise TypeError(
|
|
167
|
+
f"Binding '{variable}' is not an EdgeRef, got {type(value).__name__}"
|
|
168
|
+
)
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class GraphPattern:
|
|
173
|
+
"""
|
|
174
|
+
Fluent builder for constructing graph patterns.
|
|
175
|
+
|
|
176
|
+
Graph patterns describe structural shapes to find in the graph,
|
|
177
|
+
consisting of node patterns (with optional type and attribute filters)
|
|
178
|
+
and edge patterns (with optional relationship and direction filters).
|
|
179
|
+
|
|
180
|
+
Example:
|
|
181
|
+
pattern = GraphPattern()
|
|
182
|
+
pattern.node("a", label="feature", filters={"status": "blocked"})
|
|
183
|
+
pattern.edge("e", source="a", target="b", relationship="blocked_by")
|
|
184
|
+
pattern.node("b", label="feature", filters={"priority": "high"})
|
|
185
|
+
|
|
186
|
+
results = pattern.match(graph)
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(self) -> None:
|
|
190
|
+
self._node_patterns: list[NodePattern] = []
|
|
191
|
+
self._edge_patterns: list[EdgePattern] = []
|
|
192
|
+
self._node_pattern_map: dict[str, NodePattern] = {}
|
|
193
|
+
self._columns: list[str] | None = None
|
|
194
|
+
|
|
195
|
+
def node(
|
|
196
|
+
self,
|
|
197
|
+
variable: str,
|
|
198
|
+
label: str | None = None,
|
|
199
|
+
filters: dict[str, Any] | None = None,
|
|
200
|
+
) -> GraphPattern:
|
|
201
|
+
"""
|
|
202
|
+
Add a node pattern to the graph pattern.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
variable: Binding variable name for this node.
|
|
206
|
+
label: Optional node type filter (matches ``Node.type``).
|
|
207
|
+
filters: Optional attribute filters as key-value pairs.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Self for method chaining.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
ValueError: If a node pattern with the same variable already exists.
|
|
214
|
+
"""
|
|
215
|
+
if variable in self._node_pattern_map:
|
|
216
|
+
raise ValueError(f"Duplicate node variable: '{variable}'")
|
|
217
|
+
|
|
218
|
+
np = NodePattern(variable=variable, label=label, filters=filters or {})
|
|
219
|
+
self._node_patterns.append(np)
|
|
220
|
+
self._node_pattern_map[variable] = np
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
def edge(
|
|
224
|
+
self,
|
|
225
|
+
variable: str,
|
|
226
|
+
source: str,
|
|
227
|
+
target: str,
|
|
228
|
+
relationship: str | None = None,
|
|
229
|
+
direction: Literal["outgoing", "incoming", "both"] = "outgoing",
|
|
230
|
+
quantifier: Literal["one", "one_or_more", "zero_or_more"] = "one",
|
|
231
|
+
) -> GraphPattern:
|
|
232
|
+
"""
|
|
233
|
+
Add an edge pattern connecting two node variables.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
variable: Binding variable name for this edge.
|
|
237
|
+
source: Variable name of the source node pattern.
|
|
238
|
+
target: Variable name of the target node pattern.
|
|
239
|
+
relationship: Optional edge relationship type filter.
|
|
240
|
+
direction: Edge traversal direction (``"outgoing"``,
|
|
241
|
+
``"incoming"``, or ``"both"``).
|
|
242
|
+
quantifier: Edge repetition quantifier (``"one"``,
|
|
243
|
+
``"one_or_more"``, or ``"zero_or_more"``).
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Self for method chaining.
|
|
247
|
+
"""
|
|
248
|
+
ep = EdgePattern(
|
|
249
|
+
variable=variable,
|
|
250
|
+
source=source,
|
|
251
|
+
target=target,
|
|
252
|
+
relationship=relationship,
|
|
253
|
+
direction=direction,
|
|
254
|
+
quantifier=quantifier,
|
|
255
|
+
)
|
|
256
|
+
self._edge_patterns.append(ep)
|
|
257
|
+
return self
|
|
258
|
+
|
|
259
|
+
def columns(self, *attrs: str) -> GraphPattern:
|
|
260
|
+
"""
|
|
261
|
+
Specify which bindings to project in results.
|
|
262
|
+
|
|
263
|
+
When set, only the specified variables will appear in the
|
|
264
|
+
``MatchResult.bindings`` dictionary. If not called, all bindings
|
|
265
|
+
are included.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
*attrs: Variable names to include in results.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Self for method chaining.
|
|
272
|
+
"""
|
|
273
|
+
self._columns = list(attrs)
|
|
274
|
+
return self
|
|
275
|
+
|
|
276
|
+
def match(self, graph: HtmlGraph) -> list[MatchResult]:
|
|
277
|
+
"""
|
|
278
|
+
Execute this pattern against a graph and return all matches.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
graph: The HtmlGraph instance to search.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
List of MatchResult instances, one per unique match found.
|
|
285
|
+
"""
|
|
286
|
+
matcher = PatternMatcher(pattern=self, graph=graph)
|
|
287
|
+
return matcher.execute()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class PatternMatcher:
|
|
291
|
+
"""
|
|
292
|
+
Engine that executes a GraphPattern against an HtmlGraph.
|
|
293
|
+
|
|
294
|
+
The matcher works by:
|
|
295
|
+
1. Ordering node patterns by their appearance in edge patterns to
|
|
296
|
+
determine a traversal plan.
|
|
297
|
+
2. Finding candidate nodes for the first node pattern.
|
|
298
|
+
3. For each candidate, traversing edges using the EdgeIndex to find
|
|
299
|
+
matching neighbors.
|
|
300
|
+
4. Checking neighbor attribute filters.
|
|
301
|
+
5. Building MatchResult bindings for all valid complete matches.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
pattern: The GraphPattern to execute.
|
|
305
|
+
graph: The HtmlGraph to search.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def __init__(self, pattern: GraphPattern, graph: HtmlGraph) -> None:
|
|
309
|
+
self._pattern = pattern
|
|
310
|
+
self._graph = graph
|
|
311
|
+
|
|
312
|
+
def execute(self) -> list[MatchResult]:
|
|
313
|
+
"""
|
|
314
|
+
Execute the pattern and return all matches.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
List of MatchResult instances.
|
|
318
|
+
"""
|
|
319
|
+
# Ensure graph nodes are loaded
|
|
320
|
+
self._graph._ensure_loaded()
|
|
321
|
+
|
|
322
|
+
node_patterns = self._pattern._node_patterns
|
|
323
|
+
edge_patterns = self._pattern._edge_patterns
|
|
324
|
+
|
|
325
|
+
if not node_patterns:
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
# Build traversal order from edge patterns
|
|
329
|
+
traversal_plan = self._build_traversal_plan(node_patterns, edge_patterns)
|
|
330
|
+
|
|
331
|
+
# Start matching from the first node pattern in the plan
|
|
332
|
+
first_var = traversal_plan[0][0]
|
|
333
|
+
first_np = self._pattern._node_pattern_map[first_var]
|
|
334
|
+
|
|
335
|
+
# Find all candidates for the first node pattern
|
|
336
|
+
candidates = self._find_candidates(first_np)
|
|
337
|
+
|
|
338
|
+
# For each candidate, expand through the traversal plan
|
|
339
|
+
results: list[MatchResult] = []
|
|
340
|
+
for candidate in candidates:
|
|
341
|
+
initial_bindings: dict[str, Any] = {first_var: candidate}
|
|
342
|
+
self._expand(traversal_plan, 1, initial_bindings, 0, results)
|
|
343
|
+
|
|
344
|
+
# Apply column projection if specified
|
|
345
|
+
if self._pattern._columns is not None:
|
|
346
|
+
projected_cols = set(self._pattern._columns)
|
|
347
|
+
for result in results:
|
|
348
|
+
result.bindings = {
|
|
349
|
+
k: v for k, v in result.bindings.items() if k in projected_cols
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return results
|
|
353
|
+
|
|
354
|
+
def _build_traversal_plan(
|
|
355
|
+
self,
|
|
356
|
+
node_patterns: list[NodePattern],
|
|
357
|
+
edge_patterns: list[EdgePattern],
|
|
358
|
+
) -> list[tuple[str, EdgePattern | None]]:
|
|
359
|
+
"""
|
|
360
|
+
Build an ordered traversal plan from node and edge patterns.
|
|
361
|
+
|
|
362
|
+
The plan is a list of ``(node_variable, edge_pattern_or_None)`` tuples.
|
|
363
|
+
The first entry has ``None`` for the edge (it's the starting point).
|
|
364
|
+
Subsequent entries describe which edge to traverse and which node
|
|
365
|
+
variable to bind next.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
node_patterns: List of node patterns in the graph pattern.
|
|
369
|
+
edge_patterns: List of edge patterns in the graph pattern.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Ordered traversal plan.
|
|
373
|
+
"""
|
|
374
|
+
if not edge_patterns:
|
|
375
|
+
# No edges: just return all node patterns as independent matches
|
|
376
|
+
return [(np.variable, None) for np in node_patterns]
|
|
377
|
+
|
|
378
|
+
# Build a plan by following edge patterns in order
|
|
379
|
+
visited_vars: set[str] = set()
|
|
380
|
+
plan: list[tuple[str, EdgePattern | None]] = []
|
|
381
|
+
|
|
382
|
+
# Start with the source of the first edge
|
|
383
|
+
first_edge = edge_patterns[0]
|
|
384
|
+
start_var = first_edge.source
|
|
385
|
+
plan.append((start_var, None))
|
|
386
|
+
visited_vars.add(start_var)
|
|
387
|
+
|
|
388
|
+
for ep in edge_patterns:
|
|
389
|
+
# Determine which end is the "next" node to visit
|
|
390
|
+
if ep.source in visited_vars and ep.target not in visited_vars:
|
|
391
|
+
plan.append((ep.target, ep))
|
|
392
|
+
visited_vars.add(ep.target)
|
|
393
|
+
elif ep.target in visited_vars and ep.source not in visited_vars:
|
|
394
|
+
plan.append((ep.source, ep))
|
|
395
|
+
visited_vars.add(ep.source)
|
|
396
|
+
elif ep.source not in visited_vars:
|
|
397
|
+
# Neither end visited yet - start a new component
|
|
398
|
+
plan.append((ep.source, None))
|
|
399
|
+
visited_vars.add(ep.source)
|
|
400
|
+
plan.append((ep.target, ep))
|
|
401
|
+
visited_vars.add(ep.target)
|
|
402
|
+
# else: both already visited (cross-edge), skip for now
|
|
403
|
+
|
|
404
|
+
# Add any node patterns not yet in the plan (disconnected nodes)
|
|
405
|
+
for np in node_patterns:
|
|
406
|
+
if np.variable not in visited_vars:
|
|
407
|
+
plan.append((np.variable, None))
|
|
408
|
+
visited_vars.add(np.variable)
|
|
409
|
+
|
|
410
|
+
return plan
|
|
411
|
+
|
|
412
|
+
def _find_candidates(self, node_pattern: NodePattern) -> list[Node]:
|
|
413
|
+
"""
|
|
414
|
+
Find all nodes matching a node pattern.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
node_pattern: The node pattern to match against.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
List of candidate nodes.
|
|
421
|
+
"""
|
|
422
|
+
candidates: list[Node] = []
|
|
423
|
+
for node in self._graph._nodes.values():
|
|
424
|
+
if node_pattern.matches(node):
|
|
425
|
+
candidates.append(node)
|
|
426
|
+
return candidates
|
|
427
|
+
|
|
428
|
+
def _expand(
|
|
429
|
+
self,
|
|
430
|
+
plan: list[tuple[str, EdgePattern | None]],
|
|
431
|
+
step_index: int,
|
|
432
|
+
bindings: dict[str, Any],
|
|
433
|
+
edge_count: int,
|
|
434
|
+
results: list[MatchResult],
|
|
435
|
+
) -> None:
|
|
436
|
+
"""
|
|
437
|
+
Recursively expand partial matches through the traversal plan.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
plan: The traversal plan.
|
|
441
|
+
step_index: Current index into the plan.
|
|
442
|
+
bindings: Current variable bindings (variable -> Node or EdgeRef).
|
|
443
|
+
edge_count: Running count of edges traversed.
|
|
444
|
+
results: Accumulator for complete match results.
|
|
445
|
+
"""
|
|
446
|
+
if step_index >= len(plan):
|
|
447
|
+
# All steps satisfied - record result
|
|
448
|
+
results.append(MatchResult(bindings=dict(bindings), path_length=edge_count))
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
next_var, edge_pattern = plan[step_index]
|
|
452
|
+
next_np = self._pattern._node_pattern_map.get(next_var)
|
|
453
|
+
|
|
454
|
+
if edge_pattern is None:
|
|
455
|
+
# No edge to traverse - find independent candidates
|
|
456
|
+
if next_np is None:
|
|
457
|
+
return
|
|
458
|
+
for candidate in self._find_candidates(next_np):
|
|
459
|
+
# Ensure no duplicate node bindings
|
|
460
|
+
if any(
|
|
461
|
+
isinstance(v, type(candidate))
|
|
462
|
+
and hasattr(v, "id")
|
|
463
|
+
and v.id == candidate.id
|
|
464
|
+
for v in bindings.values()
|
|
465
|
+
):
|
|
466
|
+
continue
|
|
467
|
+
bindings[next_var] = candidate
|
|
468
|
+
self._expand(plan, step_index + 1, bindings, edge_count, results)
|
|
469
|
+
del bindings[next_var]
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
# Traverse edge from bound source/target to find the next node
|
|
473
|
+
self._traverse_edge(
|
|
474
|
+
edge_pattern,
|
|
475
|
+
next_var,
|
|
476
|
+
next_np,
|
|
477
|
+
plan,
|
|
478
|
+
step_index,
|
|
479
|
+
bindings,
|
|
480
|
+
edge_count,
|
|
481
|
+
results,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def _traverse_edge(
|
|
485
|
+
self,
|
|
486
|
+
edge_pattern: EdgePattern,
|
|
487
|
+
next_var: str,
|
|
488
|
+
next_np: NodePattern | None,
|
|
489
|
+
plan: list[tuple[str, EdgePattern | None]],
|
|
490
|
+
step_index: int,
|
|
491
|
+
bindings: dict[str, Any],
|
|
492
|
+
edge_count: int,
|
|
493
|
+
results: list[MatchResult],
|
|
494
|
+
) -> None:
|
|
495
|
+
"""
|
|
496
|
+
Traverse an edge pattern to find matching neighbor nodes.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
edge_pattern: The edge pattern to traverse.
|
|
500
|
+
next_var: The variable to bind the discovered node to.
|
|
501
|
+
next_np: Optional node pattern the discovered node must match.
|
|
502
|
+
plan: The full traversal plan.
|
|
503
|
+
step_index: Current step index.
|
|
504
|
+
bindings: Current variable bindings.
|
|
505
|
+
edge_count: Running edge count.
|
|
506
|
+
results: Result accumulator.
|
|
507
|
+
"""
|
|
508
|
+
from htmlgraph.edge_index import EdgeRef as EdgeRefClass
|
|
509
|
+
|
|
510
|
+
# Determine which bound node to traverse from
|
|
511
|
+
source_var = edge_pattern.source
|
|
512
|
+
target_var = edge_pattern.target
|
|
513
|
+
|
|
514
|
+
if source_var in bindings and next_var == target_var:
|
|
515
|
+
# Forward traversal: source is bound, looking for target
|
|
516
|
+
bound_node = bindings[source_var]
|
|
517
|
+
bound_node_id: str = bound_node.id
|
|
518
|
+
ref_pairs = self._get_edge_refs_with_neighbors(
|
|
519
|
+
bound_node_id, edge_pattern, traversal="forward"
|
|
520
|
+
)
|
|
521
|
+
for ref, neighbor_id in ref_pairs:
|
|
522
|
+
neighbor = self._graph._nodes.get(neighbor_id)
|
|
523
|
+
if neighbor is None:
|
|
524
|
+
continue
|
|
525
|
+
if next_np is not None and not next_np.matches(neighbor):
|
|
526
|
+
continue
|
|
527
|
+
bindings[next_var] = neighbor
|
|
528
|
+
bindings[edge_pattern.variable] = ref
|
|
529
|
+
self._expand(plan, step_index + 1, bindings, edge_count + 1, results)
|
|
530
|
+
del bindings[next_var]
|
|
531
|
+
del bindings[edge_pattern.variable]
|
|
532
|
+
|
|
533
|
+
elif target_var in bindings and next_var == source_var:
|
|
534
|
+
# Reverse traversal: target is bound, looking for source
|
|
535
|
+
bound_node = bindings[target_var]
|
|
536
|
+
bound_node_id = bound_node.id
|
|
537
|
+
ref_pairs = self._get_edge_refs_with_neighbors(
|
|
538
|
+
bound_node_id, edge_pattern, traversal="reverse"
|
|
539
|
+
)
|
|
540
|
+
for ref, neighbor_id in ref_pairs:
|
|
541
|
+
neighbor = self._graph._nodes.get(neighbor_id)
|
|
542
|
+
if neighbor is None:
|
|
543
|
+
continue
|
|
544
|
+
if next_np is not None and not next_np.matches(neighbor):
|
|
545
|
+
continue
|
|
546
|
+
bindings[next_var] = neighbor
|
|
547
|
+
bindings[edge_pattern.variable] = EdgeRefClass(
|
|
548
|
+
source_id=neighbor_id,
|
|
549
|
+
target_id=bound_node_id,
|
|
550
|
+
relationship=ref.relationship,
|
|
551
|
+
)
|
|
552
|
+
self._expand(plan, step_index + 1, bindings, edge_count + 1, results)
|
|
553
|
+
del bindings[next_var]
|
|
554
|
+
del bindings[edge_pattern.variable]
|
|
555
|
+
|
|
556
|
+
def _get_edge_refs_with_neighbors(
|
|
557
|
+
self,
|
|
558
|
+
node_id: str,
|
|
559
|
+
edge_pattern: EdgePattern,
|
|
560
|
+
traversal: Literal["forward", "reverse"],
|
|
561
|
+
) -> list[tuple[EdgeRef, str]]:
|
|
562
|
+
"""
|
|
563
|
+
Get matching edge references with their neighbor node IDs.
|
|
564
|
+
|
|
565
|
+
For each matching edge, returns a tuple of ``(EdgeRef, neighbor_id)``
|
|
566
|
+
where ``neighbor_id`` is the ID of the node on the *other* end of the
|
|
567
|
+
edge from ``node_id``.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
node_id: The node ID to look up edges for.
|
|
571
|
+
edge_pattern: The edge pattern with direction and relationship filters.
|
|
572
|
+
traversal: Whether we are going forward (outgoing from node_id)
|
|
573
|
+
or reverse (incoming to node_id).
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
List of ``(EdgeRef, neighbor_id)`` tuples.
|
|
577
|
+
"""
|
|
578
|
+
direction = edge_pattern.direction
|
|
579
|
+
relationship = edge_pattern.relationship
|
|
580
|
+
edge_index = self._graph._edge_index
|
|
581
|
+
|
|
582
|
+
pairs: list[tuple[EdgeRef, str]] = []
|
|
583
|
+
|
|
584
|
+
if traversal == "forward":
|
|
585
|
+
# Forward: source is bound (node_id), looking for targets
|
|
586
|
+
if direction in ("outgoing", "both"):
|
|
587
|
+
for ref in edge_index.get_outgoing(node_id, relationship):
|
|
588
|
+
pairs.append((ref, ref.target_id))
|
|
589
|
+
if direction in ("incoming", "both"):
|
|
590
|
+
for ref in edge_index.get_incoming(node_id, relationship):
|
|
591
|
+
pairs.append((ref, ref.source_id))
|
|
592
|
+
else:
|
|
593
|
+
# Reverse: target is bound (node_id), looking for sources
|
|
594
|
+
if direction in ("outgoing", "both"):
|
|
595
|
+
for ref in edge_index.get_incoming(node_id, relationship):
|
|
596
|
+
pairs.append((ref, ref.source_id))
|
|
597
|
+
if direction in ("incoming", "both"):
|
|
598
|
+
for ref in edge_index.get_outgoing(node_id, relationship):
|
|
599
|
+
pairs.append((ref, ref.target_id))
|
|
600
|
+
|
|
601
|
+
return pairs
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _get_nested_attr(obj: Any, path: str) -> Any:
|
|
605
|
+
"""
|
|
606
|
+
Get a nested attribute using dot notation.
|
|
607
|
+
|
|
608
|
+
Supports:
|
|
609
|
+
- Direct attributes: "status", "priority"
|
|
610
|
+
- Nested attributes: "properties.effort"
|
|
611
|
+
- Dictionary access: properties["key"]
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
obj: Object to get attribute from.
|
|
615
|
+
path: Dot-separated path to attribute.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Attribute value or None if not found.
|
|
619
|
+
"""
|
|
620
|
+
parts = path.split(".")
|
|
621
|
+
current: Any = obj
|
|
622
|
+
|
|
623
|
+
for part in parts:
|
|
624
|
+
if current is None:
|
|
625
|
+
return None
|
|
626
|
+
|
|
627
|
+
# Try object attribute first
|
|
628
|
+
if hasattr(current, part):
|
|
629
|
+
current = getattr(current, part)
|
|
630
|
+
# Then try dictionary access
|
|
631
|
+
elif isinstance(current, dict):
|
|
632
|
+
current = current.get(part)
|
|
633
|
+
else:
|
|
634
|
+
return None
|
|
635
|
+
|
|
636
|
+
return current
|