summaryrefslogtreecommitdiff
path: root/plugins/plugin_manager.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-08-01 19:06:36 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-08-01 19:06:36 -0600
commit0aa59c13af65beeae104662593b21ba4d8a37789 (patch)
tree17a1e6bd92a3a236bef0bca2e11ca7a875ba6caa /plugins/plugin_manager.py
parentbfbdd0c19910f464e779fa64cc0ec8590f8e37c1 (diff)
Rename LabDAQ → LabUI and overhaul plugin system
Branding: - Rename app, window title, file extension (.labdaq → .labui), user data dirs (~/.labui/), spec file (labdaq.spec → labui.spec), and APP_NAME throughout all source, docs, and config files Plugin system: - Plugins no longer bundled in the PyInstaller build — installed at runtime by users via Settings → Plugins → Install Plugin (zip) - PluginManager now takes user_dir + extra_scan_dirs; user plugins live in ~/.labui/plugins/, dev scan additionally covers project plugins/ - install_from_zip / uninstall / is_user_installed added to PluginManager - vendor/ dir inside plugin zips: prepended to sys.path at load time so plugins can ship their own deps without requiring pip on end-user machine - source_url field in manifest.json: shown as Download button in the missing-plugins dialog when a profile requires an absent plugin - Frozen-app pip install now targets ~/.labui/plugin_packages/ using a real system Python (sys.executable is the exe in frozen builds) Profile loading: - Profile now stores plugins_manifest snapshot (id, name, version, source_url) alongside plugins_enabled - On load, missing or dep-broken plugins trigger MissingPluginsDialog before the rest of the profile is applied; user can install from zip or download via source_url in-dialog, or cancel the load - Plugin reconciliation only enables installed plugins — missing ones are not written to enabled.json UI: - Version label added to status bar (bottom-right, muted colour) - Settings → Plugins tab: Install Plugin… button, per-plugin Remove button for user-installed plugins, live list refresh after install/remove Docs: - New docs/building.md covers the full release pipeline - docs/plugin-development.md updated for new install flow, vendoring, source_url, profile behaviour, and distribution instructions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'plugins/plugin_manager.py')
-rw-r--r--plugins/plugin_manager.py151
1 files changed, 115 insertions, 36 deletions
diff --git a/plugins/plugin_manager.py b/plugins/plugin_manager.py
index 5cefc2e..4fceba2 100644
--- a/plugins/plugin_manager.py
+++ b/plugins/plugin_manager.py
@@ -1,7 +1,7 @@
"""
plugins/plugin_manager.py
-Discovers and manages the lifecycle of LabDAQ plugins.
+Discovers and manages the lifecycle of LabUI plugins.
Enabled state is persisted to plugins/enabled.json (one dict
{plugin_id: bool}). This survives app restarts; profiles store
@@ -39,6 +39,7 @@ class PluginManifest:
entry_point: str = "plugin.Plugin" # "module.ClassName" relative to plugin dir
requires: List[str] = field(default_factory=list) # pip-style reqs, e.g. "opencv-python>=4.8.0"
plugin_dir: str = ""
+ source_url: str = ""
def _dist_name(requirement: str) -> str:
@@ -70,11 +71,11 @@ def missing_requirements(requires: List[str]) -> List[str]:
class PluginManager:
"""
- Discovers, loads, and lifecycle-manages LabDAQ plugins.
+ Discovers, loads, and lifecycle-manages LabUI plugins.
Typical usage in MainWindow:
- self._plugins = PluginManager(plugins_dir)
+ self._plugins = PluginManager(user_dir, extra_scan_dirs=[project_plugins])
self._plugins.discover()
for plugin in self._plugins.load_enabled(context):
self._install_plugin(plugin)
@@ -89,9 +90,10 @@ class PluginManager:
self._uninstall_plugin("my_plugin")
"""
- def __init__(self, plugins_dir: str):
- self._dir = plugins_dir
- self._enabled_path = os.path.join(plugins_dir, _ENABLED_FILE)
+ def __init__(self, user_dir: str, extra_scan_dirs: List[str] = None):
+ self._user_dir = user_dir
+ self._scan_dirs = [user_dir] + list(extra_scan_dirs or [])
+ self._enabled_path = os.path.join(user_dir, _ENABLED_FILE)
self._manifests: Dict[str, PluginManifest] = {}
self._loaded: Dict[str, LabPlugin] = {}
self._enabled: Dict[str, bool] = {}
@@ -100,34 +102,36 @@ class PluginManager:
# ── Discovery ─────────────────────────────────────────────────────────
def discover(self) -> List[PluginManifest]:
- """Scan plugin directory and return all found manifests."""
+ """Scan all plugin directories and return all found manifests."""
self._manifests.clear()
- if not os.path.isdir(self._dir):
- return []
-
- for entry in sorted(os.listdir(self._dir)):
- plugin_dir = os.path.join(self._dir, entry)
- if not os.path.isdir(plugin_dir):
+ for scan_dir in self._scan_dirs:
+ if not os.path.isdir(scan_dir):
continue
- manifest_path = os.path.join(plugin_dir, _MANIFEST_FILE)
- if not os.path.isfile(manifest_path):
- continue
- try:
- with open(manifest_path) as f:
- data = json.load(f)
- m = PluginManifest(
- plugin_id = data["plugin_id"],
- name = data.get("name", entry),
- version = data.get("version", "1.0.0"),
- description = data.get("description", ""),
- author = data.get("author", ""),
- entry_point = data.get("entry_point", "plugin.Plugin"),
- requires = data.get("requires", []),
- plugin_dir = plugin_dir,
- )
- self._manifests[m.plugin_id] = m
- except Exception as exc:
- print(f"[PluginManager] Bad manifest in '{entry}': {exc}")
+ for entry in sorted(os.listdir(scan_dir)):
+ plugin_dir = os.path.join(scan_dir, entry)
+ if not os.path.isdir(plugin_dir):
+ continue
+ manifest_path = os.path.join(plugin_dir, _MANIFEST_FILE)
+ if not os.path.isfile(manifest_path):
+ continue
+ try:
+ with open(manifest_path) as f:
+ data = json.load(f)
+ m = PluginManifest(
+ plugin_id = data["plugin_id"],
+ name = data.get("name", entry),
+ version = data.get("version", "1.0.0"),
+ description = data.get("description", ""),
+ author = data.get("author", ""),
+ entry_point = data.get("entry_point", "plugin.Plugin"),
+ requires = data.get("requires", []),
+ plugin_dir = plugin_dir,
+ source_url = data.get("source_url", ""),
+ )
+ if m.plugin_id not in self._manifests:
+ self._manifests[m.plugin_id] = m
+ except Exception as exc:
+ print(f"[PluginManager] Bad manifest in '{entry}': {exc}")
return list(self._manifests.values())
@@ -142,7 +146,7 @@ class PluginManager:
self._enabled = {}
def _save_enabled_state(self):
- os.makedirs(self._dir, exist_ok=True)
+ os.makedirs(self._user_dir, exist_ok=True)
with open(self._enabled_path, "w") as f:
json.dump(self._enabled, f, indent=2)
@@ -175,7 +179,7 @@ class PluginManager:
print(f"[PluginManager] No manifest for '{plugin_id}'")
return None
- missing = missing_requirements(manifest.requires)
+ missing = self.get_missing_dependencies(plugin_id)
if missing:
print(f"[Plugin] '{plugin_id}' missing dependencies: {', '.join(missing)}")
return None
@@ -193,9 +197,12 @@ class PluginManager:
stage = "importing module"
spec = importlib.util.spec_from_file_location(
- f"_labdaq_plugin_{plugin_id}", module_file
+ f"_labui_plugin_{plugin_id}", module_file
)
mod = importlib.util.module_from_spec(spec)
+ vendor = os.path.join(manifest.plugin_dir, "vendor")
+ if os.path.isdir(vendor) and vendor not in sys.path:
+ sys.path.insert(0, vendor)
if manifest.plugin_dir not in sys.path:
sys.path.insert(0, manifest.plugin_dir)
spec.loader.exec_module(mod)
@@ -252,12 +259,84 @@ class PluginManager:
def get_manifests(self) -> List[PluginManifest]:
return list(self._manifests.values())
+ def get_manifest(self, plugin_id: str) -> Optional[PluginManifest]:
+ return self._manifests.get(plugin_id)
+
def get_missing_dependencies(self, plugin_id: str) -> List[str]:
manifest = self._manifests.get(plugin_id)
- return missing_requirements(manifest.requires) if manifest else []
+ if not manifest:
+ return []
+ vendor = os.path.join(manifest.plugin_dir, "vendor")
+ if os.path.isdir(vendor):
+ added = vendor not in sys.path
+ if added:
+ sys.path.insert(0, vendor)
+ try:
+ return missing_requirements(manifest.requires)
+ finally:
+ if added:
+ sys.path.remove(vendor)
+ return missing_requirements(manifest.requires)
def get_loaded(self) -> List[LabPlugin]:
return list(self._loaded.values())
def get_plugin(self, plugin_id: str) -> Optional[LabPlugin]:
return self._loaded.get(plugin_id)
+
+ # ── Install / uninstall ───────────────────────────────────────────────
+
+ def install_from_zip(self, zip_path: str) -> PluginManifest:
+ """Extract a plugin zip into user_dir and re-discover."""
+ import shutil
+ import zipfile
+
+ with zipfile.ZipFile(zip_path) as zf:
+ names = zf.namelist()
+ top_dirs = {n.split("/")[0] for n in names if "/" in n}
+ if not top_dirs:
+ raise ValueError("Plugin zip must contain a top-level directory")
+ if len(top_dirs) != 1:
+ raise ValueError(
+ f"Plugin zip must contain exactly one top-level directory, found: {sorted(top_dirs)}"
+ )
+ plugin_folder = top_dirs.pop()
+ if f"{plugin_folder}/manifest.json" not in names:
+ raise ValueError(f"No {plugin_folder}/manifest.json found in zip")
+ dest = os.path.join(self._user_dir, plugin_folder)
+ if os.path.exists(dest):
+ shutil.rmtree(dest)
+ os.makedirs(self._user_dir, exist_ok=True)
+ zf.extractall(self._user_dir)
+
+ self.discover()
+ for m in self._manifests.values():
+ if os.path.basename(m.plugin_dir) == plugin_folder:
+ return m
+ raise ValueError("Plugin installed but not found after discovery")
+
+ def uninstall(self, plugin_id: str):
+ """Delete a user-installed plugin from disk. Must be disabled first."""
+ import shutil
+
+ manifest = self._manifests.get(plugin_id)
+ if manifest is None:
+ raise ValueError(f"Plugin '{plugin_id}' not found")
+ if not self.is_user_installed(plugin_id):
+ raise ValueError(f"Plugin '{plugin_id}' is not user-installed and cannot be removed")
+ if self.is_enabled(plugin_id):
+ raise RuntimeError(f"Disable '{plugin_id}' before uninstalling")
+ shutil.rmtree(manifest.plugin_dir)
+ self._manifests.pop(plugin_id, None)
+
+ def is_user_installed(self, plugin_id: str) -> bool:
+ """True if the plugin lives inside user_dir (i.e. can be uninstalled)."""
+ manifest = self._manifests.get(plugin_id)
+ if manifest is None:
+ return False
+ return os.path.abspath(manifest.plugin_dir).startswith(
+ os.path.abspath(self._user_dir)
+ )
+
+ def get_user_dir(self) -> str:
+ return self._user_dir