graph.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. # -*- coding: utf-8 -*-
  2. """
  3. Abhängigkeitsgraph für Plugins.
  4. Verwaltet die Abhängigkeitsbeziehungen zwischen Plugins
  5. und ermöglicht topologische Sortierung.
  6. """
  7. from __future__ import annotations
  8. from collections import defaultdict
  9. from dataclasses import dataclass, field
  10. from enum import IntEnum, auto
  11. from typing import Any, Iterator
  12. class CycleError(Exception):
  13. """Wird ausgelöst, wenn ein Zyklus erkannt wird."""
  14. def __init__(self, cycle: list[str]) -> None:
  15. self.cycle = cycle
  16. super().__init__(f"Zyklische Abhängigkeit erkannt: {' -> '.join(cycle)}")
  17. class NodeState(IntEnum):
  18. """Zustand eines Knotens während der Traversierung."""
  19. UNVISITED = auto()
  20. VISITING = auto()
  21. VISITED = auto()
  22. @dataclass
  23. class DependencyNode:
  24. """
  25. Ein Knoten im Abhängigkeitsgraphen.
  26. Attributes:
  27. name: Plugin-Name
  28. version: Plugin-Version
  29. dependencies: Liste der Abhängigkeiten (Name -> Version-Requirement)
  30. optional_dependencies: Optionale Abhängigkeiten
  31. metadata: Zusätzliche Metadaten
  32. """
  33. name: str
  34. version: str = "1.0.0"
  35. dependencies: dict[str, str] = field(default_factory=dict)
  36. optional_dependencies: dict[str, str] = field(default_factory=dict)
  37. metadata: dict[str, Any] = field(default_factory=dict)
  38. def has_dependency(self, name: str) -> bool:
  39. """Prüft, ob eine Abhängigkeit existiert."""
  40. return name in self.dependencies or name in self.optional_dependencies
  41. def get_all_dependencies(self) -> dict[str, str]:
  42. """Gibt alle Abhängigkeiten zurück (required + optional)."""
  43. result = self.dependencies.copy()
  44. result.update(self.optional_dependencies)
  45. return result
  46. def to_dict(self) -> dict[str, Any]:
  47. """Konvertiert zu Dictionary."""
  48. return {
  49. "name": self.name,
  50. "version": self.version,
  51. "dependencies": self.dependencies.copy(),
  52. "optional_dependencies": self.optional_dependencies.copy(),
  53. "metadata": self.metadata.copy(),
  54. }
  55. class DependencyGraph:
  56. """
  57. Gerichteter Graph für Plugin-Abhängigkeiten.
  58. Ermöglicht:
  59. - Hinzufügen/Entfernen von Knoten
  60. - Zykluserkennung
  61. - Topologische Sortierung
  62. - Suche nach abhängigen Plugins
  63. Example:
  64. graph = DependencyGraph()
  65. # Knoten hinzufügen
  66. graph.add_node(DependencyNode(
  67. name="plugin_a",
  68. version="1.0.0",
  69. dependencies={"plugin_b": "^1.0.0"}
  70. ))
  71. graph.add_node(DependencyNode(
  72. name="plugin_b",
  73. version="1.2.0"
  74. ))
  75. # Ladereihenfolge berechnen
  76. order = graph.get_load_order()
  77. # ['plugin_b', 'plugin_a']
  78. # Abhängige finden
  79. dependents = graph.get_dependents("plugin_b")
  80. # ['plugin_a']
  81. # Als DOT exportieren
  82. dot = graph.to_dot()
  83. """
  84. def __init__(self) -> None:
  85. """Initialisiert den Graphen."""
  86. self._nodes: dict[str, DependencyNode] = {}
  87. self._edges: dict[str, set[str]] = defaultdict(set) # from -> set(to)
  88. self._reverse_edges: dict[str, set[str]] = defaultdict(set) # to -> set(from)
  89. @property
  90. def node_count(self) -> int:
  91. """Anzahl Knoten."""
  92. return len(self._nodes)
  93. @property
  94. def edge_count(self) -> int:
  95. """Anzahl Kanten."""
  96. return sum(len(targets) for targets in self._edges.values())
  97. def add_node(self, node: DependencyNode) -> None:
  98. """
  99. Fügt einen Knoten hinzu.
  100. Args:
  101. node: Der hinzuzufügende Knoten
  102. Raises:
  103. ValueError: Wenn der Knoten bereits existiert
  104. """
  105. if node.name in self._nodes:
  106. raise ValueError(f"Knoten existiert bereits: {node.name}")
  107. self._nodes[node.name] = node
  108. # Kanten für Abhängigkeiten hinzufügen
  109. for dep_name in node.dependencies.keys():
  110. self._edges[node.name].add(dep_name)
  111. self._reverse_edges[dep_name].add(node.name)
  112. def update_node(self, node: DependencyNode) -> None:
  113. """
  114. Aktualisiert einen existierenden Knoten.
  115. Args:
  116. node: Der zu aktualisierende Knoten
  117. """
  118. if node.name in self._nodes:
  119. self.remove_node(node.name)
  120. self.add_node(node)
  121. def remove_node(self, name: str) -> DependencyNode | None:
  122. """
  123. Entfernt einen Knoten.
  124. Args:
  125. name: Knotenname
  126. Returns:
  127. Der entfernte Knoten oder None
  128. """
  129. node = self._nodes.pop(name, None)
  130. if node is None:
  131. return None
  132. # Kanten entfernen
  133. if name in self._edges:
  134. for target in self._edges[name]:
  135. self._reverse_edges[target].discard(name)
  136. del self._edges[name]
  137. if name in self._reverse_edges:
  138. for source in self._reverse_edges[name]:
  139. self._edges[source].discard(name)
  140. del self._reverse_edges[name]
  141. return node
  142. def get_node(self, name: str) -> DependencyNode | None:
  143. """Gibt einen Knoten zurück."""
  144. return self._nodes.get(name)
  145. def has_node(self, name: str) -> bool:
  146. """Prüft, ob ein Knoten existiert."""
  147. return name in self._nodes
  148. def get_dependencies(self, name: str) -> set[str]:
  149. """
  150. Gibt die direkten Abhängigkeiten eines Knotens zurück.
  151. Args:
  152. name: Knotenname
  153. Returns:
  154. Set der Abhängigkeiten
  155. """
  156. return self._edges.get(name, set()).copy()
  157. def get_dependents(self, name: str) -> set[str]:
  158. """
  159. Gibt alle Knoten zurück, die von diesem abhängen.
  160. Args:
  161. name: Knotenname
  162. Returns:
  163. Set der abhängigen Knoten
  164. """
  165. return self._reverse_edges.get(name, set()).copy()
  166. def get_all_dependencies(self, name: str) -> set[str]:
  167. """
  168. Gibt alle transitiven Abhängigkeiten zurück.
  169. Args:
  170. name: Knotenname
  171. Returns:
  172. Set aller direkten und indirekten Abhängigkeiten
  173. """
  174. result: set[str] = set()
  175. stack = list(self._edges.get(name, set()))
  176. while stack:
  177. dep = stack.pop()
  178. if dep not in result:
  179. result.add(dep)
  180. stack.extend(self._edges.get(dep, set()))
  181. return result
  182. def get_all_dependents(self, name: str) -> set[str]:
  183. """
  184. Gibt alle Knoten zurück, die direkt oder indirekt abhängig sind.
  185. Args:
  186. name: Knotenname
  187. Returns:
  188. Set aller abhängigen Knoten
  189. """
  190. result: set[str] = set()
  191. stack = list(self._reverse_edges.get(name, set()))
  192. while stack:
  193. dep = stack.pop()
  194. if dep not in result:
  195. result.add(dep)
  196. stack.extend(self._reverse_edges.get(dep, set()))
  197. return result
  198. def has_cycle(self) -> bool:
  199. """
  200. Prüft, ob der Graph einen Zyklus enthält.
  201. Returns:
  202. True wenn ein Zyklus existiert
  203. """
  204. try:
  205. self._detect_cycle()
  206. return False
  207. except CycleError:
  208. return True
  209. def find_cycle(self) -> list[str] | None:
  210. """
  211. Findet einen Zyklus im Graphen.
  212. Returns:
  213. Liste der Knoten im Zyklus oder None
  214. """
  215. try:
  216. self._detect_cycle()
  217. return None
  218. except CycleError as e:
  219. return e.cycle
  220. def _detect_cycle(self) -> None:
  221. """Wirft CycleError wenn ein Zyklus gefunden wird."""
  222. state: dict[str, NodeState] = {
  223. name: NodeState.UNVISITED for name in self._nodes
  224. }
  225. path: list[str] = []
  226. def visit(name: str) -> None:
  227. if state.get(name, NodeState.UNVISITED) == NodeState.VISITED:
  228. return
  229. if state.get(name, NodeState.UNVISITED) == NodeState.VISITING:
  230. # Zyklus gefunden
  231. cycle_start = path.index(name)
  232. raise CycleError(path[cycle_start:] + [name])
  233. state[name] = NodeState.VISITING
  234. path.append(name)
  235. for dep in self._edges.get(name, set()):
  236. visit(dep)
  237. path.pop()
  238. state[name] = NodeState.VISITED
  239. for name in self._nodes:
  240. if state[name] == NodeState.UNVISITED:
  241. visit(name)
  242. def get_load_order(self) -> list[str]:
  243. """
  244. Berechnet die Ladereihenfolge (topologische Sortierung).
  245. Abhängigkeiten kommen vor den abhängigen Plugins.
  246. Returns:
  247. Liste der Knotennamen in Ladereihenfolge
  248. Raises:
  249. CycleError: Bei zyklischen Abhängigkeiten
  250. """
  251. self._detect_cycle()
  252. # Kahn's Algorithmus
  253. in_degree = {name: 0 for name in self._nodes}
  254. for sources in self._edges.values():
  255. for target in sources:
  256. if target in in_degree:
  257. in_degree[target] += 1
  258. # Starte mit Knoten ohne eingehende Kanten
  259. queue = [name for name, degree in in_degree.items() if degree == 0]
  260. result: list[str] = []
  261. while queue:
  262. # Sortiert für deterministische Reihenfolge
  263. queue.sort()
  264. name = queue.pop(0)
  265. result.append(name)
  266. for dep in self._reverse_edges.get(name, set()):
  267. if dep in in_degree:
  268. in_degree[dep] -= 1
  269. if in_degree[dep] == 0:
  270. queue.append(dep)
  271. # Umkehren: Abhängigkeiten zuerst
  272. result.reverse()
  273. return result
  274. def get_unload_order(self) -> list[str]:
  275. """
  276. Berechnet die Entlade-Reihenfolge.
  277. Umgekehrte Ladereihenfolge - abhängige Plugins zuerst.
  278. Returns:
  279. Liste der Knotennamen in Entlade-Reihenfolge
  280. """
  281. return list(reversed(self.get_load_order()))
  282. def get_missing_dependencies(self) -> dict[str, list[str]]:
  283. """
  284. Findet alle fehlenden Abhängigkeiten.
  285. Returns:
  286. Dictionary mit Knoten -> Liste fehlender Abhängigkeiten
  287. """
  288. missing: dict[str, list[str]] = {}
  289. for name, node in self._nodes.items():
  290. node_missing = []
  291. for dep_name in node.dependencies.keys():
  292. if dep_name not in self._nodes:
  293. node_missing.append(dep_name)
  294. if node_missing:
  295. missing[name] = node_missing
  296. return missing
  297. def get_root_nodes(self) -> list[str]:
  298. """
  299. Gibt alle Knoten ohne Abhängigkeiten zurück.
  300. Returns:
  301. Liste der Root-Knoten
  302. """
  303. return [
  304. name for name, node in self._nodes.items()
  305. if not node.dependencies
  306. ]
  307. def get_leaf_nodes(self) -> list[str]:
  308. """
  309. Gibt alle Knoten ohne abhängige zurück.
  310. Returns:
  311. Liste der Blatt-Knoten
  312. """
  313. return [
  314. name for name in self._nodes
  315. if not self._reverse_edges.get(name)
  316. ]
  317. def to_dot(self, highlight: set[str] | None = None) -> str:
  318. """
  319. Exportiert den Graphen als DOT-Format.
  320. Args:
  321. highlight: Optional zu hervorhebende Knoten
  322. Returns:
  323. DOT-String für Graphviz
  324. """
  325. highlight = highlight or set()
  326. lines = ["digraph dependencies {"]
  327. lines.append(' rankdir=LR;')
  328. lines.append(' node [shape=box];')
  329. # Knoten
  330. for name, node in self._nodes.items():
  331. style = 'style=filled fillcolor=yellow' if name in highlight else ''
  332. label = f"{name}\\n{node.version}"
  333. lines.append(f' "{name}" [label="{label}" {style}];')
  334. # Kanten
  335. for source, targets in self._edges.items():
  336. for target in sorted(targets):
  337. style = 'style=dashed' if target not in self._nodes else ''
  338. lines.append(f' "{source}" -> "{target}" [{style}];')
  339. lines.append("}")
  340. return "\n".join(lines)
  341. def to_mermaid(self) -> str:
  342. """
  343. Exportiert den Graphen als Mermaid-Format.
  344. Returns:
  345. Mermaid-String
  346. """
  347. lines = ["graph LR"]
  348. for name, node in self._nodes.items():
  349. lines.append(f' {name}["{name} v{node.version}"]')
  350. for source, targets in self._edges.items():
  351. for target in sorted(targets):
  352. lines.append(f' {source} --> {target}')
  353. return "\n".join(lines)
  354. def __contains__(self, name: str) -> bool:
  355. return name in self._nodes
  356. def __iter__(self) -> Iterator[DependencyNode]:
  357. return iter(self._nodes.values())
  358. def __len__(self) -> int:
  359. return len(self._nodes)