htmlgraph 0.20.1__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.
Files changed (304) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
  2. htmlgraph/.htmlgraph/agents.json +72 -0
  3. htmlgraph/.htmlgraph/htmlgraph.db +0 -0
  4. htmlgraph/__init__.py +51 -1
  5. htmlgraph/__init__.pyi +123 -0
  6. htmlgraph/agent_detection.py +26 -10
  7. htmlgraph/agent_registry.py +2 -1
  8. htmlgraph/analytics/__init__.py +8 -1
  9. htmlgraph/analytics/cli.py +86 -20
  10. htmlgraph/analytics/cost_analyzer.py +391 -0
  11. htmlgraph/analytics/cost_monitor.py +664 -0
  12. htmlgraph/analytics/cost_reporter.py +675 -0
  13. htmlgraph/analytics/cross_session.py +617 -0
  14. htmlgraph/analytics/dependency.py +10 -6
  15. htmlgraph/analytics/pattern_learning.py +771 -0
  16. htmlgraph/analytics/session_graph.py +707 -0
  17. htmlgraph/analytics/strategic/__init__.py +80 -0
  18. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  19. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  20. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  21. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  22. htmlgraph/analytics/work_type.py +67 -27
  23. htmlgraph/analytics_index.py +53 -20
  24. htmlgraph/api/__init__.py +3 -0
  25. htmlgraph/api/cost_alerts_websocket.py +416 -0
  26. htmlgraph/api/main.py +2498 -0
  27. htmlgraph/api/static/htmx.min.js +1 -0
  28. htmlgraph/api/static/style-redesign.css +1344 -0
  29. htmlgraph/api/static/style.css +1079 -0
  30. htmlgraph/api/templates/dashboard-redesign.html +1366 -0
  31. htmlgraph/api/templates/dashboard.html +794 -0
  32. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  33. htmlgraph/api/templates/partials/activity-feed.html +1100 -0
  34. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  35. htmlgraph/api/templates/partials/agents.html +317 -0
  36. htmlgraph/api/templates/partials/event-traces.html +373 -0
  37. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  38. htmlgraph/api/templates/partials/features.html +578 -0
  39. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  40. htmlgraph/api/templates/partials/metrics.html +346 -0
  41. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  42. htmlgraph/api/templates/partials/orchestration.html +198 -0
  43. htmlgraph/api/templates/partials/spawners.html +375 -0
  44. htmlgraph/api/templates/partials/work-items.html +613 -0
  45. htmlgraph/api/websocket.py +538 -0
  46. htmlgraph/archive/__init__.py +24 -0
  47. htmlgraph/archive/bloom.py +234 -0
  48. htmlgraph/archive/fts.py +297 -0
  49. htmlgraph/archive/manager.py +583 -0
  50. htmlgraph/archive/search.py +244 -0
  51. htmlgraph/atomic_ops.py +560 -0
  52. htmlgraph/attribute_index.py +2 -1
  53. htmlgraph/bounded_paths.py +539 -0
  54. htmlgraph/builders/base.py +57 -2
  55. htmlgraph/builders/bug.py +19 -3
  56. htmlgraph/builders/chore.py +19 -3
  57. htmlgraph/builders/epic.py +19 -3
  58. htmlgraph/builders/feature.py +27 -3
  59. htmlgraph/builders/insight.py +2 -1
  60. htmlgraph/builders/metric.py +2 -1
  61. htmlgraph/builders/pattern.py +2 -1
  62. htmlgraph/builders/phase.py +19 -3
  63. htmlgraph/builders/spike.py +29 -3
  64. htmlgraph/builders/track.py +42 -1
  65. htmlgraph/cigs/__init__.py +81 -0
  66. htmlgraph/cigs/autonomy.py +385 -0
  67. htmlgraph/cigs/cost.py +475 -0
  68. htmlgraph/cigs/messages_basic.py +472 -0
  69. htmlgraph/cigs/messaging.py +365 -0
  70. htmlgraph/cigs/models.py +771 -0
  71. htmlgraph/cigs/pattern_storage.py +427 -0
  72. htmlgraph/cigs/patterns.py +503 -0
  73. htmlgraph/cigs/posttool_analyzer.py +234 -0
  74. htmlgraph/cigs/reporter.py +818 -0
  75. htmlgraph/cigs/tracker.py +317 -0
  76. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  77. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  78. htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
  79. htmlgraph/cli/__init__.py +42 -0
  80. htmlgraph/cli/__main__.py +6 -0
  81. htmlgraph/cli/analytics.py +1424 -0
  82. htmlgraph/cli/base.py +685 -0
  83. htmlgraph/cli/constants.py +206 -0
  84. htmlgraph/cli/core.py +954 -0
  85. htmlgraph/cli/main.py +147 -0
  86. htmlgraph/cli/models.py +475 -0
  87. htmlgraph/cli/templates/__init__.py +1 -0
  88. htmlgraph/cli/templates/cost_dashboard.py +399 -0
  89. htmlgraph/cli/work/__init__.py +239 -0
  90. htmlgraph/cli/work/browse.py +115 -0
  91. htmlgraph/cli/work/features.py +568 -0
  92. htmlgraph/cli/work/orchestration.py +676 -0
  93. htmlgraph/cli/work/report.py +728 -0
  94. htmlgraph/cli/work/sessions.py +466 -0
  95. htmlgraph/cli/work/snapshot.py +559 -0
  96. htmlgraph/cli/work/tracks.py +486 -0
  97. htmlgraph/cli_commands/__init__.py +1 -0
  98. htmlgraph/cli_commands/feature.py +195 -0
  99. htmlgraph/cli_framework.py +115 -0
  100. htmlgraph/collections/__init__.py +2 -0
  101. htmlgraph/collections/base.py +197 -14
  102. htmlgraph/collections/bug.py +2 -1
  103. htmlgraph/collections/chore.py +2 -1
  104. htmlgraph/collections/epic.py +2 -1
  105. htmlgraph/collections/feature.py +2 -1
  106. htmlgraph/collections/insight.py +2 -1
  107. htmlgraph/collections/metric.py +2 -1
  108. htmlgraph/collections/pattern.py +2 -1
  109. htmlgraph/collections/phase.py +2 -1
  110. htmlgraph/collections/session.py +194 -0
  111. htmlgraph/collections/spike.py +13 -2
  112. htmlgraph/collections/task_delegation.py +241 -0
  113. htmlgraph/collections/todo.py +14 -1
  114. htmlgraph/collections/traces.py +487 -0
  115. htmlgraph/config/cost_models.json +56 -0
  116. htmlgraph/config.py +190 -0
  117. htmlgraph/context_analytics.py +2 -1
  118. htmlgraph/converter.py +116 -7
  119. htmlgraph/cost_analysis/__init__.py +5 -0
  120. htmlgraph/cost_analysis/analyzer.py +438 -0
  121. htmlgraph/dashboard.html +2246 -248
  122. htmlgraph/dashboard.html.backup +6592 -0
  123. htmlgraph/dashboard.html.bak +7181 -0
  124. htmlgraph/dashboard.html.bak2 +7231 -0
  125. htmlgraph/dashboard.html.bak3 +7232 -0
  126. htmlgraph/db/__init__.py +38 -0
  127. htmlgraph/db/queries.py +790 -0
  128. htmlgraph/db/schema.py +1788 -0
  129. htmlgraph/decorators.py +317 -0
  130. htmlgraph/dependency_models.py +2 -1
  131. htmlgraph/deploy.py +26 -27
  132. htmlgraph/docs/API_REFERENCE.md +841 -0
  133. htmlgraph/docs/HTTP_API.md +750 -0
  134. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  135. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
  136. htmlgraph/docs/README.md +532 -0
  137. htmlgraph/docs/__init__.py +77 -0
  138. htmlgraph/docs/docs_version.py +55 -0
  139. htmlgraph/docs/metadata.py +93 -0
  140. htmlgraph/docs/migrations.py +232 -0
  141. htmlgraph/docs/template_engine.py +143 -0
  142. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  143. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  144. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  145. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  146. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  147. htmlgraph/docs/version_check.py +163 -0
  148. htmlgraph/edge_index.py +2 -1
  149. htmlgraph/error_handler.py +544 -0
  150. htmlgraph/event_log.py +86 -37
  151. htmlgraph/event_migration.py +2 -1
  152. htmlgraph/file_watcher.py +12 -8
  153. htmlgraph/find_api.py +2 -1
  154. htmlgraph/git_events.py +67 -9
  155. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  156. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  157. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  158. htmlgraph/hooks/__init__.py +8 -0
  159. htmlgraph/hooks/bootstrap.py +169 -0
  160. htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
  161. htmlgraph/hooks/concurrent_sessions.py +208 -0
  162. htmlgraph/hooks/context.py +350 -0
  163. htmlgraph/hooks/drift_handler.py +525 -0
  164. htmlgraph/hooks/event_tracker.py +790 -99
  165. htmlgraph/hooks/git_commands.py +175 -0
  166. htmlgraph/hooks/installer.py +5 -1
  167. htmlgraph/hooks/orchestrator.py +327 -76
  168. htmlgraph/hooks/orchestrator_reflector.py +31 -4
  169. htmlgraph/hooks/post_tool_use_failure.py +32 -7
  170. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  171. htmlgraph/hooks/posttooluse.py +92 -19
  172. htmlgraph/hooks/pretooluse.py +527 -7
  173. htmlgraph/hooks/prompt_analyzer.py +637 -0
  174. htmlgraph/hooks/session_handler.py +668 -0
  175. htmlgraph/hooks/session_summary.py +395 -0
  176. htmlgraph/hooks/state_manager.py +504 -0
  177. htmlgraph/hooks/subagent_detection.py +202 -0
  178. htmlgraph/hooks/subagent_stop.py +369 -0
  179. htmlgraph/hooks/task_enforcer.py +99 -4
  180. htmlgraph/hooks/validator.py +212 -91
  181. htmlgraph/ids.py +2 -1
  182. htmlgraph/learning.py +125 -100
  183. htmlgraph/mcp_server.py +2 -1
  184. htmlgraph/models.py +217 -18
  185. htmlgraph/operations/README.md +62 -0
  186. htmlgraph/operations/__init__.py +79 -0
  187. htmlgraph/operations/analytics.py +339 -0
  188. htmlgraph/operations/bootstrap.py +289 -0
  189. htmlgraph/operations/events.py +244 -0
  190. htmlgraph/operations/fastapi_server.py +231 -0
  191. htmlgraph/operations/hooks.py +350 -0
  192. htmlgraph/operations/initialization.py +597 -0
  193. htmlgraph/operations/initialization.py.backup +228 -0
  194. htmlgraph/operations/server.py +303 -0
  195. htmlgraph/orchestration/__init__.py +58 -0
  196. htmlgraph/orchestration/claude_launcher.py +179 -0
  197. htmlgraph/orchestration/command_builder.py +72 -0
  198. htmlgraph/orchestration/headless_spawner.py +281 -0
  199. htmlgraph/orchestration/live_events.py +377 -0
  200. htmlgraph/orchestration/model_selection.py +327 -0
  201. htmlgraph/orchestration/plugin_manager.py +140 -0
  202. htmlgraph/orchestration/prompts.py +137 -0
  203. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  204. htmlgraph/orchestration/spawners/__init__.py +16 -0
  205. htmlgraph/orchestration/spawners/base.py +194 -0
  206. htmlgraph/orchestration/spawners/claude.py +173 -0
  207. htmlgraph/orchestration/spawners/codex.py +435 -0
  208. htmlgraph/orchestration/spawners/copilot.py +294 -0
  209. htmlgraph/orchestration/spawners/gemini.py +471 -0
  210. htmlgraph/orchestration/subprocess_runner.py +36 -0
  211. htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
  212. htmlgraph/orchestration.md +563 -0
  213. htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
  214. htmlgraph/orchestrator.py +2 -1
  215. htmlgraph/orchestrator_config.py +357 -0
  216. htmlgraph/orchestrator_mode.py +115 -4
  217. htmlgraph/parallel.py +2 -1
  218. htmlgraph/parser.py +86 -6
  219. htmlgraph/path_query.py +608 -0
  220. htmlgraph/pattern_matcher.py +636 -0
  221. htmlgraph/pydantic_models.py +476 -0
  222. htmlgraph/quality_gates.py +350 -0
  223. htmlgraph/query_builder.py +2 -1
  224. htmlgraph/query_composer.py +509 -0
  225. htmlgraph/reflection.py +443 -0
  226. htmlgraph/refs.py +344 -0
  227. htmlgraph/repo_hash.py +512 -0
  228. htmlgraph/repositories/__init__.py +292 -0
  229. htmlgraph/repositories/analytics_repository.py +455 -0
  230. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  231. htmlgraph/repositories/feature_repository.py +581 -0
  232. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  233. htmlgraph/repositories/feature_repository_memory.py +607 -0
  234. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  235. htmlgraph/repositories/filter_service.py +620 -0
  236. htmlgraph/repositories/filter_service_standard.py +445 -0
  237. htmlgraph/repositories/shared_cache.py +621 -0
  238. htmlgraph/repositories/shared_cache_memory.py +395 -0
  239. htmlgraph/repositories/track_repository.py +552 -0
  240. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  241. htmlgraph/repositories/track_repository_memory.py +508 -0
  242. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  243. htmlgraph/sdk/__init__.py +398 -0
  244. htmlgraph/sdk/__init__.pyi +14 -0
  245. htmlgraph/sdk/analytics/__init__.py +19 -0
  246. htmlgraph/sdk/analytics/engine.py +155 -0
  247. htmlgraph/sdk/analytics/helpers.py +178 -0
  248. htmlgraph/sdk/analytics/registry.py +109 -0
  249. htmlgraph/sdk/base.py +484 -0
  250. htmlgraph/sdk/constants.py +216 -0
  251. htmlgraph/sdk/core.pyi +308 -0
  252. htmlgraph/sdk/discovery.py +120 -0
  253. htmlgraph/sdk/help/__init__.py +12 -0
  254. htmlgraph/sdk/help/mixin.py +699 -0
  255. htmlgraph/sdk/mixins/__init__.py +15 -0
  256. htmlgraph/sdk/mixins/attribution.py +113 -0
  257. htmlgraph/sdk/mixins/mixin.py +410 -0
  258. htmlgraph/sdk/operations/__init__.py +12 -0
  259. htmlgraph/sdk/operations/mixin.py +427 -0
  260. htmlgraph/sdk/orchestration/__init__.py +17 -0
  261. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  262. htmlgraph/sdk/orchestration/spawner.py +204 -0
  263. htmlgraph/sdk/planning/__init__.py +19 -0
  264. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  265. htmlgraph/sdk/planning/mixin.py +211 -0
  266. htmlgraph/sdk/planning/parallel.py +186 -0
  267. htmlgraph/sdk/planning/queue.py +210 -0
  268. htmlgraph/sdk/planning/recommendations.py +87 -0
  269. htmlgraph/sdk/planning/smart_planning.py +319 -0
  270. htmlgraph/sdk/session/__init__.py +19 -0
  271. htmlgraph/sdk/session/continuity.py +57 -0
  272. htmlgraph/sdk/session/handoff.py +110 -0
  273. htmlgraph/sdk/session/info.py +309 -0
  274. htmlgraph/sdk/session/manager.py +103 -0
  275. htmlgraph/sdk/strategic/__init__.py +26 -0
  276. htmlgraph/sdk/strategic/mixin.py +563 -0
  277. htmlgraph/server.py +295 -107
  278. htmlgraph/session_hooks.py +300 -0
  279. htmlgraph/session_manager.py +285 -3
  280. htmlgraph/session_registry.py +587 -0
  281. htmlgraph/session_state.py +436 -0
  282. htmlgraph/session_warning.py +2 -1
  283. htmlgraph/sessions/__init__.py +23 -0
  284. htmlgraph/sessions/handoff.py +756 -0
  285. htmlgraph/system_prompts.py +450 -0
  286. htmlgraph/templates/orchestration-view.html +350 -0
  287. htmlgraph/track_builder.py +33 -1
  288. htmlgraph/track_manager.py +38 -0
  289. htmlgraph/transcript.py +18 -5
  290. htmlgraph/validation.py +115 -0
  291. htmlgraph/watch.py +2 -1
  292. htmlgraph/work_type_utils.py +2 -1
  293. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
  294. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
  295. htmlgraph-0.27.5.dist-info/RECORD +337 -0
  296. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
  297. htmlgraph/cli.py +0 -4839
  298. htmlgraph/sdk.py +0 -2359
  299. htmlgraph-0.20.1.dist-info/RECORD +0 -118
  300. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
  301. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  302. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  303. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  304. {htmlgraph-0.20.1.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