truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -22
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
  164. truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
  166. truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,541 @@
1
+ """Dependency Graph and Resolution.
2
+
3
+ This module provides:
4
+ - Dependency graph construction
5
+ - Cycle detection
6
+ - Topological sorting for load order
7
+ - Dependency resolution
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import defaultdict, deque
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from typing import Any
16
+
17
+ from .semver import Version, parse_version
18
+ from .constraints import parse_constraint, satisfies, find_best_version
19
+
20
+
21
+ class DependencyType(str, Enum):
22
+ """Types of dependencies."""
23
+
24
+ REQUIRED = "required" # Must be present
25
+ OPTIONAL = "optional" # Nice to have
26
+ DEV = "dev" # Development only
27
+ PEER = "peer" # Must be installed by parent
28
+ CONFLICT = "conflict" # Must NOT be present
29
+
30
+
31
+ class DependencyResolutionError(Exception):
32
+ """Raised when dependency resolution fails."""
33
+ pass
34
+
35
+
36
+ class CyclicDependencyError(DependencyResolutionError):
37
+ """Raised when a cyclic dependency is detected."""
38
+
39
+ def __init__(self, cycle: list[str]) -> None:
40
+ """Initialize with cycle path.
41
+
42
+ Args:
43
+ cycle: List of node IDs forming the cycle.
44
+ """
45
+ self.cycle = cycle
46
+ cycle_str = " -> ".join(cycle)
47
+ super().__init__(f"Cyclic dependency detected: {cycle_str}")
48
+
49
+
50
+ @dataclass
51
+ class Dependency:
52
+ """Represents a dependency relationship.
53
+
54
+ Attributes:
55
+ name: Name of the dependency.
56
+ version_constraint: Version constraint string.
57
+ dep_type: Type of dependency.
58
+ optional: Whether this dependency is optional.
59
+ platform: Platform restriction (e.g., "linux", "darwin").
60
+ python_version: Python version constraint.
61
+ extras: Extra features this dependency provides.
62
+ resolved_version: Resolved version after resolution.
63
+ """
64
+
65
+ name: str
66
+ version_constraint: str = "*"
67
+ dep_type: DependencyType = DependencyType.REQUIRED
68
+ optional: bool = False
69
+ platform: str | None = None
70
+ python_version: str | None = None
71
+ extras: list[str] = field(default_factory=list)
72
+ resolved_version: Version | None = None
73
+
74
+ def __str__(self) -> str:
75
+ """Return string representation."""
76
+ if self.version_constraint and self.version_constraint != "*":
77
+ return f"{self.name}@{self.version_constraint}"
78
+ return self.name
79
+
80
+ def to_dict(self) -> dict[str, Any]:
81
+ """Convert to dictionary."""
82
+ return {
83
+ "name": self.name,
84
+ "version_constraint": self.version_constraint,
85
+ "dep_type": self.dep_type.value,
86
+ "optional": self.optional,
87
+ "platform": self.platform,
88
+ "python_version": self.python_version,
89
+ "extras": self.extras,
90
+ "resolved_version": (
91
+ str(self.resolved_version) if self.resolved_version else None
92
+ ),
93
+ }
94
+
95
+ @classmethod
96
+ def from_dict(cls, data: dict[str, Any]) -> "Dependency":
97
+ """Create from dictionary."""
98
+ return cls(
99
+ name=data["name"],
100
+ version_constraint=data.get("version_constraint", "*"),
101
+ dep_type=DependencyType(data.get("dep_type", "required")),
102
+ optional=data.get("optional", False),
103
+ platform=data.get("platform"),
104
+ python_version=data.get("python_version"),
105
+ extras=data.get("extras", []),
106
+ )
107
+
108
+
109
+ @dataclass
110
+ class DependencyNode:
111
+ """A node in the dependency graph.
112
+
113
+ Attributes:
114
+ id: Unique identifier (usually plugin name).
115
+ version: Version of this node.
116
+ dependencies: List of dependencies.
117
+ metadata: Additional metadata.
118
+ """
119
+
120
+ id: str
121
+ version: Version | str | None = None
122
+ dependencies: list[Dependency] = field(default_factory=list)
123
+ metadata: dict[str, Any] = field(default_factory=dict)
124
+
125
+ def __post_init__(self) -> None:
126
+ """Parse version if string."""
127
+ if isinstance(self.version, str):
128
+ self.version = parse_version(self.version)
129
+
130
+ def __str__(self) -> str:
131
+ """Return string representation."""
132
+ if self.version:
133
+ return f"{self.id}@{self.version}"
134
+ return self.id
135
+
136
+
137
+ class DependencyGraph:
138
+ """Directed graph for dependency management.
139
+
140
+ Supports:
141
+ - Adding/removing nodes
142
+ - Cycle detection
143
+ - Topological sorting
144
+ - Path finding
145
+ """
146
+
147
+ def __init__(self) -> None:
148
+ """Initialize empty graph."""
149
+ self._nodes: dict[str, DependencyNode] = {}
150
+ self._edges: dict[str, set[str]] = defaultdict(set) # id -> dependencies
151
+ self._reverse_edges: dict[str, set[str]] = defaultdict(set) # id -> dependents
152
+
153
+ def add_node(
154
+ self,
155
+ node_id: str,
156
+ version: Version | str | None = None,
157
+ dependencies: list[Dependency] | None = None,
158
+ metadata: dict[str, Any] | None = None,
159
+ ) -> DependencyNode:
160
+ """Add a node to the graph.
161
+
162
+ Args:
163
+ node_id: Unique identifier.
164
+ version: Node version.
165
+ dependencies: List of dependencies.
166
+ metadata: Additional metadata.
167
+
168
+ Returns:
169
+ The created node.
170
+ """
171
+ node = DependencyNode(
172
+ id=node_id,
173
+ version=version,
174
+ dependencies=dependencies or [],
175
+ metadata=metadata or {},
176
+ )
177
+ self._nodes[node_id] = node
178
+
179
+ # Add edges for dependencies
180
+ for dep in node.dependencies:
181
+ if dep.dep_type != DependencyType.CONFLICT:
182
+ self._edges[node_id].add(dep.name)
183
+ self._reverse_edges[dep.name].add(node_id)
184
+
185
+ return node
186
+
187
+ def remove_node(self, node_id: str) -> None:
188
+ """Remove a node from the graph.
189
+
190
+ Args:
191
+ node_id: Node to remove.
192
+ """
193
+ if node_id not in self._nodes:
194
+ return
195
+
196
+ # Remove edges
197
+ for dep_id in self._edges[node_id]:
198
+ self._reverse_edges[dep_id].discard(node_id)
199
+ del self._edges[node_id]
200
+
201
+ for dependent_id in list(self._reverse_edges[node_id]):
202
+ self._edges[dependent_id].discard(node_id)
203
+ del self._reverse_edges[node_id]
204
+
205
+ del self._nodes[node_id]
206
+
207
+ def get_node(self, node_id: str) -> DependencyNode | None:
208
+ """Get a node by ID.
209
+
210
+ Args:
211
+ node_id: Node ID.
212
+
213
+ Returns:
214
+ Node or None if not found.
215
+ """
216
+ return self._nodes.get(node_id)
217
+
218
+ def get_dependencies(self, node_id: str) -> list[str]:
219
+ """Get direct dependencies of a node.
220
+
221
+ Args:
222
+ node_id: Node ID.
223
+
224
+ Returns:
225
+ List of dependency IDs.
226
+ """
227
+ return list(self._edges.get(node_id, []))
228
+
229
+ def get_dependents(self, node_id: str) -> list[str]:
230
+ """Get nodes that depend on this node.
231
+
232
+ Args:
233
+ node_id: Node ID.
234
+
235
+ Returns:
236
+ List of dependent node IDs.
237
+ """
238
+ return list(self._reverse_edges.get(node_id, []))
239
+
240
+ def get_all_dependencies(self, node_id: str) -> set[str]:
241
+ """Get all transitive dependencies of a node.
242
+
243
+ Args:
244
+ node_id: Node ID.
245
+
246
+ Returns:
247
+ Set of all dependency IDs.
248
+ """
249
+ all_deps: set[str] = set()
250
+ queue = deque(self._edges.get(node_id, []))
251
+
252
+ while queue:
253
+ dep_id = queue.popleft()
254
+ if dep_id not in all_deps:
255
+ all_deps.add(dep_id)
256
+ queue.extend(self._edges.get(dep_id, []))
257
+
258
+ return all_deps
259
+
260
+ def detect_cycles(self) -> list[list[str]]:
261
+ """Detect all cycles in the graph.
262
+
263
+ Returns:
264
+ List of cycles, where each cycle is a list of node IDs.
265
+ """
266
+ cycles: list[list[str]] = []
267
+ visited: set[str] = set()
268
+ rec_stack: set[str] = set()
269
+ path: list[str] = []
270
+
271
+ def dfs(node_id: str) -> None:
272
+ visited.add(node_id)
273
+ rec_stack.add(node_id)
274
+ path.append(node_id)
275
+
276
+ for neighbor in self._edges.get(node_id, []):
277
+ if neighbor not in visited:
278
+ dfs(neighbor)
279
+ elif neighbor in rec_stack:
280
+ # Found cycle
281
+ cycle_start = path.index(neighbor)
282
+ cycle = path[cycle_start:] + [neighbor]
283
+ cycles.append(cycle)
284
+
285
+ path.pop()
286
+ rec_stack.remove(node_id)
287
+
288
+ for node_id in self._nodes:
289
+ if node_id not in visited:
290
+ dfs(node_id)
291
+
292
+ return cycles
293
+
294
+ def has_cycle(self) -> bool:
295
+ """Check if graph has any cycles.
296
+
297
+ Returns:
298
+ True if cycles exist.
299
+ """
300
+ return len(self.detect_cycles()) > 0
301
+
302
+ def topological_sort(self) -> list[str]:
303
+ """Get topological ordering of nodes.
304
+
305
+ Returns:
306
+ List of node IDs in topological order.
307
+
308
+ Raises:
309
+ CyclicDependencyError: If graph has cycles.
310
+ """
311
+ cycles = self.detect_cycles()
312
+ if cycles:
313
+ raise CyclicDependencyError(cycles[0])
314
+
315
+ # Kahn's algorithm
316
+ in_degree: dict[str, int] = {node_id: 0 for node_id in self._nodes}
317
+ for node_id in self._nodes:
318
+ for dep_id in self._edges.get(node_id, []):
319
+ if dep_id in in_degree:
320
+ in_degree[dep_id] += 1
321
+
322
+ # Start with nodes that have no dependents
323
+ queue = deque([
324
+ node_id for node_id, degree in in_degree.items() if degree == 0
325
+ ])
326
+ result: list[str] = []
327
+
328
+ while queue:
329
+ node_id = queue.popleft()
330
+ result.append(node_id)
331
+
332
+ for dep_id in self._edges.get(node_id, []):
333
+ if dep_id in in_degree:
334
+ in_degree[dep_id] -= 1
335
+ if in_degree[dep_id] == 0:
336
+ queue.append(dep_id)
337
+
338
+ if len(result) != len(self._nodes):
339
+ # Should not happen if cycle detection is correct
340
+ raise CyclicDependencyError(["unknown cycle"])
341
+
342
+ return result
343
+
344
+ def get_load_order(self) -> list[str]:
345
+ """Get the order in which nodes should be loaded.
346
+
347
+ Dependencies are loaded before dependents.
348
+
349
+ Returns:
350
+ List of node IDs in load order.
351
+
352
+ Raises:
353
+ CyclicDependencyError: If graph has cycles.
354
+ """
355
+ # Reverse of topological sort gives load order
356
+ return list(reversed(self.topological_sort()))
357
+
358
+ def find_path(self, from_id: str, to_id: str) -> list[str] | None:
359
+ """Find a path between two nodes.
360
+
361
+ Args:
362
+ from_id: Starting node.
363
+ to_id: Target node.
364
+
365
+ Returns:
366
+ Path as list of node IDs, or None if no path exists.
367
+ """
368
+ if from_id not in self._nodes or to_id not in self._nodes:
369
+ return None
370
+
371
+ visited: set[str] = set()
372
+ queue: deque[tuple[str, list[str]]] = deque([(from_id, [from_id])])
373
+
374
+ while queue:
375
+ current, path = queue.popleft()
376
+ if current == to_id:
377
+ return path
378
+
379
+ if current in visited:
380
+ continue
381
+ visited.add(current)
382
+
383
+ for neighbor in self._edges.get(current, []):
384
+ if neighbor not in visited:
385
+ queue.append((neighbor, path + [neighbor]))
386
+
387
+ return None
388
+
389
+ def to_dict(self) -> dict[str, Any]:
390
+ """Convert graph to dictionary."""
391
+ return {
392
+ "nodes": [
393
+ {
394
+ "id": node.id,
395
+ "version": str(node.version) if node.version else None,
396
+ "dependencies": [d.to_dict() for d in node.dependencies],
397
+ "metadata": node.metadata,
398
+ }
399
+ for node in self._nodes.values()
400
+ ],
401
+ "edges": {k: list(v) for k, v in self._edges.items()},
402
+ }
403
+
404
+
405
+ class DependencyResolver:
406
+ """Resolves dependencies and finds compatible versions."""
407
+
408
+ def __init__(
409
+ self,
410
+ available_versions: dict[str, list[str]] | None = None,
411
+ ) -> None:
412
+ """Initialize resolver.
413
+
414
+ Args:
415
+ available_versions: Dict mapping package names to available versions.
416
+ """
417
+ self.available_versions = available_versions or {}
418
+
419
+ def set_available_versions(
420
+ self, package: str, versions: list[str]
421
+ ) -> None:
422
+ """Set available versions for a package.
423
+
424
+ Args:
425
+ package: Package name.
426
+ versions: List of available version strings.
427
+ """
428
+ self.available_versions[package] = versions
429
+
430
+ def resolve(
431
+ self,
432
+ dependencies: list[Dependency],
433
+ installed: dict[str, str] | None = None,
434
+ ) -> dict[str, Version]:
435
+ """Resolve dependencies to specific versions.
436
+
437
+ Args:
438
+ dependencies: List of dependencies to resolve.
439
+ installed: Currently installed versions.
440
+
441
+ Returns:
442
+ Dict mapping package names to resolved versions.
443
+
444
+ Raises:
445
+ DependencyResolutionError: If resolution fails.
446
+ """
447
+ installed = installed or {}
448
+ resolved: dict[str, Version] = {}
449
+ errors: list[str] = []
450
+
451
+ for dep in dependencies:
452
+ if dep.dep_type == DependencyType.CONFLICT:
453
+ # Check that conflicting package is not installed
454
+ if dep.name in installed:
455
+ if satisfies(installed[dep.name], dep.version_constraint):
456
+ errors.append(
457
+ f"Conflict: {dep.name}@{installed[dep.name]} "
458
+ f"conflicts with constraint {dep.version_constraint}"
459
+ )
460
+ continue
461
+
462
+ # Check if already resolved
463
+ if dep.name in resolved:
464
+ # Verify constraint compatibility
465
+ if not satisfies(resolved[dep.name], dep.version_constraint):
466
+ errors.append(
467
+ f"Version conflict for {dep.name}: "
468
+ f"resolved to {resolved[dep.name]} but "
469
+ f"constraint {dep.version_constraint} not satisfied"
470
+ )
471
+ continue
472
+
473
+ # Check if already installed
474
+ if dep.name in installed:
475
+ if satisfies(installed[dep.name], dep.version_constraint):
476
+ resolved[dep.name] = parse_version(installed[dep.name])
477
+ continue
478
+ elif not dep.optional:
479
+ errors.append(
480
+ f"Installed version {dep.name}@{installed[dep.name]} "
481
+ f"does not satisfy {dep.version_constraint}"
482
+ )
483
+ continue
484
+
485
+ # Find best version from available
486
+ available = self.available_versions.get(dep.name, [])
487
+ if not available:
488
+ if dep.dep_type == DependencyType.REQUIRED and not dep.optional:
489
+ errors.append(f"No versions available for {dep.name}")
490
+ continue
491
+
492
+ best = find_best_version(available, dep.version_constraint)
493
+ if best:
494
+ resolved[dep.name] = best
495
+ elif dep.dep_type == DependencyType.REQUIRED and not dep.optional:
496
+ errors.append(
497
+ f"No version of {dep.name} satisfies {dep.version_constraint}"
498
+ )
499
+
500
+ if errors:
501
+ raise DependencyResolutionError("\n".join(errors))
502
+
503
+ return resolved
504
+
505
+ def check_compatibility(
506
+ self,
507
+ package: str,
508
+ version: str | Version,
509
+ dependencies: list[Dependency],
510
+ ) -> tuple[bool, list[str]]:
511
+ """Check if a package version is compatible with dependencies.
512
+
513
+ Args:
514
+ package: Package name.
515
+ version: Version to check.
516
+ dependencies: List of dependencies.
517
+
518
+ Returns:
519
+ Tuple of (is_compatible, list of issues).
520
+ """
521
+ if isinstance(version, str):
522
+ version = parse_version(version)
523
+
524
+ issues: list[str] = []
525
+
526
+ for dep in dependencies:
527
+ if dep.name != package:
528
+ continue
529
+
530
+ if dep.dep_type == DependencyType.CONFLICT:
531
+ if satisfies(version, dep.version_constraint):
532
+ issues.append(
533
+ f"Version {version} conflicts with constraint {dep.version_constraint}"
534
+ )
535
+ else:
536
+ if not satisfies(version, dep.version_constraint):
537
+ issues.append(
538
+ f"Version {version} does not satisfy {dep.version_constraint}"
539
+ )
540
+
541
+ return len(issues) == 0, issues