discovery.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. # -*- coding: utf-8 -*-
  2. """
  3. Plugin-Erkennung und -Scanning.
  4. Scannt Verzeichnisse nach Plugins und extrahiert Metadaten.
  5. """
  6. from __future__ import annotations
  7. import importlib.util
  8. import json
  9. from dataclasses import dataclass, field
  10. from pathlib import Path
  11. from typing import Any, TYPE_CHECKING
  12. from trixy_core.plugins.registry.manifest import (
  13. PluginManifest,
  14. ManifestError,
  15. load_manifest,
  16. )
  17. if TYPE_CHECKING:
  18. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  19. @dataclass
  20. class DiscoveryResult:
  21. """
  22. Ergebnis einer Plugin-Suche.
  23. Attributes:
  24. path: Pfad zum Plugin
  25. manifest: Plugin-Manifest (wenn vorhanden)
  26. valid: Ist das Plugin valide
  27. errors: Gefundene Fehler
  28. warnings: Warnungen
  29. """
  30. path: Path
  31. manifest: PluginManifest | None = None
  32. valid: bool = True
  33. errors: list[str] = field(default_factory=list)
  34. warnings: list[str] = field(default_factory=list)
  35. @property
  36. def name(self) -> str:
  37. """Plugin-Name."""
  38. if self.manifest:
  39. return self.manifest.name
  40. return self.path.name
  41. @property
  42. def version(self) -> str:
  43. """Plugin-Version."""
  44. if self.manifest:
  45. return self.manifest.version
  46. return "unknown"
  47. def to_dict(self) -> dict[str, Any]:
  48. """Konvertiert zu Dictionary."""
  49. return {
  50. "path": str(self.path),
  51. "name": self.name,
  52. "version": self.version,
  53. "valid": self.valid,
  54. "errors": self.errors,
  55. "warnings": self.warnings,
  56. "manifest": self.manifest.to_dict() if self.manifest else None,
  57. }
  58. class PluginDiscovery:
  59. """
  60. Erkennt und validiert Plugins in Verzeichnissen.
  61. Scannt Plugin-Verzeichnisse, lädt Manifeste und prüft
  62. grundlegende Anforderungen.
  63. Example:
  64. discovery = PluginDiscovery()
  65. # Alle Plugins im Verzeichnis finden
  66. plugins = discovery.scan(Path("plugins"))
  67. for result in plugins:
  68. if result.valid:
  69. print(f"Gefunden: {result.name} v{result.version}")
  70. else:
  71. print(f"Ungültig: {result.name}: {result.errors}")
  72. # Einzelnes Plugin prüfen
  73. result = discovery.check_plugin(Path("plugins/my_plugin"))
  74. # Plugin-Klasse extrahieren
  75. plugin_class = discovery.extract_plugin_class(result.path)
  76. """
  77. MANIFEST_FILES = ["manifest.json", "plugin.json"]
  78. ENTRY_POINTS = ["main.py", "plugin.py", "__init__.py"]
  79. CONFIG_FILES = ["config.json", "settings.json"]
  80. def __init__(self) -> None:
  81. """Initialisiert die Plugin-Erkennung."""
  82. self._cache: dict[Path, DiscoveryResult] = {}
  83. def scan(
  84. self,
  85. directory: Path,
  86. recursive: bool = False,
  87. use_cache: bool = True,
  88. ) -> list[DiscoveryResult]:
  89. """
  90. Scannt ein Verzeichnis nach Plugins.
  91. Args:
  92. directory: Zu scannendes Verzeichnis
  93. recursive: Auch Unterverzeichnisse scannen
  94. use_cache: Cache verwenden
  95. Returns:
  96. Liste der gefundenen Plugins
  97. """
  98. if not directory.exists():
  99. return []
  100. results = []
  101. for item in directory.iterdir():
  102. if not item.is_dir():
  103. continue
  104. # Versteckte und private Verzeichnisse ignorieren
  105. if item.name.startswith(".") or item.name.startswith("_"):
  106. continue
  107. # Cache prüfen
  108. if use_cache and item in self._cache:
  109. results.append(self._cache[item])
  110. continue
  111. result = self.check_plugin(item)
  112. results.append(result)
  113. if use_cache:
  114. self._cache[item] = result
  115. # Rekursiv scannen
  116. if recursive and not result.valid:
  117. results.extend(self.scan(item, recursive=True, use_cache=use_cache))
  118. return results
  119. def check_plugin(self, path: Path) -> DiscoveryResult:
  120. """
  121. Prüft ein einzelnes Plugin-Verzeichnis.
  122. Args:
  123. path: Pfad zum Plugin
  124. Returns:
  125. DiscoveryResult
  126. """
  127. errors: list[str] = []
  128. warnings: list[str] = []
  129. manifest: PluginManifest | None = None
  130. if not path.exists():
  131. return DiscoveryResult(
  132. path=path,
  133. valid=False,
  134. errors=["Verzeichnis existiert nicht"],
  135. )
  136. if not path.is_dir():
  137. return DiscoveryResult(
  138. path=path,
  139. valid=False,
  140. errors=["Kein Verzeichnis"],
  141. )
  142. # Manifest suchen
  143. manifest = self._find_manifest(path, errors, warnings)
  144. # Entry-Point prüfen
  145. entry_point = self._find_entry_point(path, manifest)
  146. if entry_point is None:
  147. errors.append(
  148. f"Kein Entry-Point gefunden (erwartet: {', '.join(self.ENTRY_POINTS)})"
  149. )
  150. # Config prüfen
  151. config_path = self._find_config(path)
  152. if config_path is None and manifest is None:
  153. warnings.append("Keine Konfigurationsdatei gefunden")
  154. # Syntax prüfen
  155. if entry_point:
  156. syntax_errors = self._check_syntax(entry_point)
  157. errors.extend(syntax_errors)
  158. valid = len(errors) == 0
  159. return DiscoveryResult(
  160. path=path,
  161. manifest=manifest,
  162. valid=valid,
  163. errors=errors,
  164. warnings=warnings,
  165. )
  166. def _find_manifest(
  167. self,
  168. path: Path,
  169. errors: list[str],
  170. warnings: list[str],
  171. ) -> PluginManifest | None:
  172. """Sucht und lädt das Manifest."""
  173. for manifest_name in self.MANIFEST_FILES:
  174. manifest_path = path / manifest_name
  175. if manifest_path.exists():
  176. try:
  177. return load_manifest(manifest_path)
  178. except ManifestError as e:
  179. errors.append(f"Manifest-Fehler: {e}")
  180. return None
  181. # Fallback: Aus main.py extrahieren
  182. main_file = path / "main.py"
  183. if main_file.exists():
  184. try:
  185. return self._extract_manifest_from_class(main_file, path.name)
  186. except Exception:
  187. pass
  188. warnings.append("Kein Manifest gefunden, verwende Standardwerte")
  189. return PluginManifest(name=path.name)
  190. def _extract_manifest_from_class(
  191. self,
  192. file_path: Path,
  193. default_name: str,
  194. ) -> PluginManifest | None:
  195. """Extrahiert Manifest-Informationen aus der Plugin-Klasse."""
  196. try:
  197. # Modul laden ohne auszuführen
  198. import ast
  199. with open(file_path, "r", encoding="utf-8") as f:
  200. tree = ast.parse(f.read())
  201. for node in ast.walk(tree):
  202. if isinstance(node, ast.ClassDef):
  203. # Klassenattribute extrahieren
  204. info = {"name": default_name}
  205. for item in node.body:
  206. if isinstance(item, ast.Assign):
  207. for target in item.targets:
  208. if isinstance(target, ast.Name):
  209. if target.id == "NAME" and isinstance(item.value, ast.Constant):
  210. info["name"] = item.value.value
  211. elif target.id == "VERSION" and isinstance(item.value, ast.Constant):
  212. info["version"] = item.value.value
  213. elif target.id == "DESCRIPTION" and isinstance(item.value, ast.Constant):
  214. info["description"] = item.value.value
  215. elif target.id == "AUTHOR" and isinstance(item.value, ast.Constant):
  216. info["author"] = item.value.value
  217. return PluginManifest(**info)
  218. except Exception:
  219. pass
  220. return None
  221. def _find_entry_point(
  222. self,
  223. path: Path,
  224. manifest: PluginManifest | None,
  225. ) -> Path | None:
  226. """Findet den Entry-Point."""
  227. if manifest and manifest.entry_point:
  228. entry = path / manifest.entry_point
  229. if entry.exists():
  230. return entry
  231. for entry_name in self.ENTRY_POINTS:
  232. entry = path / entry_name
  233. if entry.exists():
  234. return entry
  235. return None
  236. def _find_config(self, path: Path) -> Path | None:
  237. """Findet die Konfigurationsdatei."""
  238. for config_name in self.CONFIG_FILES:
  239. config = path / config_name
  240. if config.exists():
  241. return config
  242. return None
  243. def _check_syntax(self, file_path: Path) -> list[str]:
  244. """Prüft die Python-Syntax."""
  245. errors = []
  246. try:
  247. with open(file_path, "r", encoding="utf-8") as f:
  248. source = f.read()
  249. compile(source, str(file_path), "exec")
  250. except SyntaxError as e:
  251. errors.append(f"Syntax-Fehler in {file_path.name}: {e.msg} (Zeile {e.lineno})")
  252. return errors
  253. def extract_plugin_class(
  254. self,
  255. path: Path,
  256. ) -> type["TrixyPlugin"] | None:
  257. """
  258. Extrahiert die Plugin-Klasse aus einem Verzeichnis.
  259. Args:
  260. path: Pfad zum Plugin
  261. Returns:
  262. Die Plugin-Klasse oder None
  263. """
  264. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  265. result = self.check_plugin(path)
  266. if not result.valid:
  267. return None
  268. entry_point = self._find_entry_point(path, result.manifest)
  269. if entry_point is None:
  270. return None
  271. try:
  272. spec = importlib.util.spec_from_file_location(
  273. f"trixy_plugins.{path.name}",
  274. entry_point
  275. )
  276. if spec is None or spec.loader is None:
  277. return None
  278. module = importlib.util.module_from_spec(spec)
  279. spec.loader.exec_module(module)
  280. # Plugin-Klasse finden
  281. for attr_name in dir(module):
  282. if attr_name.startswith("_"):
  283. continue
  284. attr = getattr(module, attr_name)
  285. if (
  286. isinstance(attr, type) and
  287. issubclass(attr, TrixyPlugin) and
  288. attr is not TrixyPlugin
  289. ):
  290. return attr
  291. except Exception:
  292. pass
  293. return None
  294. def get_valid_plugins(
  295. self,
  296. directory: Path,
  297. ) -> list[DiscoveryResult]:
  298. """
  299. Gibt nur valide Plugins zurück.
  300. Args:
  301. directory: Zu scannendes Verzeichnis
  302. Returns:
  303. Liste valider Plugins
  304. """
  305. results = self.scan(directory)
  306. return [r for r in results if r.valid]
  307. def clear_cache(self, path: Path | None = None) -> None:
  308. """
  309. Löscht den Cache.
  310. Args:
  311. path: Optionaler spezifischer Pfad
  312. """
  313. if path:
  314. self._cache.pop(path, None)
  315. else:
  316. self._cache.clear()