diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-08-02 11:05:48 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-08-02 11:05:48 -0600 |
| commit | 1c349a982026736f4c2a26951d9d0aefe7b963bc (patch) | |
| tree | b66175df392e3d707ca45650facfd18bcba6c337 /plugins | |
| parent | fa0304793a4525db68cb53b527d695bfa2c65966 (diff) | |
| parent | 0aa59c13af65beeae104662593b21ba4d8a37789 (diff) | |
Merge branch 'feat/auto-updater'
Diffstat (limited to 'plugins')
| -rw-r--r-- | plugins/__init__.py | 2 | ||||
| -rw-r--r-- | plugins/base_plugin.py | 8 | ||||
| -rw-r--r-- | plugins/motion_capture/manifest.json | 2 | ||||
| -rw-r--r-- | plugins/motion_capture/plugin.py | 4 | ||||
| -rw-r--r-- | plugins/plugin_manager.py | 151 |
5 files changed, 123 insertions, 44 deletions
diff --git a/plugins/__init__.py b/plugins/__init__.py index bdb24e0..d1733a8 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -1,7 +1,7 @@ """ plugins/ -LabDAQ plugin system. Each plugin lives in its own subdirectory: +LabUI plugin system. Each plugin lives in its own subdirectory: plugins/ my_plugin/ diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py index 2dbb56d..d728d08 100644 --- a/plugins/base_plugin.py +++ b/plugins/base_plugin.py @@ -1,7 +1,7 @@ """ plugins/base_plugin.py -Abstract base class for all LabDAQ plugins. +Abstract base class for all LabUI plugins. To create a plugin: 1. Create a directory under plugins/ e.g. plugins/my_plugin/ @@ -25,7 +25,7 @@ Integration points (all optional — override only what you need): get_devices() → list of BaseDevice auto-added to registry + engine get_filter_classes() → {name: FilterBase cls} custom signal pipeline filters get_settings_widget() → QWidget | None shown in Settings > Plugins - get_save_state() → dict persisted in .labdaq profiles + get_save_state() → dict persisted in .labui profiles apply_save_state(dict) restore from profile """ @@ -68,7 +68,7 @@ class PluginAction: class LabPlugin(ABC): """ - Abstract base for all LabDAQ plugins. + Abstract base for all LabUI plugins. Subclass this, set the metadata properties, and override whatever integration hooks your plugin needs. @@ -129,7 +129,7 @@ class LabPlugin(ABC): # ── Profile persistence ─────────────────────────────────────────────── def get_save_state(self) -> Dict[str, Any]: - """Return a JSON-serialisable dict, saved with the .labdaq profile.""" + """Return a JSON-serialisable dict, saved with the .labui profile.""" return {} def apply_save_state(self, state: Dict[str, Any]) -> None: diff --git a/plugins/motion_capture/manifest.json b/plugins/motion_capture/manifest.json index f1f10ce..59e5d02 100644 --- a/plugins/motion_capture/manifest.json +++ b/plugins/motion_capture/manifest.json @@ -3,7 +3,7 @@ "name": "Motion Capture", "version": "1.0.0", "description": "Track a point via webcam and stream x/y position as live signals. Apply pixel_to_mm filter to convert to real-world units.", - "author": "LabDAQ", + "author": "LabUI", "entry_point": "plugin.MotionCapturePlugin", "requires": ["opencv-python>=4.8.0"] } diff --git a/plugins/motion_capture/plugin.py b/plugins/motion_capture/plugin.py index 9cf9557..d6ca2b3 100644 --- a/plugins/motion_capture/plugin.py +++ b/plugins/motion_capture/plugin.py @@ -1,7 +1,7 @@ """ motion_capture/plugin.py -LabDAQ Motion Capture plugin. +LabUI Motion Capture plugin. When enabled, adds a "Camera" device type to the Add Device dialog. Each camera added by the user becomes a first-class device in the @@ -44,7 +44,7 @@ class MotionCapturePlugin(LabPlugin): ) @property - def author(self) -> str: return "LabDAQ" + def author(self) -> str: return "LabUI" # ── Lifecycle ───────────────────────────────────────────────────────── 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 |
