From 2c70911b24bd9fbf8f0aa564456f2d109d106507 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:52:21 +0200 Subject: [PATCH 1/3] fix(plugins): load plugin packages from their folder without installing them (#411) A plugin package found in a plugin directory was only registered if its package root defined the plugin class. Layered packages, as created by `datalab-plugin create`, keep their root free of Qt and define the class in `adapters/desktop.py`, which only the entry point of an installed distribution named. A fresh clone added as plugin directory was silently ignored, while a checkout holding `.egg-info` from an earlier install was loaded. - After importing a `datalab_*` package, discovery also imports its `adapters.desktop` module when it exists. Errors in that module are reported with its name and file, without blocking other plugins. - A module is reloaded at most once per discovery pass, whether it is found by an entry point, by naming convention or both. - A configured plugin directory whose `src` subfolder holds `datalab_*` modules or packages is searched together with that subfolder, so that users may add the project folder itself. - Generated projects explain how to try the plugin in DataLab without installing it. Assisted-by: Claude Opus 5.5 --- datalab/plugins/base.py | 174 ++++++++++++++++++++++++++--------- datalab/plugins/generator.py | 23 +++-- 2 files changed, 150 insertions(+), 47 deletions(-) diff --git a/datalab/plugins/base.py b/datalab/plugins/base.py index 086e0bbc3..5e9b28d36 100644 --- a/datalab/plugins/base.py +++ b/datalab/plugins/base.py @@ -73,6 +73,11 @@ PLUGIN_ENTRY_POINT_GROUP = DESKTOP_ENTRY_POINT_GROUP #: Plugins installed from a file (kept out of the plugin search path) INSTALLED_PLUGINS_PATH = get_config_path("installed_plugins") +#: Module of a ``datalab_*`` package defining its plugin class, as laid out by +#: ``datalab-plugin create``: the package root stays free of Qt +PACKAGE_DESKTOP_MODULE = "adapters.desktop" +#: Subfolder of a plugin project holding its ``datalab_*`` packages +PROJECT_SOURCE_FOLDER = "src" if not osp.isdir(PLUGINS_DEFAULT_PATH): os.makedirs(PLUGINS_DEFAULT_PATH) @@ -1092,6 +1097,86 @@ def _add_plugin_discovery_source(plugin_class: type[PluginBase], source: str) -> plugin_class.__plugin_discovery_sources__ = tuple(dict.fromkeys((*sources, source))) +def _holds_plugin_modules(directory: str) -> bool: + """Return True if a directory holds ``datalab_*`` modules or packages.""" + prefix = f"{MOD_NAME}_" + try: + with os.scandir(directory) as entries: + for entry in entries: + if not entry.name.startswith(prefix): + continue + if entry.is_file() and entry.name.endswith(".py"): + return True + if entry.is_dir() and osp.isfile(osp.join(entry.path, "__init__.py")): + return True + except OSError: + return False + return False + + +def _get_plugin_search_paths() -> list[str]: + """Return the directories searched for plugins, in discovery order. + + The ``src`` subfolder of a plugin project is searched too when it holds + ``datalab_*`` modules or packages, so that users may simply add the project + folder. + """ + paths: list[str] = [] + for path in ( + get_user_plugin_paths() + [PLUGINS_DEFAULT_PATH] + OTHER_PLUGINS_PATHLIST + ): + rpath = osp.realpath(path) + source_path = osp.join(rpath, PROJECT_SOURCE_FOLDER) + candidates = [rpath] + if _holds_plugin_modules(source_path): + candidates.append(source_path) + paths.extend(candidate for candidate in candidates if candidate not in paths) + return paths + + +def _extend_sys_path_with_plugin_paths() -> None: + """Append the plugin search paths missing from ``sys.path``.""" + for path in _get_plugin_search_paths(): + if path not in sys.path: + sys.path.append(path) + + +def _reload_once( + name: str, reloadable_modules: Collection[str], reloaded_modules: set[str] +) -> ModuleType | None: + """Reload a module imported before the discovery pass, at most once per pass. + + Reloading takes code changes into account (hot reload). A module shared by + several entry points, or found both through an entry point and by naming + convention, is reloaded only once. + + Args: + name: Module name + reloadable_modules: Modules imported before the discovery pass + reloaded_modules: Modules already reloaded during the pass (updated) + + Returns: + The reloaded module, or None if the module was not imported before the + pass or was already reloaded + """ + module = sys.modules.get(name) + if module is None or name not in reloadable_modules or name in reloaded_modules: + return None + reloaded_modules.add(name) + return importlib.reload(module) + + +def _find_package_desktop_module(package_name: str) -> str | None: + """Return the full name of the Desktop module of a package, if it has one.""" + module_name = package_name + for part in PACKAGE_DESKTOP_MODULE.split("."): + spec = importlib.util.find_spec(module_name) + if spec is None or spec.submodule_search_locations is None: + return None + module_name = f"{module_name}.{part}" + return module_name if importlib.util.find_spec(module_name) else None + + def _get_plugin_entry_points() -> list[importlib_metadata.EntryPoint]: """Return installed DataLab plugin entry points in deterministic order.""" entry_points = importlib_metadata.entry_points() @@ -1121,8 +1206,15 @@ def _record_plugin_discovery_failure( PluginRegistry.add_failed_plugin(name, filepath, tb_text, source) -def _discover_entry_point_plugins() -> list[ModuleType]: - """Load and register plugin classes declared through package entry points.""" +def _discover_entry_point_plugins( + reloadable_modules: Collection[str], reloaded_modules: set[str] +) -> list[ModuleType]: + """Load and register plugin classes declared through package entry points. + + Args: + reloadable_modules: Modules imported before the discovery pass + reloaded_modules: Modules already reloaded during the pass (updated) + """ try: entry_points = _get_plugin_entry_points() # Installed distribution metadata is external input. A malformed package @@ -1135,8 +1227,6 @@ def _discover_entry_point_plugins() -> list[ModuleType]: return [] discovered_modules: list[ModuleType] = [] - reloadable_modules = set(sys.modules) - reloaded_modules: set[str] = set() for entry_point in entry_points: source = f"entry point {entry_point.name!r} ({entry_point.value})" plugin_classes = PluginRegistry.get_plugin_classes() @@ -1144,12 +1234,8 @@ def _discover_entry_point_plugins() -> list[ModuleType]: try: try: module_name = getattr(entry_point, "module", None) - if ( - module_name in reloadable_modules - and module_name not in reloaded_modules - ): - importlib.reload(sys.modules[module_name]) - reloaded_modules.add(module_name) + if module_name: + _reload_once(module_name, reloadable_modules, reloaded_modules) plugin_class = entry_point.load() finally: # Importing the target module may invoke PluginBaseMeta. The entry @@ -1262,7 +1348,9 @@ def discover_plugins() -> list[ModuleType]: Installed packages may expose a :class:`PluginBase` subclass through the ``datalab.plugins`` entry-point group. This function also reloads or imports - modules matching the historical ``"{MOD_NAME}_*"`` naming scheme. + modules and packages matching the historical ``"{MOD_NAME}_*"`` naming scheme, + as well as the ``adapters.desktop`` module of such packages, which defines + the plugin class of packages laid out by ``datalab-plugin create``. Import errors for individual plugins are captured and logged so that one broken plugin does not prevent the others from loading. Error @@ -1282,13 +1370,7 @@ def discover_plugins() -> list[ModuleType]: if not Conf.plugins_enabled.get(): return [] - # Ensure plugin search paths are present in sys.path - for path in ( - get_user_plugin_paths() + [PLUGINS_DEFAULT_PATH] + OTHER_PLUGINS_PATHLIST - ): - rpath = osp.realpath(path) - if rpath not in sys.path: - sys.path.append(rpath) + _extend_sys_path_with_plugin_paths() try: get_installed_plugin_store().activate() except (OSError, ValueError): @@ -1299,42 +1381,59 @@ def discover_plugins() -> list[ModuleType]: INSTALLED_PLUGINS_PATH, ) - modules = _discover_entry_point_plugins() - for finder, name, _ispkg in pkgutil.iter_modules(): + reloadable_modules = set(sys.modules) + reloaded_modules: set[str] = set() + modules = _discover_entry_point_plugins(reloadable_modules, reloaded_modules) + for finder, name, ispkg in pkgutil.iter_modules(): if not name.startswith(f"{MOD_NAME}_"): continue + source = f"module convention {name!r}" + module_name = name try: previous_classes = list(PluginRegistry.get_plugin_classes()) # If module is already loaded, reload it so that code changes # are taken into account (useful for hot-reload in dev). - if name in sys.modules: - module = importlib.reload(sys.modules[name]) - else: - module = importlib.import_module(name) - source = f"module convention {name!r}" + loaded_modules = [ + _reload_once(name, reloadable_modules, reloaded_modules) + or importlib.import_module(name) + ] + desktop_module_name = _find_package_desktop_module(name) if ispkg else None + if desktop_module_name is not None: + module_name = desktop_module_name + loaded_modules.append( + _reload_once(module_name, reloadable_modules, reloaded_modules) + or importlib.import_module(module_name) + ) + loaded_names = {module.__name__ for module in loaded_modules} for plugin_class in PluginRegistry.get_plugin_classes(): - if plugin_class not in previous_classes: + # Classes already registered by an entry point in this pass are + # also found by convention, without executing their module again + if ( + plugin_class not in previous_classes + or plugin_class.__module__ in loaded_names + ): _add_plugin_discovery_source(plugin_class, source) - _set_plugin_class_filepaths(module) - if module not in modules: - modules.append(module) + for module in loaded_modules: + _set_plugin_class_filepaths(module) + if module not in modules: + modules.append(module) # Plugin discovery imports arbitrary third-party modules. We must catch # every failure here so discovery can continue and the error is exposed # through the console, log files, and plugin configuration dialog. except Exception as e: # pylint: disable=broad-except tb_text = traceback.format_exc() - print(f"Error loading plugin '{name}': {e}") + print(f"Error loading plugin '{module_name}': {e}") traceback.print_exc() # Log to file so it appears in Log Files viewer logger = logging.getLogger(__name__) - logger.error("Error loading plugin '%s'", name, exc_info=True) + logger.error("Error loading plugin '%s'", module_name, exc_info=True) Conf.traceback_log_available.set(True) # Accumulate for replay in internal console PluginRegistry.add_discovery_error(tb_text) # Record structured info about the failed plugin filepath = "" try: - spec = importlib.util.find_spec(name) + spec = importlib.util.find_spec(module_name) if spec and spec.origin: filepath = spec.origin # Best effort only: failing to resolve the file path must never mask @@ -1342,9 +1441,7 @@ def discover_plugins() -> list[ModuleType]: except Exception: # pylint: disable=broad-except if hasattr(finder, "path"): filepath = osp.join(finder.path, name) - PluginRegistry.add_failed_plugin( - name, filepath, tb_text, f"module convention {name!r}" - ) + PluginRegistry.add_failed_plugin(module_name, filepath, tb_text, source) _normalize_discovered_plugin_classes() return modules @@ -1381,12 +1478,7 @@ def discover_v020_plugins() -> list[tuple[str, str]]: """ v020_plugins = [] if Conf.plugins_enabled.get(): - for path in ( - get_user_plugin_paths() + [PLUGINS_DEFAULT_PATH] + OTHER_PLUGINS_PATHLIST - ): - rpath = osp.realpath(path) - if rpath not in sys.path: - sys.path.append(rpath) + _extend_sys_path_with_plugin_paths() for finder, name, _ispkg in pkgutil.iter_modules(): if name.startswith("cdl_"): # Get the directory path from the module finder diff --git a/datalab/plugins/generator.py b/datalab/plugins/generator.py index 910b2fc65..6e3dbb9d1 100644 --- a/datalab/plugins/generator.py +++ b/datalab/plugins/generator.py @@ -408,6 +408,12 @@ def _render_readme(project: PluginProject) -> str: {project.description} +## Trying the plugin in DataLab + +Add this project folder in **Plugins > Configure plugins... > Plugin settings**, +then reload the plugins: DataLab loads the plugin from +`src/{project.package}/adapters/desktop.py`, without installing the project. + ## Development ```bash @@ -416,11 +422,11 @@ def _render_readme(project: PluginProject) -> str: python -m ruff check . ``` -Installing the project registers `{project.plugin_id}` through the -`datalab.plugins` entry-point group. Put host-independent algorithms in `core`, -compose them into headless recipes in `workflow`, and keep DataLab or browser -integration in `adapters`. The generated architecture test preserves these -dependency boundaries as the plugin grows. +Installing the project also registers `{project.plugin_id}` through the +`datalab.plugins` entry-point group, as the published wheel does. Put +host-independent algorithms in `core`, compose them into headless recipes in +`workflow`, and keep DataLab or browser integration in `adapters`. The generated +architecture test preserves these dependency boundaries as the plugin grows. ## Publishing @@ -514,7 +520,8 @@ def _render_architecture_doc(project: PluginProject) -> str: - `core` owns host-independent domain behavior and does not import DataLab. - `workflow` may use DataLab's headless recipe contracts, but not GUI modules. -- `adapters/desktop.py` is the installed DataLab plugin entry point. +- `adapters/desktop.py` defines the DataLab plugin class: DataLab loads it from + the project folder, and the installed entry point targets it. - `adapters/web.py` records Web support explicitly and starts as unsupported. The package root exposes identity metadata without importing a host adapter. @@ -526,6 +533,10 @@ def _render_contributing() -> str: """Render concise development and architecture rules.""" return """# Contributing +To try a change in DataLab, add the project folder in **Plugins > Configure +plugins... > Plugin settings** and reload the plugins: no installation is +needed. + Install the project in editable mode and run its checks before submitting a change: From 49f52366b46a63bff27a0d62319d89553b468142 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:52:25 +0200 Subject: [PATCH 2/3] test(plugins): load fresh plugin projects from their folder (#411) Plugin projects without any distribution metadata, configured either as project folder or as `src` folder, are discovered with their stable ID. Single-file plugins and packages without `adapters/desktop.py` behave as before; a failing Desktop module is reported without blocking other plugins; hot reload executes the edited module once; a package also declared by an entry point is registered once with both sources; and only `src` folders holding `datalab_*` modules are searched. A project freshly created by `datalab-plugin create` is loaded from its folder without being installed. Assisted-by: Claude Opus 5.5 --- .../plugins/plugin_generator_unit_test.py | 85 ++++- .../plugins/plugins_registry_unit_test.py | 326 ++++++++++++++++++ 2 files changed, 410 insertions(+), 1 deletion(-) diff --git a/datalab/tests/backbone/plugins/plugin_generator_unit_test.py b/datalab/tests/backbone/plugins/plugin_generator_unit_test.py index 39fac0191..d725ee48b 100644 --- a/datalab/tests/backbone/plugins/plugin_generator_unit_test.py +++ b/datalab/tests/backbone/plugins/plugin_generator_unit_test.py @@ -6,6 +6,7 @@ import importlib import os +import pkgutil import subprocess import sys from pathlib import Path @@ -14,8 +15,11 @@ import yaml from datalab import __version__ -from datalab.plugins import PluginCapability, PluginRegistry +from datalab.config import Conf +from datalab.plugins import PluginCapability, PluginRegistry, discover_plugins +from datalab.plugins import base as plugin_base from datalab.plugins.generator import _default_plugin_id, main +from datalab.plugins.store import InstalledPluginStore PROJECT_ROOT = Path(__file__).parents[4] @@ -219,6 +223,85 @@ def test_create_application_only_project(tmp_path: Path) -> None: assert "register_computations" not in desktop_adapter +def test_generated_project_is_loaded_from_its_folder( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """DataLab loads a freshly generated project from its folder (Issue #411).""" + destination = tmp_path / "folder-plugin" + package_name = "datalab_folder_plugin" + assert ( + main( + [ + "create", + str(destination), + "--name", + "Folder Plugin", + "--package", + package_name, + "--plugin-id", + "org.example.folder-plugin", + "--description", + "Plugin loaded from its project folder", + ] + ) + == 0 + ) + readme = (destination / "README.md").read_text(encoding="utf-8") + assert "**Plugins > Configure plugins... > Plugin settings**" in readme + + # Only the project folder is scanned: no installed plugin, no user plugin + iter_modules = pkgutil.iter_modules + store = InstalledPluginStore(str(tmp_path / "installed_plugins")) + monkeypatch.setattr(sys, "path", list(sys.path)) + monkeypatch.setattr( + plugin_base, "get_user_plugin_paths", lambda: [str(destination)] + ) + monkeypatch.setattr(plugin_base, "PLUGINS_DEFAULT_PATH", str(tmp_path / "none")) + monkeypatch.setattr(plugin_base, "OTHER_PLUGINS_PATHLIST", []) + monkeypatch.setattr(plugin_base, "get_installed_plugin_store", lambda: store) + monkeypatch.setattr(plugin_base, "_get_plugin_entry_points", lambda: []) + monkeypatch.setattr( + plugin_base.pkgutil, + "iter_modules", + # pylint: disable-next=protected-access + lambda: iter_modules(plugin_base._get_plugin_search_paths()), + ) + monkeypatch.setattr(Conf.plugins_enabled, "get", lambda: True) + previous_classes = list(PluginRegistry.get_plugin_classes()) + discovery_errors = PluginRegistry.get_discovery_errors() + failed_plugins = PluginRegistry.get_failed_plugins() + PluginRegistry.clear_plugin_classes() + try: + discover_plugins() + + assert not [*destination.rglob("*.egg-info"), *destination.rglob("*.dist-info")] + plugin_classes = PluginRegistry.get_plugin_classes() + assert len(plugin_classes) == 1 + plugin_class = plugin_classes[0] + assert plugin_class.get_plugin_id() == "org.example.folder-plugin" + assert plugin_class.__module__ == f"{package_name}.adapters.desktop" + assert not PluginRegistry.get_failed_plugins() + finally: + PluginRegistry.clear_plugin_classes() + PluginRegistry.get_plugin_classes().extend(previous_classes) + PluginRegistry.clear_discovery_errors() + for tb_text in discovery_errors: + PluginRegistry.add_discovery_error(tb_text) + PluginRegistry.clear_failed_plugins() + for failed_plugin in failed_plugins: + PluginRegistry.add_failed_plugin( + failed_plugin.name, + failed_plugin.filepath, + failed_plugin.traceback, + failed_plugin.source, + ) + for module_name in tuple(sys.modules): + if module_name == package_name or module_name.startswith( + f"{package_name}." + ): + sys.modules.pop(module_name) + + def test_create_refuses_existing_destination(tmp_path: Path) -> None: """The CLI never overwrites an existing project directory.""" destination = tmp_path / "existing" diff --git a/datalab/tests/backbone/plugins/plugins_registry_unit_test.py b/datalab/tests/backbone/plugins/plugins_registry_unit_test.py index be35408c0..8a7010861 100644 --- a/datalab/tests/backbone/plugins/plugins_registry_unit_test.py +++ b/datalab/tests/backbone/plugins/plugins_registry_unit_test.py @@ -5,6 +5,8 @@ from __future__ import annotations import importlib +import os.path as osp +import pkgutil import sys import textwrap from collections.abc import Iterator @@ -23,6 +25,8 @@ discover_plugins, migrate_enabled_plugin_ids, ) +from datalab.plugins import base as plugin_base +from datalab.plugins.store import InstalledPluginStore @pytest.fixture(autouse=True) @@ -733,3 +737,325 @@ def register_hooks(self: PluginBase) -> None: assert not plugin.is_registered() assert plugin.main is None assert plugin.proxy is None + + +# --- Plugin packages found in plugin directories (Issue #411) --------------------- + +FOLDER_PACKAGE_PREFIX = "datalab_folder_probe" + + +def write_plugin_project( + project: Path, suffix: str, *, version: str = "1.0.0", desktop: str | None = None +) -> str: + """Write a plugin project laid out as ``datalab-plugin create`` does. + + The project has no distribution metadata (no ``.dist-info`` or ``.egg-info``), + as a fresh clone. + + Args: + project: Project folder + suffix: Suffix of the package name and of the plugin ID + version: Plugin version + desktop: Source of ``adapters/desktop.py`` (default: a plugin class) + + Returns: + Package name + """ + package = f"{FOLDER_PACKAGE_PREFIX}_{suffix}" + root = project / "src" / package + (root / "adapters").mkdir(parents=True) + (root / "__init__.py").write_text( + f'"""Qt-free package identity."""\n\nPLUGIN_ID = "org.example.{suffix}"\n', + encoding="utf-8", + ) + (root / "adapters" / "__init__.py").write_text( + '"""Host adapters."""\n', encoding="utf-8" + ) + if desktop is None: + desktop = textwrap.dedent( + f""" + from datalab.plugins import PluginBase, PluginInfo + + from .. import PLUGIN_ID + + + class FolderPlugin(PluginBase): + PLUGIN_INFO = PluginInfo( + id=PLUGIN_ID, name="{suffix}", version="{version}" + ) + + def create_actions(self): + pass + """ + ) + (root / "adapters" / "desktop.py").write_text(desktop, encoding="utf-8") + return package + + +@pytest.fixture(name="plugin_folders") +def fixture_plugin_folders( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> Iterator[list[str]]: + """Return the configured plugin directories, initially empty. + + Discovery is isolated from the user's plugins, from installed packages and + from the rest of the environment: only the plugin directories are scanned. + """ + configured: list[str] = [] + default_path = tmp_path / "default_plugins" + default_path.mkdir() + store = InstalledPluginStore(str(tmp_path / "installed_plugins")) + iter_modules = pkgutil.iter_modules + monkeypatch.setattr(sys, "path", list(sys.path)) + # Edited sources must not be shadowed by bytecode of the same size and time + monkeypatch.setattr(sys, "dont_write_bytecode", True) + monkeypatch.setattr(plugin_base, "get_user_plugin_paths", lambda: list(configured)) + monkeypatch.setattr(plugin_base, "PLUGINS_DEFAULT_PATH", str(default_path)) + monkeypatch.setattr(plugin_base, "OTHER_PLUGINS_PATHLIST", []) + monkeypatch.setattr(plugin_base, "get_installed_plugin_store", lambda: store) + monkeypatch.setattr(plugin_base, "_get_plugin_entry_points", lambda: []) + monkeypatch.setattr( + plugin_base.pkgutil, + "iter_modules", + # pylint: disable-next=protected-access + lambda: iter_modules(plugin_base._get_plugin_search_paths()), + ) + monkeypatch.setattr(Conf.plugins_enabled, "get", lambda: True) + PluginRegistry.clear_plugin_classes() + try: + yield configured + finally: + for name in [ + name for name in sys.modules if name.startswith(FOLDER_PACKAGE_PREFIX) + ]: + del sys.modules[name] + importlib.invalidate_caches() + + +def count_reloads(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Record the names of the modules reloaded by plugin discovery.""" + reloaded: list[str] = [] + reload = importlib.reload + + def reload_module(module: ModuleType) -> ModuleType: + reloaded.append(module.__name__) + return reload(module) + + monkeypatch.setattr(plugin_base.importlib, "reload", reload_module) + return reloaded + + +def get_single_plugin_class() -> type[PluginBase]: + """Return the only discovered plugin class.""" + plugin_classes = PluginRegistry.get_plugin_classes() + assert len(plugin_classes) == 1, plugin_classes + return plugin_classes[0] + + +@pytest.mark.parametrize("configured_folder", ["project", "src"]) +def test_plugin_project_folder_is_discovered_without_installation( + plugin_folders: list[str], tmp_path: Path, configured_folder: str +) -> None: + """A fresh plugin project is loaded from its folder or from its ``src``.""" + project = tmp_path / "project" + package = write_plugin_project(project, "fresh") + source_folder = project / "src" + desktop_name = f"{package}.adapters.desktop" + folder = project if configured_folder == "project" else source_folder + plugin_folders.append(str(folder)) + + discovered = discover_plugins() + + assert osp.realpath(source_folder) in sys.path + assert not [*project.rglob("*.egg-info"), *project.rglob("*.dist-info")] + plugin_class = get_single_plugin_class() + assert plugin_class.get_plugin_id() == "org.example.fresh" + assert plugin_class.__module__ == desktop_name + assert plugin_class.__plugin_discovery_sources__ == ( + f"module convention {package!r}", + ) + assert osp.samefile( + plugin_class.__plugin_filepath__, + source_folder / package / "adapters" / "desktop.py", + ) + assert [module.__name__ for module in discovered] == [package, desktop_name] + assert not PluginRegistry.get_failed_plugins() + + +def test_plugins_without_desktop_module_are_discovered_as_before( + plugin_folders: list[str], tmp_path: Path +) -> None: + """Single files and packages without ``adapters/desktop.py`` are unchanged.""" + folder = tmp_path / "plugins" + plugin_source = textwrap.dedent( + """ + from datalab.plugins import PluginBase, PluginInfo + + + class LocalPlugin(PluginBase): + PLUGIN_INFO = PluginInfo(id=PLUGIN_ID, name=PLUGIN_ID) + + def create_actions(self): + pass + """ + ) + files = { + f"{FOLDER_PACKAGE_PREFIX}_single.py": ( + 'PLUGIN_ID = "org.example.single"\n' + plugin_source + ), + f"{FOLDER_PACKAGE_PREFIX}_root/__init__.py": ( + 'PLUGIN_ID = "org.example.root"\n' + plugin_source + ), + f"{FOLDER_PACKAGE_PREFIX}_library/__init__.py": '"""Not a plugin."""\n', + f"{FOLDER_PACKAGE_PREFIX}_flat/__init__.py": '"""Not a plugin."""\n', + f"{FOLDER_PACKAGE_PREFIX}_flat/adapters.py": '"""Not a package."""\n', + } + for relative_path, source in files.items(): + path = folder / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + plugin_folders.append(str(folder)) + + discover_plugins() + + assert { + (plugin_class.get_plugin_id(), plugin_class.__module__) + for plugin_class in PluginRegistry.get_plugin_classes() + } == { + ("org.example.single", f"{FOLDER_PACKAGE_PREFIX}_single"), + ("org.example.root", f"{FOLDER_PACKAGE_PREFIX}_root"), + } + assert not PluginRegistry.get_failed_plugins() + + +def test_desktop_module_error_is_reported_without_blocking_other_plugins( + plugin_folders: list[str], tmp_path: Path +) -> None: + """A failing Desktop module is reported; other plugins are still loaded.""" + broken = write_plugin_project( + tmp_path / "broken", + "broken", + desktop='raise ImportError("missing plugin dependency")\n', + ) + write_plugin_project(tmp_path / "working", "working") + plugin_folders.extend([str(tmp_path / "broken"), str(tmp_path / "working")]) + + discover_plugins() + + plugin_class = get_single_plugin_class() + assert plugin_class.get_plugin_id() == "org.example.working" + failed_plugins = PluginRegistry.get_failed_plugins() + assert len(failed_plugins) == 1 + failed_plugin = failed_plugins[0] + assert failed_plugin.name == f"{broken}.adapters.desktop" + assert failed_plugin.source == f"module convention {broken!r}" + assert osp.samefile( + failed_plugin.filepath, + tmp_path / "broken" / "src" / broken / "adapters" / "desktop.py", + ) + assert "missing plugin dependency" in failed_plugin.traceback + + +def test_plugin_package_reload_takes_desktop_changes_into_account( + plugin_folders: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Reloading plugins executes the edited Desktop module once.""" + project = tmp_path / "project" + package = write_plugin_project(project, "reloaded", version="1.0.0") + plugin_folders.append(str(project)) + discover_plugins() + desktop = project / "src" / package / "adapters" / "desktop.py" + desktop.write_text( + desktop.read_text(encoding="utf-8").replace('"1.0.0"', '"2.0.0"'), + encoding="utf-8", + ) + reloaded = count_reloads(monkeypatch) + + # The main window clears plugin classes before discovering them again + PluginRegistry.clear_plugin_classes() + discover_plugins() + + plugin_class = get_single_plugin_class() + assert plugin_class.PLUGIN_INFO.version == "2.0.0" + assert reloaded == [package, f"{package}.adapters.desktop"] + + +def test_plugin_package_also_installed_is_loaded_once( + plugin_folders: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A package found by its entry point and by convention is merged.""" + project = tmp_path / "project" + package = write_plugin_project(project, "installed") + desktop_name = f"{package}.adapters.desktop" + plugin_folders.append(str(project)) + + class EntryPoint: + """Installed entry point targeting the Desktop adapter of the package.""" + + name = "installed-folder-plugin" + module = desktop_name + value = f"{desktop_name}:FolderPlugin" + + @staticmethod + def load() -> type[PluginBase]: + """Return the plugin class of the Desktop adapter.""" + return importlib.import_module(desktop_name).FolderPlugin + + monkeypatch.setattr(plugin_base, "_get_plugin_entry_points", lambda: [EntryPoint()]) + discover_plugins() + reloaded = count_reloads(monkeypatch) + PluginRegistry.clear_plugin_classes() + + discover_plugins() + + plugin_class = get_single_plugin_class() + assert plugin_class is sys.modules[desktop_name].FolderPlugin + assert plugin_class.__plugin_discovery_sources__ == ( + f"entry point 'installed-folder-plugin' ({desktop_name}:FolderPlugin)", + f"module convention {package!r}", + ) + assert reloaded == [desktop_name, package] + assert not PluginRegistry.get_failed_plugins() + + +def test_plugin_search_paths_include_project_source_folders( + plugin_folders: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Only ``src`` folders holding ``datalab_*`` modules are searched, once.""" + project = tmp_path / "project" + write_plugin_project(project, "paths") + environment_project = tmp_path / "environment_project" + write_plugin_project(environment_project, "environment") + other = tmp_path / "other" + (other / "src" / "datalab_other.egg-info").mkdir(parents=True) + (other / "src" / "other_package").mkdir() + (other / "src" / "other_package" / "__init__.py").write_text("", encoding="utf-8") + missing = tmp_path / "missing" + plugin_folders.extend( + [str(project), str(project / "src"), str(other), str(missing)] + ) + # Directories of the DATALAB_PLUGINS environment variable + monkeypatch.setattr( + plugin_base, "OTHER_PLUGINS_PATHLIST", [str(environment_project)] + ) + + # pylint: disable-next=protected-access + paths = plugin_base._get_plugin_search_paths() + discover_plugins() + + assert paths == [ + osp.realpath(path) + for path in ( + project, + project / "src", + other, + missing, + tmp_path / "default_plugins", + environment_project, + environment_project / "src", + ) + ] + assert { + plugin_class.get_plugin_id() + for plugin_class in PluginRegistry.get_plugin_classes() + } == {"org.example.paths", "org.example.environment"} From 2398f9bf28c95ee4e438f2a3c04d0d91680dc26b Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:52:29 +0200 Subject: [PATCH 3/3] docs(plugins): document plugins organized as a folder (#411) The plugin documentation now presents the two forms DataLab loads from a plugin directory, a single `datalab_*.py` file or a `datalab_*` folder, and explains that a plugin project folder may be added directly. Release notes 1.4 (Bug Fixes, "Plugins") with their French translation. Assisted-by: Claude Opus 5.5 --- doc/features/advanced/plugins.rst | 28 +++++++----- .../LC_MESSAGES/features/advanced/plugins.po | 45 ++++++++++--------- .../LC_MESSAGES/release_notes/release_1.04.po | 6 +++ doc/release_notes/release_1.04.md | 4 ++ 4 files changed, 51 insertions(+), 32 deletions(-) diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst index cb02fdc0c..89b6eb4df 100644 --- a/doc/features/advanced/plugins.rst +++ b/doc/features/advanced/plugins.rst @@ -12,18 +12,16 @@ DataLab supports a robust plugin architecture, allowing users to extend the appl What is a plugin? ----------------- -A plugin is a Python module that is automatically loaded by DataLab at startup. It can define new features or modify existing ones. +A plugin is Python code that is automatically loaded by DataLab at startup. It can define new features or modify existing ones. -To be recognized by the historical module scan, a local plugin file must: +A plugin defines a class that **inherits from** :class:`datalab.plugins.PluginBase`, includes a class attribute named ``PLUGIN_INFO`` (an instance of :class:`datalab.plugins.PluginInfo`) and implements the ``create_actions`` method. DataLab finds this class in a plugin directory (see :ref:`plugin_locations`), in one of two forms: -- Be a Python module whose name **starts with** ``datalab_`` (e.g. ``datalab_myplugin.py``), -- Contain a class that **inherits from** :class:`datalab.plugins.PluginBase`, -- Include a class attribute named ``PLUGIN_INFO``, which must be an instance of :class:`datalab.plugins.PluginInfo`, -- Implement the ``create_actions`` method. +- **A single file** whose name **starts with** ``datalab_`` (e.g. ``datalab_myplugin.py``) and which contains the class. This is the simplest form, recommended to get started. +- **A folder** whose name starts with ``datalab_`` and which holds an ``__init__.py`` file (a Python package, e.g. ``datalab_myplugin/``). DataLab loads the folder, then its ``adapters/desktop.py`` file when it exists: this is where projects created by ``datalab-plugin create`` define the class (see :ref:`plugin_project`). Otherwise, the class is defined in ``__init__.py``. -Plugins distributed as installed Python packages may instead expose their -``PluginBase`` subclass through the ``datalab.plugins`` entry-point group, -without relying on the module-name prefix. +Nothing needs to be installed: the file or folder is loaded as it is. + +Plugins distributed as installed Python packages, such as the wheels of the plugin catalog, declare their ``PluginBase`` subclass through the ``datalab.plugins`` entry-point group, without relying on the ``datalab_`` prefix. A plugin found both ways, for example a project that is installed and whose folder is also a plugin directory, is loaded only once. The ``PLUGIN_INFO`` object must define a unique, namespaced ``id`` that remains stable when the display name or implementation class changes. DataLab uses this @@ -54,7 +52,7 @@ field remain valid and are shown without capability labels. .. note:: - Only Python files whose names start with ``datalab_`` will be scanned for plugins. + Only files and folders whose names start with ``datalab_`` are scanned for plugins. DataLab supports three categories of plugins, each with its own purpose and registration mechanism: @@ -88,6 +86,8 @@ Live preview compatibility module state may persist until a preview is cancelled while running, plugins are reloaded, or DataLab exits. +.. _plugin_locations: + Where to put a plugin? ---------------------- @@ -142,6 +142,8 @@ Plugins are automatically discovered at startup from multiple locations: Changes to this variable are only taken into account at DataLab startup. +A plugin directory may also be the folder of a plugin project, such as one created by ``datalab-plugin create``: DataLab then also searches its ``src`` subfolder, which holds the ``datalab_*`` folder of the plugin. The Python packages the plugin depends on must be available in DataLab's environment. + Managing plugins in DataLab --------------------------- @@ -626,6 +628,8 @@ Declared tiles replace the default tile. The first one is the main tile of the p The user decides which applications are shown and in which order: the welcome page shows a limited number of tile rows, pinned applications first, then recently used ones, then the others by name (see :ref:`welcome_page`). A plugin therefore cannot rely on its tile being visible: its features must remain available from the **Applications** catalog. +.. _plugin_project: + Creating a layered plugin project ---------------------------------- @@ -677,7 +681,9 @@ destination. The template remains deliberately small. Add domain recipes, DataSet parameters, packaged examples, and specialized CI only when the plugin needs -them. From the generated directory, install and validate the project with: +them. + +To try the plugin, add the generated directory as a plugin directory (see :ref:`plugin_search_paths`) and reload the plugins: DataLab loads the plugin class from ``adapters/desktop.py`` without installing the project. To validate the project, install it with its test dependencies and run its checks from the generated directory: .. code-block:: bash diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po index d0a80f388..e1125c98d 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po @@ -25,26 +25,23 @@ msgstr "DataLab prend en charge une architecture de plugin robuste, permettant a msgid "What is a plugin?" msgstr "Qu'est-ce qu'un plugin ?" -msgid "A plugin is a Python module that is automatically loaded by DataLab at startup. It can define new features or modify existing ones." -msgstr "Un plugin est un module Python qui est automatiquement chargé par DataLab au démarrage. Il peut définir de nouvelles fonctionnalités ou modifier des fonctionnalités existantes." +msgid "A plugin is Python code that is automatically loaded by DataLab at startup. It can define new features or modify existing ones." +msgstr "Un plugin est du code Python qui est automatiquement chargé par DataLab au démarrage. Il peut définir de nouvelles fonctionnalités ou modifier des fonctionnalités existantes." -msgid "To be recognized by the historical module scan, a local plugin file must:" -msgstr "Pour être reconnu par l'analyse historique des modules, un fichier de plugin local doit :" +msgid "A plugin defines a class that **inherits from** :class:`datalab.plugins.PluginBase`, includes a class attribute named ``PLUGIN_INFO`` (an instance of :class:`datalab.plugins.PluginInfo`) and implements the ``create_actions`` method. DataLab finds this class in a plugin directory (see :ref:`plugin_locations`), in one of two forms:" +msgstr "Un plugin définit une classe qui **hérite de** :class:`datalab.plugins.PluginBase`, comporte un attribut de classe nommé ``PLUGIN_INFO`` (une instance de :class:`datalab.plugins.PluginInfo`) et implémente la méthode ``create_actions``. DataLab trouve cette classe dans un répertoire de plugins (voir :ref:`plugin_locations`), sous l'une de ces deux formes :" -msgid "Be a Python module whose name **starts with** ``datalab_`` (e.g. ``datalab_myplugin.py``)," -msgstr "Être un module Python dont le nom **commence par** ``datalab_`` (par exemple ``datalab_myplugin.py``)," +msgid "**A single file** whose name **starts with** ``datalab_`` (e.g. ``datalab_myplugin.py``) and which contains the class. This is the simplest form, recommended to get started." +msgstr "**Un fichier unique** dont le nom **commence par** ``datalab_`` (par exemple ``datalab_myplugin.py``) et qui contient la classe. C'est la forme la plus simple, recommandée pour débuter." -msgid "Contain a class that **inherits from** :class:`datalab.plugins.PluginBase`," -msgstr "Contenir une classe qui **hérite de** :class:`datalab.plugins.PluginBase`," +msgid "**A folder** whose name starts with ``datalab_`` and which holds an ``__init__.py`` file (a Python package, e.g. ``datalab_myplugin/``). DataLab loads the folder, then its ``adapters/desktop.py`` file when it exists: this is where projects created by ``datalab-plugin create`` define the class (see :ref:`plugin_project`). Otherwise, the class is defined in ``__init__.py``." +msgstr "**Un dossier** dont le nom commence par ``datalab_`` et qui contient un fichier ``__init__.py`` (un paquet Python, par exemple ``datalab_myplugin/``). DataLab charge le dossier, puis son fichier ``adapters/desktop.py`` s'il existe : c'est là que les projets créés par ``datalab-plugin create`` définissent la classe (voir :ref:`plugin_project`). Sinon, la classe est définie dans ``__init__.py``." -msgid "Include a class attribute named ``PLUGIN_INFO``, which must be an instance of :class:`datalab.plugins.PluginInfo`," -msgstr "Inclure un attribut de classe nommé ``PLUGIN_INFO``, qui doit être une instance de :class:`datalab.plugins.PluginInfo`," +msgid "Nothing needs to be installed: the file or folder is loaded as it is." +msgstr "Rien n'a besoin d'être installé : le fichier ou le dossier est chargé tel quel." -msgid "Implement the ``create_actions`` method." -msgstr "Implémenter la méthode ``create_actions``." - -msgid "Plugins distributed as installed Python packages may instead expose their ``PluginBase`` subclass through the ``datalab.plugins`` entry-point group, without relying on the module-name prefix." -msgstr "Les plugins distribués sous forme de paquets Python installés peuvent à la place exposer leur sous-classe ``PluginBase`` via le groupe de points d'entrée ``datalab.plugins``, sans dépendre du préfixe du nom de module." +msgid "Plugins distributed as installed Python packages, such as the wheels of the plugin catalog, declare their ``PluginBase`` subclass through the ``datalab.plugins`` entry-point group, without relying on the ``datalab_`` prefix. A plugin found both ways, for example a project that is installed and whose folder is also a plugin directory, is loaded only once." +msgstr "Les plugins distribués sous forme de paquets Python installés, comme les wheels du catalogue de plugins, déclarent leur sous-classe de ``PluginBase`` via le groupe de points d'entrée ``datalab.plugins``, sans dépendre du préfixe ``datalab_``. Un plugin trouvé des deux façons, par exemple un projet installé dont le dossier est aussi un répertoire de plugins, n'est chargé qu'une seule fois." msgid "The ``PLUGIN_INFO`` object must define a unique, namespaced ``id`` that remains stable when the display name or implementation class changes. DataLab uses this ID for registration and persisted enablement settings. Plugins without an ID remain supported through a module-and-class fallback for backward compatibility, but new plugins should not rely on that fallback." msgstr "L'objet ``PLUGIN_INFO`` doit définir un ``id`` unique, préfixé par un espace de noms, qui reste stable lorsque le nom affiché ou la classe d'implémentation changent. DataLab utilise cet identifiant pour l'enregistrement et pour les paramètres d'activation persistants. Les plugins sans identifiant restent pris en charge grâce à un repli fondé sur le module et la classe, pour assurer la rétrocompatibilité, mais les nouveaux plugins ne doivent pas s'appuyer sur ce repli." @@ -52,8 +49,8 @@ msgstr "L'objet ``PLUGIN_INFO`` doit définir un ``id`` unique, préfixé par un msgid "``capabilities`` declares how DataLab may present and consume a plugin. Supported values are ``PROCESSING``, ``IO``, ``VISUALIZATION`` and ``APPLICATION``. A plugin may combine several values; domain applications typically declare ``APPLICATION`` together with ``PROCESSING``. The plugin configuration dialog shows declared capabilities in a stable order. Existing plugins that omit the field remain valid and are shown without capability labels." msgstr "``capabilities`` déclare comment DataLab peut présenter et utiliser un plugin. Les valeurs prises en charge sont ``PROCESSING``, ``IO``, ``VISUALIZATION`` et ``APPLICATION``. Un plugin peut combiner plusieurs valeurs ; les applications métier déclarent généralement ``APPLICATION`` avec ``PROCESSING``. La boîte de dialogue de configuration des plugins affiche les capacités déclarées dans un ordre stable. Les plugins existants qui omettent ce champ restent valides et sont affichés sans libellé de capacité." -msgid "Only Python files whose names start with ``datalab_`` will be scanned for plugins." -msgstr "Seuls les fichiers Python dont les noms commencent par ``datalab_`` seront analysés pour les plugins." +msgid "Only files and folders whose names start with ``datalab_`` are scanned for plugins." +msgstr "Seuls les fichiers et dossiers dont les noms commencent par ``datalab_`` sont analysés pour les plugins." msgid "DataLab supports three categories of plugins, each with its own purpose and registration mechanism:" msgstr "DataLab prend en charge trois catégories de plugins, chacune ayant son propre objectif et mécanisme d'enregistrement :" @@ -109,6 +106,9 @@ msgstr "Répertoires supplémentaires listés dans la variable d'environnement ` msgid "Changes to this variable are only taken into account at DataLab startup." msgstr "Les modifications apportées à cette variable ne sont prises en compte qu'au démarrage de DataLab." +msgid "A plugin directory may also be the folder of a plugin project, such as one created by ``datalab-plugin create``: DataLab then also searches its ``src`` subfolder, which holds the ``datalab_*`` folder of the plugin. The Python packages the plugin depends on must be available in DataLab's environment." +msgstr "Un répertoire de plugins peut aussi être le dossier d'un projet de plugin, comme ceux créés par ``datalab-plugin create`` : DataLab recherche alors aussi dans son sous-dossier ``src``, qui contient le dossier ``datalab_*`` du plugin. Les paquets Python dont dépend le plugin doivent être disponibles dans l'environnement de DataLab." + msgid "Managing plugins in DataLab" msgstr "Gestion des plugins dans DataLab" @@ -442,8 +442,11 @@ msgstr "``core`` contient le comportement métier indépendant de l'hôte, ``wor msgid "The project also includes a stable :class:`datalab.plugins.PluginInfo`, an owned sample processing when the ``processing`` capability is selected, Ruff settings, architecture and contribution documentation, a changelog, a README, and a BSD-3-Clause license. The command refuses to overwrite an existing destination." msgstr "Le projet comprend également un :class:`datalab.plugins.PluginInfo` stable, un traitement d'exemple possédé lorsque la capacité ``processing`` est sélectionnée, une configuration Ruff, une documentation de l'architecture et des contributions, un changelog, un README et une licence BSD-3-Clause. La commande refuse d'écraser une destination existante." -msgid "The template remains deliberately small. Add domain recipes, DataSet parameters, packaged examples, and specialized CI only when the plugin needs them. From the generated directory, install and validate the project with:" -msgstr "Le modèle reste volontairement réduit. Ajoutez des recettes métier, des paramètres DataSet, des exemples empaquetés et une intégration continue spécialisée uniquement lorsque le plugin en a besoin. Depuis le répertoire généré, installez et validez le projet avec :" +msgid "The template remains deliberately small. Add domain recipes, DataSet parameters, packaged examples, and specialized CI only when the plugin needs them." +msgstr "Le modèle reste volontairement réduit. Ajoutez des recettes métier, des paramètres DataSet, des exemples empaquetés et une intégration continue spécialisée uniquement lorsque le plugin en a besoin." + +msgid "To try the plugin, add the generated directory as a plugin directory (see :ref:`plugin_search_paths`) and reload the plugins: DataLab loads the plugin class from ``adapters/desktop.py`` without installing the project. To validate the project, install it with its test dependencies and run its checks from the generated directory:" +msgstr "Pour essayer le plugin, ajoutez le répertoire généré comme répertoire de plugins (voir :ref:`plugin_search_paths`) et rechargez les plugins : DataLab charge la classe du plugin depuis ``adapters/desktop.py`` sans installer le projet. Pour valider le projet, installez-le avec ses dépendances de test et lancez ses vérifications depuis le répertoire généré :" msgid "The generated ``pyproject.toml`` requires the current DataLab major version (for example ``datalab-platform >= 1.4, < 2``) and declares the project URLs when the GitHub account is known. The generated ``.github/workflows/release.yml`` publishes the plugin without any PyPI account: pushing a tag such as ``v0.1.0`` builds the wheel, checks that the tag matches the package version, attaches the wheel to a GitHub release, and prints the entry to submit to the `DataLab plugin catalog `_, including the SHA-256 digest of the wheel. Users then install the wheel with **Plugins > Configure plugins... > Install plugins** (see :ref:`plugin_install_from_file`). Listing the plugin in the catalog is optional; once listed, it can also be installed from the **Available plugins** tab (see :ref:`plugin_catalog`)." msgstr "Le ``pyproject.toml`` généré exige la version majeure courante de DataLab (par exemple ``datalab-platform >= 1.4, < 2``) et déclare les URL du projet lorsque le compte GitHub est connu. Le fichier ``.github/workflows/release.yml`` généré publie le plugin sans compte PyPI : pousser une étiquette comme ``v0.1.0`` construit le wheel, vérifie que l'étiquette correspond à la version du package, joint le wheel à une release GitHub et affiche la fiche à soumettre au `catalogue de plugins DataLab `_, avec l'empreinte SHA-256 du wheel. Les utilisateurs installent ensuite le wheel avec **Plugins > Configurer les plugins... > Installer des plugins** (voir :ref:`plugin_install_from_file`). L'inscription du plugin au catalogue est facultative ; une fois inscrit, il peut aussi être installé depuis l'onglet **Plugins disponibles** (voir :ref:`plugin_catalog`)." @@ -946,8 +949,8 @@ msgid "Discover plugins through package entry points and naming convention." msgstr "Découvrir les plugins via les points d'entrée de packages et la convention de nommage." #, python-brace-format -msgid "Installed packages may expose a :class:`PluginBase` subclass through the ``datalab.plugins`` entry-point group. This function also reloads or imports modules matching the historical ``\"{MOD_NAME}_*\"`` naming scheme." -msgstr "Les packages installés peuvent exposer une sous-classe de :class:`PluginBase` via le groupe de points d'entrée ``datalab.plugins``. Cette fonction recharge ou importe également les modules qui respectent la convention de nommage historique ``\"{MOD_NAME}_*\"``." +msgid "Installed packages may expose a :class:`PluginBase` subclass through the ``datalab.plugins`` entry-point group. This function also reloads or imports modules and packages matching the historical ``\"{MOD_NAME}_*\"`` naming scheme, as well as the ``adapters.desktop`` module of such packages, which defines the plugin class of packages laid out by ``datalab-plugin create``." +msgstr "Les packages installés peuvent exposer une sous-classe de :class:`PluginBase` via le groupe de points d'entrée ``datalab.plugins``. Cette fonction recharge ou importe également les modules et packages qui respectent la convention de nommage historique ``\"{MOD_NAME}_*\"``, ainsi que le module ``adapters.desktop`` de ces packages, qui définit la classe du plugin des packages organisés comme ceux créés par ``datalab-plugin create``." msgid "Import errors for individual plugins are captured and logged so that one broken plugin does not prevent the others from loading. Error tracebacks are accumulated in :class:`PluginRegistry` class attributes so that callers (e.g. the main window) can replay them into the internal console once it is ready." msgstr "Les erreurs d'importation pour les plugins individuels sont capturées et enregistrées afin qu'un plugin défectueux n'empêche pas les autres de se charger. Les traces d'erreurs sont accumulées dans les attributs de classe :class:`PluginRegistry` afin que les appelants (par exemple, la fenêtre principale) puissent les rejouer dans la console interne une fois qu'elle est prête." diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po index e784a9f87..9088fc241 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -339,6 +339,12 @@ msgstr "Les résultats de l'extraction de plusieurs régions d'intérêt dans un msgid "**Edit > Recompute** now also recomputes results of operations combining several objects (e.g., difference of two signals, average of several images), which were skipped" msgstr "**Édition > Retraiter** recalcule désormais aussi les résultats des opérations combinant plusieurs objets (par exemple la différence de deux signaux ou la moyenne de plusieurs images), qui étaient ignorés" +msgid "**Plugins:**" +msgstr "**Plugins :**" + +msgid "Plugins organized as a folder, such as those created by `datalab-plugin create`, are now loaded when their folder is added to the plugin directories, without being installed first: they were silently ignored, unless an earlier installation had left package information in the project. The project folder itself can now be added, instead of its `src` subfolder (fixes [Issue #411](https://github.com/DataLab-Platform/DataLab/issues/411))" +msgstr "Les plugins organisés en dossier, comme ceux créés par `datalab-plugin create`, sont désormais chargés lorsque leur dossier est ajouté aux répertoires de plugins, sans devoir être installés au préalable : ils étaient ignorés sans message, sauf si une installation antérieure avait laissé des informations de paquet dans le projet. Le dossier du projet lui-même peut désormais être ajouté, au lieu de son sous-dossier `src` (corrige [l'issue #411](https://github.com/DataLab-Platform/DataLab/issues/411))" + msgid "**Plugins and History:**" msgstr "**Plugins et historique :**" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index 9b2f538ca..426e40662 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -149,6 +149,10 @@ DataLab now uses stable UUID-based object references across object trees, result * Results of the extraction of several regions of interest into a single object can be processed again: the **Processing** tab showed "Unknown computing feature: extract_rois" with **Reapply** disabled, and replaying the History skipped the action * **Edit > Recompute** now also recomputes results of operations combining several objects (e.g., difference of two signals, average of several images), which were skipped +**Plugins:** + +* Plugins organized as a folder, such as those created by `datalab-plugin create`, are now loaded when their folder is added to the plugin directories, without being installed first: they were silently ignored, unless an earlier installation had left package information in the project. The project folder itself can now be added, instead of its `src` subfolder (fixes [Issue #411](https://github.com/DataLab-Platform/DataLab/issues/411)) + **Plugins and History:** * Replaying a History action or re-processing a result created by a plugin feature that shares its name with a built-in feature now runs the plugin feature instead of the built-in one