truthound-dashboard 1.3.0__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.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
- truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
- truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
- truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.0.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
|