From 2ce384fe7bba1b29ea298ab2206bd1567017a032 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:33:33 +0200 Subject: [PATCH 1/4] feat(plugins): create single-file plugins with datalab-plugin create (#413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `datalab-plugin create` now creates a single-file plugin by default: it only asks for the plugin name and description, then writes a `datalab_.py` file to the user plugin directory, which DataLab loads once the plugins are reloaded. The file registers a sample processing, listed in the plugin submenu of the Plugins menu, whose short NumPy function is the only part to edit (signals, or images with `--object-kind image`); without the processing capability, the submenu holds an action showing a message. The layered installable project is unchanged and created with `--project`. - Python API, usable without the command line (#377): `render_single_file_plugin`, `create_single_file_plugin` and `get_default_plugin_folder`. The `datalab_` prefix is enforced and an existing file is never overwritten. - User values are written as readable Python strings, accents included, quoted as Ruff formats them. - Accents are removed from the names derived from the plugin name, e.g. `datalab_filtres_ameliores` and `FiltresAmelioresPlugin` for "Filtres améliorés", for projects too. - French translations of the new messages. Assisted-by: Claude Opus 5.5 --- datalab/locale/fr/LC_MESSAGES/datalab.po | 37 ++- datalab/plugins/generator.py | 368 ++++++++++++++++++++--- 2 files changed, 357 insertions(+), 48 deletions(-) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 84c572f49..90c4230fb 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -4363,6 +4363,14 @@ msgstr "Le compte GitHub doit être un utilisateur ou une organisation GitHub" msgid "Plugin name must contain letters or digits" msgstr "Le nom du plugin doit contenir des lettres ou des chiffres" +#, python-format +msgid "The module name of a single-file plugin must start with '%s'" +msgstr "Le nom du module d'un plugin en fichier unique doit commencer par '%s'" + +#, python-format +msgid "File already exists: %s" +msgstr "Le fichier existe déjà : %s" + #, python-format msgid "Destination already exists: %s" msgstr "La destination existe déjà : %s" @@ -4393,11 +4401,17 @@ msgstr "Description" msgid "Destination" msgstr "Destination" -msgid "Create and maintain DataLab plugin projects." -msgstr "Créer et maintenir des projets de plugins DataLab." +msgid "Create and maintain DataLab plugins." +msgstr "Créer et maintenir des plugins DataLab." + +msgid "Create a single-file plugin, or a layered installable project with --project." +msgstr "Créer un plugin en fichier unique, ou un projet installable structuré en couches avec --project." + +msgid "Folder receiving the plugin file (default: the user plugin directory), or project folder with --project" +msgstr "Dossier recevant le fichier du plugin (par défaut : le répertoire de plugins de l'utilisateur), ou dossier du projet avec --project" -msgid "Create a layered installable plugin project." -msgstr "Créer un projet de plugin installable structuré en couches." +msgid "Create a layered installable project, to publish the plugin" +msgstr "Créer un projet installable structuré en couches, pour publier le plugin" msgid "Plugin display name" msgstr "Nom d'affichage du plugin" @@ -4420,8 +4434,8 @@ msgstr "Capacité du plugin ; l'option peut être répétée" msgid "Object type used by the sample processing" msgstr "Type d'objet utilisé par le traitement d'exemple" -msgid "Project license" -msgstr "Licence du projet" +msgid "Project license (with --project)" +msgstr "Licence du projet (avec --project)" msgid "Build or check a local plugin catalog folder." msgstr "Construire ou vérifier un dossier de catalogue local de plugins." @@ -4455,6 +4469,17 @@ msgstr "Le catalogue local de plugins répertorie %d plugins." msgid "Created DataLab plugin project at %s" msgstr "Projet de plugin DataLab créé dans %s" +#, python-format +msgid "Created DataLab plugin %s at %s" +msgstr "Plugin DataLab %s créé : %s" + +msgid "Select Plugins > Reload plugins in DataLab, or restart it." +msgstr "Sélectionnez Plugins > Recharger les plugins dans DataLab, ou redémarrez-le." + +#, python-format +msgid "Add %s to the plugin directories (Plugins > Configure plugins... > Plugin settings), then reload the plugins." +msgstr "Ajoutez %s aux répertoires de plugins (Plugins > Configurer les plugins... > Paramètres des plugins), puis rechargez les plugins." + msgid "Source object no longer exists." msgstr "L'objet source n'existe plus." diff --git a/datalab/plugins/generator.py b/datalab/plugins/generator.py index 6e3dbb9d1..a54c39919 100644 --- a/datalab/plugins/generator.py +++ b/datalab/plugins/generator.py @@ -1,6 +1,9 @@ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. -"""Generate a layered installable DataLab plugin project. +"""Generate DataLab plugins: a single file, or a layered installable project. + +A single-file plugin is a ``datalab_.py`` module that DataLab loads from +a plugin directory. A layered project is installable and publishable as a wheel. The ``datalab-plugin`` command also builds and checks local plugin catalogs. """ @@ -11,13 +14,15 @@ import dataclasses import json import keyword +import os.path as osp import re import sys -from collections.abc import Sequence +import unicodedata +from collections.abc import Callable, Sequence from pathlib import Path from datalab import __version__ -from datalab.config import _ +from datalab.config import _, get_config_path from datalab.plugins.catalog import CatalogError from datalab.plugins.catalogtool import build_local_catalog, check_local_catalog @@ -27,11 +32,40 @@ _CAPABILITIES = ("application", "processing", "io", "visualization") #: GitHub organization whose plugins use the reserved ``org.datalab.`` IDs OFFICIAL_GITHUB_ACCOUNT = "DataLab-Platform" +#: Prefix of the modules that DataLab loads from its plugin directories +PLUGIN_MODULE_PREFIX = "datalab_" +#: Import of the plugin contracts by single-file plugins +_PLUGIN_IMPORT = ( + "from datalab.plugins import PluginBase, PluginCapability, PluginInfo\n" +) +#: Sample processing of single-file plugins, by object kind: argument, summary and +#: returned expression of the NumPy function that users edit, then object class +#: and summary of the computation function registered in DataLab +_SAMPLE_FUNCTIONS = { + "signal": ( + "y", + "Return new Y values computed from those of the selected signal.", + "np.maximum(y, 0.0)", + "SignalObj", + "Return a new signal whose Y values are computed by ``sample_processing``.", + ), + "image": ( + "data", + "Return new image data computed from that of the selected image.", + "data.max() - data", + "ImageObj", + "Return a new image whose data are computed by ``sample_processing``.", + ), +} @dataclasses.dataclass(frozen=True) class PluginProject: - """Configuration for a generated plugin project.""" + """Configuration of a generated plugin: single file or layered project. + + ``destination`` is the folder receiving the ``.py`` file of a + single-file plugin, or the folder of a layered project. + """ destination: Path name: str @@ -95,7 +129,7 @@ def distribution(self) -> str: @property def class_name(self) -> str: """Return the generated plugin class name.""" - words = re.findall(r"[A-Za-z0-9]+", self.name) + words = re.findall(r"[A-Za-z0-9]+", _strip_accents(self.name)) class_name = "".join(word[0].upper() + word[1:] for word in words) if not class_name: raise ValueError(_("Plugin name must contain letters or digits")) @@ -106,14 +140,35 @@ def class_name(self) -> str: return class_name +def _strip_accents(text: str) -> str: + """Return *text* without accents, for identifiers derived from names.""" + decomposed = unicodedata.normalize("NFKD", text) + return "".join(char for char in decomposed if not unicodedata.combining(char)) + + def _toml_string(value: str) -> str: """Return *value* as a TOML-compatible quoted string.""" return json.dumps(value, ensure_ascii=True) -def _render_python_string_assignment(name: str, value: str) -> str: +def _python_string(value: str) -> str: + """Return *value* as a readable Python string literal, quoted as Ruff does. + + Non-ASCII characters are kept as they are. Single quotes are used when the + value holds more double quotes than single quotes. + """ + literal = json.dumps(value, ensure_ascii=False) + if value.count('"') <= value.count("'"): + return literal + body = literal[1:-1].replace('\\"', '"').replace("'", "\\'") + return f"'{body}'" + + +def _render_python_string_assignment( + name: str, value: str, literal_function: Callable[[str], str] = _toml_string +) -> str: """Render a string constant without requiring formatter changes.""" - literal = _toml_string(value) + literal = literal_function(value) assignment = f"{name} = {literal}" if len(assignment) <= 88: return assignment @@ -167,21 +222,41 @@ def _render_pyproject(project: PluginProject) -> str: ''' -def _render_plugin(project: PluginProject) -> str: - """Render the Desktop adapter and optional owned sample processing.""" +def _render_capabilities(project: PluginProject) -> str: + """Render the ``capabilities`` argument of the generated ``PluginInfo``.""" if len(project.capabilities) == 1: - capabilities = ( + return ( " capabilities=(PluginCapability." f"{project.capabilities[0].upper()},)," ) - else: - capability_items = "\n".join( - f" PluginCapability.{capability.upper()}," - for capability in project.capabilities - ) - capabilities = f""" capabilities=( + capability_items = "\n".join( + f" PluginCapability.{capability.upper()}," + for capability in project.capabilities + ) + return f""" capabilities=( {capability_items} ),""" + + +def _render_identity( + project: PluginProject, literal_function: Callable[[str], str] = _toml_string +) -> str: + """Render the plugin identity constants.""" + description = _render_python_string_assignment( + "PLUGIN_DESCRIPTION", project.description, literal_function + ) + plugin_id = _render_python_string_assignment( + "PLUGIN_ID", project.plugin_id, literal_function + ) + plugin_name = _render_python_string_assignment( + "PLUGIN_NAME", project.name, literal_function + ) + return f"{description}\n{plugin_id}\n{plugin_name}" + + +def _render_plugin(project: PluginProject) -> str: + """Render the Desktop adapter and optional owned sample processing.""" + capabilities = _render_capabilities(project) processing_import = "" processing_method = "" if "processing" in project.capabilities: @@ -234,16 +309,9 @@ def create_actions(self) -> None: def _render_init(project: PluginProject) -> str: """Render host-independent package identity.""" - description = _render_python_string_assignment( - "PLUGIN_DESCRIPTION", project.description - ) - plugin_id = _render_python_string_assignment("PLUGIN_ID", project.plugin_id) - plugin_name = _render_python_string_assignment("PLUGIN_NAME", project.name) return f'''"""DataLab plugin package.""" -{description} -{plugin_id} -{plugin_name} +{_render_identity(project)} __version__ = "0.1.0" __all__ = [ @@ -255,6 +323,177 @@ def _render_init(project: PluginProject) -> str: ''' +def _check_single_file_module(project: PluginProject) -> None: + """Check that DataLab loads the module of a single-file plugin. + + Raises: + ValueError: The module name does not start with ``datalab_`` + """ + if not project.package.startswith(PLUGIN_MODULE_PREFIX): + raise ValueError( + _("The module name of a single-file plugin must start with '%s'") + % PLUGIN_MODULE_PREFIX + ) + + +def _render_sample_processing(project: PluginProject) -> tuple[str, str, str]: + """Render the imports, function and methods of the sample processing.""" + kind = project.object_kind + alias = "sips" if kind == "signal" else "sipi" + argument, summary, expression, object_class, computation_summary = ( + _SAMPLE_FUNCTIONS[kind] + ) + imports = ( + f"import numpy as np\nimport sigima.proc.{kind} as {alias}\n" + f"{_PLUGIN_IMPORT}" + f"from sigima.objects import {object_class}\n" + "from sigima.proc.decorator import computation_function\n" + ) + function = f'''FEATURE_ID = f"{{PLUGIN_ID}}:sample-processing" + + +def sample_processing({argument}: np.ndarray) -> np.ndarray: + """{summary} + + Edit this function to implement your own processing. + """ + return {expression} + + +@computation_function() +def compute_sample_processing(src: {object_class}) -> {object_class}: + """{computation_summary}""" + return {alias}.Wrap1to1Func(sample_processing)(src)''' + methods = f''' + def register_computations(self) -> None: + """Register the processing of this plugin.""" + self.{kind}panel.processor.register_1_to_1( + compute_sample_processing, + "Sample processing", + feature_id=FEATURE_ID, + owner_plugin_id=self.plugin_id, + ) + + def create_actions(self) -> None: + """Add the submenu of this plugin to the Plugins menu.""" + handler = self.{kind}panel.acthandler + with handler.new_menu(PLUGIN_NAME.replace("&", "&&")): + handler.action_for(FEATURE_ID) +''' + return imports, function, methods + + +def _render_sample_action(project: PluginProject) -> str: + """Render the methods of a menu action showing a message.""" + return f''' + def show_message(self) -> None: + """Show a message: replace this method with the action of the plugin.""" + self.show_info(f"{{PLUGIN_NAME}}: edit this plugin to add features.") + + def create_actions(self) -> None: + """Add the submenu of this plugin to the Plugins menu.""" + handler = self.{project.object_kind}panel.acthandler + with handler.new_menu(PLUGIN_NAME.replace("&", "&&")): + handler.new_action( + "Show message", + triggered=self.show_message, + select_condition="always", + ) +''' + + +def render_single_file_plugin(project: PluginProject) -> str: + """Return the source code of a single-file plugin. + + With the ``processing`` capability, the plugin registers a sample NumPy + function as a processing of the signal or image panel, listed in its submenu + of the *Plugins* menu. Otherwise, the submenu holds an action showing a + message. + + Args: + project: Plugin configuration + + Returns: + Source code of the ``.py`` file + + Raises: + ValueError: The module name does not start with ``datalab_`` + """ + _check_single_file_module(project) + if "processing" in project.capabilities: + imports, function, methods = _render_sample_processing(project) + else: + imports = _PLUGIN_IMPORT + function, methods = "", _render_sample_action(project) + constants = _render_identity(project, _python_string) + if function: + constants += f"\n{function}" + return f'''"""DataLab plugin created by ``datalab-plugin create``. + +DataLab loads this file from its plugin directory. After editing it, select +**Plugins > Reload plugins** in DataLab to take the changes into account. +""" + +from __future__ import annotations + +{imports} +{constants} + + +class {project.class_name}(PluginBase): + """DataLab plugin.""" + + PLUGIN_INFO = PluginInfo( + id=PLUGIN_ID, + name=PLUGIN_NAME, + version="0.1.0", + description=PLUGIN_DESCRIPTION, +{_render_capabilities(project)} + ) +{methods}''' + + +def get_default_plugin_folder() -> Path: + """Return the user plugin directory, which DataLab always scans.""" + return Path(get_config_path("plugins")) + + +def is_default_plugin_folder(folder: Path) -> bool: + """Return True if a folder is the user plugin directory.""" + + def normalized(path: Path) -> str: + return osp.normcase(osp.realpath(str(path))) + + return normalized(folder) == normalized(get_default_plugin_folder()) + + +def create_single_file_plugin(project: PluginProject) -> Path: + """Write a single-file plugin and return its path. + + The ``.py`` file is written to ``project.destination``, created if + needed. An existing file is never overwritten. + + Args: + project: Plugin configuration + + Returns: + Path of the plugin file + + Raises: + FileExistsError: The plugin file already exists + ValueError: The module name does not start with ``datalab_`` + """ + source = render_single_file_plugin(project) + folder = project.destination.resolve() + path = folder / f"{project.package}.py" + if path.exists(): + raise FileExistsError(_("File already exists: %s") % path) + folder.mkdir(parents=True, exist_ok=True) + with path.open("x", encoding="utf-8", newline="\n") as file: + file.write(source) + return path + + def _render_workflow_init() -> str: """Render the headless workflow public API.""" return '''"""Headless plugin workflows.""" @@ -639,7 +878,7 @@ def create_plugin_project(project: PluginProject) -> Path: def _default_package(name: str) -> str: """Derive a valid import package from a display name.""" - slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") + slug = re.sub(r"[^a-z0-9]+", "_", _strip_accents(name).lower()).strip("_") if not slug.startswith("datalab_"): slug = f"datalab_{slug}" return slug @@ -667,25 +906,37 @@ def _prompt(label: str, default: str | None = None) -> str: def _project_from_args(args: argparse.Namespace) -> PluginProject: - """Resolve explicit options and interactive defaults into a project.""" + """Resolve explicit options and interactive defaults into a project. + + A single-file plugin only asks for its name and description: its module + name, plugin ID and destination are derived, unless given as options. + """ name = args.name or _prompt(_("Plugin name")) github_account = args.github_account - if github_account is None: - github_account = ( - _prompt(_("GitHub account (optional)"), "") - if args.plugin_id is None - else "" + if not args.project: + github_account = github_account or "" + package = args.package or _default_package(name) + plugin_id = args.plugin_id or _default_plugin_id(package, github_account) + else: + if github_account is None: + github_account = ( + _prompt(_("GitHub account (optional)"), "") + if args.plugin_id is None + else "" + ) + package = args.package or _prompt(_("Package name"), _default_package(name)) + plugin_id = args.plugin_id or _prompt( + _("Plugin ID"), _default_plugin_id(package, github_account) ) - package = args.package or _prompt(_("Package name"), _default_package(name)) - plugin_id = args.plugin_id or _prompt( - _("Plugin ID"), _default_plugin_id(package, github_account) - ) description = args.description or _prompt( _("Description"), _("%s plugin for DataLab") % name ) destination = args.destination if destination is None: - destination = Path(_prompt(_("Destination"), package.replace("_", "-"))) + if args.project: + destination = Path(_prompt(_("Destination"), package.replace("_", "-"))) + else: + destination = get_default_plugin_folder() capabilities = tuple(dict.fromkeys(args.capabilities or ("processing",))) return PluginProject( destination=destination, @@ -704,13 +955,30 @@ def build_parser() -> argparse.ArgumentParser: """Build the ``datalab-plugin`` argument parser.""" parser = argparse.ArgumentParser( prog="datalab-plugin", - description=_("Create and maintain DataLab plugin projects."), + description=_("Create and maintain DataLab plugins."), ) subparsers = parser.add_subparsers(dest="command", required=True) create_parser = subparsers.add_parser( - "create", help=_("Create a layered installable plugin project.") + "create", + help=_( + "Create a single-file plugin, or a layered installable project " + "with --project." + ), + ) + create_parser.add_argument( + "destination", + nargs="?", + type=Path, + help=_( + "Folder receiving the plugin file (default: the user plugin " + "directory), or project folder with --project" + ), + ) + create_parser.add_argument( + "--project", + action="store_true", + help=_("Create a layered installable project, to publish the plugin"), ) - create_parser.add_argument("destination", nargs="?", type=Path) create_parser.add_argument("--name", help=_("Plugin display name")) create_parser.add_argument("--package", help=_("Python import package name")) create_parser.add_argument("--plugin-id", help=_("Stable reverse-domain ID")) @@ -737,7 +1005,7 @@ def build_parser() -> argparse.ArgumentParser: dest="license_id", choices=("BSD-3-Clause",), default="BSD-3-Clause", - help=_("Project license"), + help=_("Project license (with --project)"), ) catalog_parser = subparsers.add_parser( "catalog", help=_("Build or check a local plugin catalog folder.") @@ -794,10 +1062,26 @@ def main(argv: Sequence[str] | None = None) -> int: return run_catalog_command(args) try: project = _project_from_args(args) - destination = create_plugin_project(project) + if args.project: + destination = create_plugin_project(project) + else: + destination = create_single_file_plugin(project) except (FileExistsError, ValueError) as exc: parser.error(str(exc)) - print(_("Created DataLab plugin project at %s") % destination) + if args.project: + print(_("Created DataLab plugin project at %s") % destination) + return 0 + print(_("Created DataLab plugin %s at %s") % (project.plugin_id, destination)) + if is_default_plugin_folder(destination.parent): + print(_("Select Plugins > Reload plugins in DataLab, or restart it.")) + else: + print( + _( + "Add %s to the plugin directories (Plugins > Configure plugins... " + "> Plugin settings), then reload the plugins." + ) + % destination.parent + ) return 0 From f530ca4cb702d3a875b383b79e31362485ec3569 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:33:38 +0200 Subject: [PATCH 2/4] test(plugins): cover single-file plugins created by the generator (#413) Generated files pass Ruff, keep the typed name and description, and are discovered from any plugin directory for signals, images and application-only plugins. Only two questions are asked, the default folder is DataLab's user plugin directory, existing files are never overwritten, module names without the `datalab_` prefix are refused, and files are accepted by the Install plugins tab. In DataLab, the generated plugin adds its menu entry and processing, also after reloading the plugins. The existing project tests now pass `--project` and are otherwise unchanged. Assisted-by: Claude Opus 5.5 --- .../plugins/plugin_generator_unit_test.py | 430 +++++++++++++++--- .../plugins/plugin_single_file_app_test.py | 86 ++++ 2 files changed, 463 insertions(+), 53 deletions(-) create mode 100644 datalab/tests/features/plugins/plugin_single_file_app_test.py diff --git a/datalab/tests/backbone/plugins/plugin_generator_unit_test.py b/datalab/tests/backbone/plugins/plugin_generator_unit_test.py index d725ee48b..808e44ae4 100644 --- a/datalab/tests/backbone/plugins/plugin_generator_unit_test.py +++ b/datalab/tests/backbone/plugins/plugin_generator_unit_test.py @@ -1,11 +1,13 @@ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. -"""Unit tests for the layered DataLab plugin project generator.""" +"""Unit tests for the DataLab plugin generator: single files and layered projects.""" from __future__ import annotations +import ast import importlib import os +import os.path as osp import pkgutil import subprocess import sys @@ -16,14 +18,83 @@ from datalab import __version__ from datalab.config import Conf -from datalab.plugins import PluginCapability, PluginRegistry, discover_plugins +from datalab.plugins import ( + PluginBase, + PluginCapability, + PluginRegistry, + discover_plugins, + generator, +) from datalab.plugins import base as plugin_base -from datalab.plugins.generator import _default_plugin_id, main +from datalab.plugins.generator import ( + PluginProject, + _default_plugin_id, + create_single_file_plugin, + get_default_plugin_folder, + is_default_plugin_folder, + main, + render_single_file_plugin, +) from datalab.plugins.store import InstalledPluginStore PROJECT_ROOT = Path(__file__).parents[4] +def _discover_from_folder( + folder: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, module_name: str +) -> tuple[list[type[PluginBase]], list]: + """Discover the plugins of a folder, isolated from any other plugin. + + No installed or user plugin is scanned, and the plugin registry and the + generated modules are restored afterwards. + + Returns: + Discovered plugin classes and failed plugins + """ + 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(folder)]) + 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() + previous_failed_plugins = PluginRegistry.get_failed_plugins() + PluginRegistry.clear_plugin_classes() + try: + discover_plugins() + return ( + list(PluginRegistry.get_plugin_classes()), + 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 previous_failed_plugins: + PluginRegistry.add_failed_plugin( + failed_plugin.name, + failed_plugin.filepath, + failed_plugin.traceback, + failed_plugin.source, + ) + for name in tuple(sys.modules): + if name == module_name or name.startswith(f"{module_name}."): + sys.modules.pop(name) + + def _run_generated_project_checks(destination: Path) -> None: """Run the quality checks advertised by a generated project.""" environment = os.environ.copy() @@ -62,6 +133,7 @@ def test_create_layered_plugin_project( main( [ "create", + "--project", str(destination), "--name", "Camera Characterization", @@ -200,6 +272,7 @@ def test_create_application_only_project(tmp_path: Path) -> None: main( [ "create", + "--project", str(destination), "--name", "Application Plugin", @@ -233,6 +306,7 @@ def test_generated_project_is_loaded_from_its_folder( main( [ "create", + "--project", str(destination), "--name", "Folder Plugin", @@ -249,57 +323,16 @@ def test_generated_project_is_loaded_from_its_folder( 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()), + plugin_classes, failed_plugins = _discover_from_folder( + destination, tmp_path, monkeypatch, package_name ) - 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) + assert not [*destination.rglob("*.egg-info"), *destination.rglob("*.dist-info")] + 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 failed_plugins def test_create_refuses_existing_destination(tmp_path: Path) -> None: @@ -311,6 +344,7 @@ def test_create_refuses_existing_destination(tmp_path: Path) -> None: main( [ "create", + "--project", str(destination), "--name", "Existing Plugin", @@ -335,6 +369,7 @@ def test_create_rejects_python_keyword_package(tmp_path: Path) -> None: main( [ "create", + "--project", str(destination), "--name", "Keyword Plugin", @@ -359,6 +394,7 @@ def test_create_rejects_multiline_metadata(tmp_path: Path) -> None: main( [ "create", + "--project", str(destination), "--name", "Multiline Plugin", @@ -383,6 +419,7 @@ def test_create_escapes_display_name_from_python_source(tmp_path: Path) -> None: main( [ "create", + "--project", str(destination), "--name", 'Camera """ Plugin', @@ -409,7 +446,7 @@ def test_create_interactive_defaults( monkeypatch.setattr("builtins.input", lambda _prompt: next(answers)) monkeypatch.chdir(tmp_path) - assert main(["create"]) == 0 + assert main(["create", "--project"]) == 0 destination = tmp_path / "datalab-pulse-characterization" pyproject = (destination / "pyproject.toml").read_text(encoding="utf-8") @@ -452,6 +489,7 @@ def test_create_rejects_invalid_github_account(tmp_path: Path) -> None: main( [ "create", + "--project", str(destination), "--name", "Account Plugin", @@ -478,6 +516,7 @@ def test_create_pulse_project_with_long_metadata_passes_checks(tmp_path: Path) - main( [ "create", + "--project", str(destination), "--name", "Pulse & Transient Characterization", @@ -502,3 +541,288 @@ def test_create_pulse_project_with_long_metadata_passes_checks(tmp_path: Path) - ) _run_generated_project_checks(destination) + + +# --- Single-file plugins (Issue #413) --------------------------------------------- + + +@pytest.fixture(name="user_plugins") +def fixture_user_plugins(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Replace the user plugin directory: tests never write to the real one.""" + folder = tmp_path / "user_plugins" + monkeypatch.setattr(generator, "get_default_plugin_folder", lambda: folder) + return folder + + +def _check_single_file(path: Path) -> str: + """Check a single-file plugin as Ruff does in a plugin directory. + + Returns: + Source code of the plugin + """ + source = path.read_text(encoding="utf-8") + compile(source, str(path), "exec") + assert all(len(line) <= 88 for line in source.splitlines()) + for command in ( + ("format", "--isolated", "--check"), + ("check", "--isolated", "--select", "E,F,I"), + ): + completed = subprocess.run( + [sys.executable, "-m", "ruff", *command, "--line-length", "88", path.name], + cwd=path.parent, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + return source + + +def _string_constants(source: str) -> dict[str, str]: + """Return the module-level string constants of a plugin source.""" + return { + target.id: ast.literal_eval(node.value) + for node in ast.parse(source).body + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) + for target in node.targets + if isinstance(target, ast.Name) + } + + +def test_create_single_file_plugin_by_default( + user_plugins: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, +) -> None: + """Without --project, a ready-to-use file is written to the plugin directory.""" + + def unexpected_prompt(prompt: str) -> str: + raise AssertionError(f"Unexpected question: {prompt}") + + monkeypatch.setattr("builtins.input", unexpected_prompt) + + assert ( + main(["create", "--name", "My Filters", "--description", "Remove noise"]) == 0 + ) + + path = user_plugins / "datalab_my_filters.py" + assert [item.name for item in user_plugins.iterdir()] == [path.name] + output = capsys.readouterr().out + assert str(path.resolve()) in output + assert "org.example.my-filters" in output + assert "Plugins > Reload plugins" in output + source = _check_single_file(path) + assert _string_constants(source) == { + "PLUGIN_DESCRIPTION": "Remove noise", + "PLUGIN_ID": "org.example.my-filters", + "PLUGIN_NAME": "My Filters", + } + assert "def sample_processing(y: np.ndarray) -> np.ndarray:" in source + assert "@computation_function()\ndef compute_sample_processing(" in source + assert "sips.Wrap1to1Func(sample_processing)" in source + assert "handler.action_for(FEATURE_ID)" in source + + +def test_create_single_file_plugin_asks_two_questions( + user_plugins: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Only the name and the description are asked: other values are derived.""" + prompts: list[str] = [] + answers = iter(("Filtres améliorés", "")) + + def answer(prompt: str) -> str: + prompts.append(prompt) + return next(answers) + + monkeypatch.setattr("builtins.input", answer) + + assert main(["create"]) == 0 + + assert len(prompts) == 2 + source = (user_plugins / "datalab_filtres_ameliores.py").read_text(encoding="utf-8") + assert _string_constants(source) == { + "PLUGIN_DESCRIPTION": "Filtres améliorés plugin for DataLab", + "PLUGIN_ID": "org.example.filtres-ameliores", + "PLUGIN_NAME": "Filtres améliorés", + } + assert "class FiltresAmelioresPlugin(PluginBase):" in source + + +@pytest.mark.parametrize( + ("object_kind", "capability", "expected_code"), + [ + ("signal", "processing", "return np.maximum(y, 0.0)"), + ("image", "processing", "return data.max() - data"), + ("image", "application", 'handler.new_action(\n "Show message"'), + ], +) +def test_single_file_plugin_is_loaded_from_its_folder( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, + user_plugins: Path, + object_kind: str, + capability: str, + expected_code: str, +) -> None: + """A generated file is valid and discovered in any plugin directory.""" + folder = tmp_path / "plugins" + module_name = f"datalab_generated_{object_kind}_{capability}" + assert ( + main( + [ + "create", + str(folder), + "--name", + "Generated Plugin", + "--package", + module_name, + "--description", + "Generated single-file plugin", + "--capability", + capability, + "--object-kind", + object_kind, + ] + ) + == 0 + ) + assert not user_plugins.exists() + # Outside the user plugin directory, the folder must be added to DataLab + assert "Plugin settings" in capsys.readouterr().out + source = _check_single_file(folder / f"{module_name}.py") + assert expected_code in source + assert f"self.{object_kind}panel" in source + + plugin_classes, failed_plugins = _discover_from_folder( + folder, tmp_path, monkeypatch, module_name + ) + + assert not failed_plugins + assert len(plugin_classes) == 1 + plugin_class = plugin_classes[0] + assert plugin_class.__module__ == module_name + assert plugin_class.get_plugin_id() == ( + f"org.example.{module_name.removeprefix('datalab_').replace('_', '-')}" + ) + assert plugin_class.PLUGIN_INFO.capabilities == frozenset( + {PluginCapability[capability.upper()]} + ) + assert plugin_class.__plugin_discovery_sources__ == ( + f"module convention {module_name!r}", + ) + + +def test_default_plugin_folder_is_the_datalab_plugin_directory() -> None: + """Generated files go where DataLab looks for plugins.""" + folder = get_default_plugin_folder() + + assert osp.normcase(osp.realpath(folder)) == osp.normcase( + osp.realpath(plugin_base.PLUGINS_DEFAULT_PATH) + ) + assert is_default_plugin_folder(Path(plugin_base.PLUGINS_DEFAULT_PATH)) + + +def test_single_file_plugin_is_never_overwritten(tmp_path: Path) -> None: + """An existing plugin file is kept, from the API and from the CLI.""" + project = PluginProject( + destination=tmp_path, + name="Kept Plugin", + package="datalab_kept_plugin", + plugin_id="org.example.kept-plugin", + description="Kept plugin", + ) + path = create_single_file_plugin(project) + path.write_text("# Edited by the user\n", encoding="utf-8") + + with pytest.raises(FileExistsError): + create_single_file_plugin(project) + with pytest.raises(SystemExit) as exc_info: + main( + [ + "create", + str(tmp_path), + "--name", + "Kept Plugin", + "--description", + "Kept plugin", + ] + ) + + assert exc_info.value.code == 2 + assert path.read_text(encoding="utf-8") == "# Edited by the user\n" + + +def test_single_file_module_name_must_start_with_datalab(tmp_path: Path) -> None: + """DataLab would ignore a plugin module without the ``datalab_`` prefix.""" + destination = tmp_path / "plugins" + project = PluginProject( + destination=destination, + name="Hidden Plugin", + package="hidden_plugin", + plugin_id="org.example.hidden-plugin", + description="Hidden plugin", + ) + + with pytest.raises(ValueError, match="datalab_"): + render_single_file_plugin(project) + with pytest.raises(SystemExit) as exc_info: + main( + [ + "create", + str(destination), + "--name", + "Hidden Plugin", + "--package", + "hidden_plugin", + "--description", + "Hidden plugin", + ] + ) + + assert exc_info.value.code == 2 + assert not destination.exists() + + +def test_single_file_plugin_keeps_quotes_and_accents_readable(tmp_path: Path) -> None: + """User values are kept as typed, as valid and formatted Python strings.""" + name = 'Caméra """ & co' + description = 'It\'s a "quoted" description with a \\ backslash' + path = create_single_file_plugin( + PluginProject( + destination=tmp_path, + name=name, + package="datalab_quoted_camera", + plugin_id="org.example.quoted-camera", + description=description, + ) + ) + + source = _check_single_file(path) + + assert 'Caméra """ & co' in source + constants = _string_constants(source) + assert (constants["PLUGIN_NAME"], constants["PLUGIN_DESCRIPTION"]) == ( + name, + description, + ) + + +def test_single_file_plugin_can_be_installed_from_the_plugin_dialog( + tmp_path: Path, +) -> None: + """A generated file is accepted by Plugins > Configure plugins > Install.""" + path = create_single_file_plugin( + PluginProject( + destination=tmp_path / "shared", + name="Shared Plugin", + package="datalab_shared_generated", + plugin_id="org.example.shared-generated", + description="Plugin shared with colleagues", + ) + ) + store = InstalledPluginStore(str(tmp_path / "installed_plugins")) + + plugin = store.install_module(str(path)).plugin + + assert (plugin.kind, plugin.name) == ("module", "datalab_shared_generated") diff --git a/datalab/tests/features/plugins/plugin_single_file_app_test.py b/datalab/tests/features/plugins/plugin_single_file_app_test.py new file mode 100644 index 000000000..b8607aecf --- /dev/null +++ b/datalab/tests/features/plugins/plugin_single_file_app_test.py @@ -0,0 +1,86 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Single-file plugins created by ``datalab-plugin create``, used in DataLab.""" + +# guitest: show + +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path + +import numpy as np +from qtpy import QtWidgets as QW +from sigima.objects import create_signal + +from datalab.config import Conf +from datalab.gui.actionhandler import ActionCategory +from datalab.gui.main import DLMainWindow +from datalab.plugins import PluginRegistry +from datalab.plugins.generator import PluginProject, create_single_file_plugin +from datalab.tests import datalab_test_app_context +from datalab.tests.features.plugins.plugin_test_dataset import temporary_plugin_dir + +PLUGIN_ID = "org.example.generated-single-file" +PLUGIN_NAME = "Generated Single File" +FEATURE_ID = f"{PLUGIN_ID}:sample-processing" +X = np.linspace(-1.0, 1.0, 21) + + +@contextmanager +def enabled_plugins_context(): + """Temporarily enable all plugins.""" + with Conf.plugins_enabled.context(True), Conf.plugins_enabled_list.context(None): + yield + + +def get_plugin_menu_actions(win: DLMainWindow) -> list[str]: + """Return the actions of the plugin submenu in the signal panel.""" + for item in win.signalpanel.get_category_actions(ActionCategory.PLUGINS): + if isinstance(item, QW.QMenu) and item.title() == PLUGIN_NAME: + # Qt menus populate their actions lazily + item.aboutToShow.emit() + return [action.text() for action in item.actions()] + raise AssertionError(f"{PLUGIN_NAME!r} menu not found in the Plugins menu") + + +def run_sample_processing(win: DLMainWindow) -> np.ndarray: + """Run the sample processing on a signal with negative values.""" + win.signalpanel.add_object(create_signal("Ramp", X, X.copy())) + count = len(win.signalpanel) + win.signalpanel.processor.run_feature(FEATURE_ID) + assert len(win.signalpanel) == count + 1 + return win.signalpanel[len(win.signalpanel)].y + + +@enabled_plugins_context() +def test_single_file_plugin_in_datalab() -> None: + """A generated plugin adds a working processing to the Plugins menu.""" + with temporary_plugin_dir() as plugin_dir: + create_single_file_plugin( + PluginProject( + destination=Path(plugin_dir), + name=PLUGIN_NAME, + package="datalab_test_plugin_generated_single_file", + plugin_id=PLUGIN_ID, + description="Plugin created by datalab-plugin create", + ) + ) + expected = np.maximum(X, 0.0) + + with datalab_test_app_context(console=False) as win: + assert PluginRegistry.get_plugin(PLUGIN_ID) is not None + assert get_plugin_menu_actions(win) == ["Sample processing"] + assert np.array_equal(run_sample_processing(win), expected) + + win.reload_plugins() + QW.QApplication.processEvents() + + registry = list(win.signalpanel.processor.computing_registry) + assert registry.count(FEATURE_ID) == 1 + assert get_plugin_menu_actions(win) == ["Sample processing"] + assert np.array_equal(run_sample_processing(win), expected) + + +if __name__ == "__main__": + test_single_file_plugin_in_datalab() From 7f5bc1b3bdeacce90cd0f5eafec72550aed2551f Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:33:42 +0200 Subject: [PATCH 3/4] docs(plugins): document single-file plugin creation (#413) The plugin documentation presents `datalab-plugin create` as the way to create a single-file plugin, and the layered project with `--project`. Release notes 1.4 (Plugin project generator) with their French translation. Assisted-by: Claude Opus 5.5 --- doc/features/advanced/plugins.rst | 40 ++++++++++++---- .../LC_MESSAGES/features/advanced/plugins.po | 46 +++++++++++++++---- .../LC_MESSAGES/release_notes/release_1.04.po | 11 +++-- doc/release_notes/release_1.04.md | 5 +- 4 files changed, 80 insertions(+), 22 deletions(-) diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst index cd084d189..d920bff11 100644 --- a/doc/features/advanced/plugins.rst +++ b/doc/features/advanced/plugins.rst @@ -16,8 +16,8 @@ A plugin is Python code that is automatically loaded by DataLab at startup. It c 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: -- **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``. +- **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: ``datalab-plugin create`` creates such a file, ready to use (see :ref:`plugin_generator`). +- **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 --project`` define the class (see :ref:`plugin_project`). Otherwise, the class is defined in ``__init__.py``. Nothing needs to be installed: the file or folder is loaded as it is. @@ -142,7 +142,7 @@ 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. +A plugin directory may also be the folder of a plugin project, such as one created by ``datalab-plugin create --project``: 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 --------------------------- @@ -630,24 +630,48 @@ 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_generator: + +Creating a plugin with ``datalab-plugin create`` +------------------------------------------------- + +The ``datalab-plugin`` command is installed with DataLab as a Python package; the standalone version of DataLab does not provide it. Run it without options to create a single-file plugin: + +.. code-block:: bash + + datalab-plugin create + +The command only asks for the plugin name and its description, then writes a ``datalab_.py`` file to the user plugin directory (see :ref:`plugin_locations`). Select **Plugins > Reload plugins** in DataLab, or restart DataLab: the plugin adds its submenu to the **Plugins** menu, with a sample processing of the selected signals. To implement your own processing, edit the ``sample_processing`` function of the file, which receives the Y values of a signal as a NumPy array and returns new ones, then reload the plugins. + +Options adapt the generated file: + +- ``--object-kind image``: the sample processing applies to images, and its function receives the image data. +- ``--capability``: without the ``processing`` capability, the submenu holds an action showing a message, to replace with your own. +- A destination folder, given as argument: it must then be added to the plugin directories (see :ref:`plugin_search_paths`). +- ``--package`` and ``--plugin-id``: the module name and the plugin ID are otherwise derived from the plugin name. The module name must start with ``datalab_``. + +An existing file is never overwritten. To share the plugin, send its file: other users install it with **Plugins > Configure plugins... > Install plugins** (see :ref:`plugin_install_from_file`). + +To publish a plugin in the plugin catalog, or to develop a larger plugin with tests, create a layered project with ``--project`` instead (see :ref:`plugin_project`). + .. _plugin_project: Creating a layered plugin project ---------------------------------- -The installed ``datalab-plugin`` command creates a small, installable project -that follows the stable plugin and feature ownership contracts. Run it without -options for an interactive setup: +With ``--project``, the installed ``datalab-plugin`` command creates a small, +installable project that follows the stable plugin and feature ownership +contracts. Run it without other options for an interactive setup: .. code-block:: bash - datalab-plugin create + datalab-plugin create --project The command asks for the display name and the GitHub account (user or organization) that will host the project, and offers defaults for the Python package, reverse-domain plugin ID, description, and destination. The default plugin ID is ``io.github..``; ``org.datalab.`` IDs are reserved for the DataLab-Platform organization, and ``org.example.`` is used when no account is given. For scripts or reproducible setup, pass the values explicitly: .. code-block:: bash - datalab-plugin create datalab-camera-characterization \ + datalab-plugin create --project datalab-camera-characterization \ --name "Camera Characterization" \ --github-account DataLab-Platform \ --package datalab_camera_characterization \ diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po index 8115c08a8..4faa1861d 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po @@ -31,11 +31,11 @@ msgstr "Un plugin est du code Python qui est automatiquement chargé par DataLab 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 "**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 "**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: ``datalab-plugin create`` creates such a file, ready to use (see :ref:`plugin_generator`)." +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 : ``datalab-plugin create`` crée un tel fichier, prêt à l'emploi (voir :ref:`plugin_generator`)." -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 "**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 --project`` 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 --project`` définissent la classe (voir :ref:`plugin_project`). Sinon, la classe est définie dans ``__init__.py``." 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." @@ -106,8 +106,8 @@ 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 "A plugin directory may also be the folder of a plugin project, such as one created by ``datalab-plugin create --project``: 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 --project`` : 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" @@ -427,11 +427,41 @@ msgstr "Les tuiles déclarées remplacent la tuile par défaut. La première est msgid "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." msgstr "L'utilisateur choisit les applications affichées et leur ordre : la page d'accueil affiche un nombre limité de lignes de tuiles, avec d'abord les applications épinglées, puis celles utilisées récemment, puis les autres par ordre alphabétique (voir :ref:`welcome_page`). Un plugin ne peut donc pas compter sur la visibilité de sa tuile : ses fonctionnalités doivent rester accessibles depuis le catalogue **Applications**." +msgid "Creating a plugin with ``datalab-plugin create``" +msgstr "Créer un plugin avec ``datalab-plugin create``" + +msgid "The ``datalab-plugin`` command is installed with DataLab as a Python package; the standalone version of DataLab does not provide it. Run it without options to create a single-file plugin:" +msgstr "La commande ``datalab-plugin`` est installée avec DataLab sous forme de paquet Python ; la version autonome de DataLab ne la fournit pas. Exécutez-la sans option pour créer un plugin en fichier unique :" + +msgid "The command only asks for the plugin name and its description, then writes a ``datalab_.py`` file to the user plugin directory (see :ref:`plugin_locations`). Select **Plugins > Reload plugins** in DataLab, or restart DataLab: the plugin adds its submenu to the **Plugins** menu, with a sample processing of the selected signals. To implement your own processing, edit the ``sample_processing`` function of the file, which receives the Y values of a signal as a NumPy array and returns new ones, then reload the plugins." +msgstr "La commande demande seulement le nom du plugin et sa description, puis écrit un fichier ``datalab_.py`` dans le répertoire de plugins de l'utilisateur (voir :ref:`plugin_locations`). Sélectionnez **Plugins > Recharger les plugins** dans DataLab, ou redémarrez DataLab : le plugin ajoute son sous-menu au menu **Plugins**, avec un traitement d'exemple des signaux sélectionnés. Pour réaliser votre propre traitement, modifiez la fonction ``sample_processing`` du fichier, qui reçoit les valeurs Y d'un signal sous forme de tableau NumPy et en renvoie de nouvelles, puis rechargez les plugins." + +msgid "Options adapt the generated file:" +msgstr "Des options adaptent le fichier généré :" + +msgid "``--object-kind image``: the sample processing applies to images, and its function receives the image data." +msgstr "``--object-kind image`` : le traitement d'exemple s'applique aux images, et sa fonction reçoit les données de l'image." + +msgid "``--capability``: without the ``processing`` capability, the submenu holds an action showing a message, to replace with your own." +msgstr "``--capability`` : sans la capacité ``processing``, le sous-menu contient une action affichant un message, à remplacer par la vôtre." + +msgid "A destination folder, given as argument: it must then be added to the plugin directories (see :ref:`plugin_search_paths`)." +msgstr "Un dossier de destination, donné en argument : il doit alors être ajouté aux répertoires de plugins (voir :ref:`plugin_search_paths`)." + +msgid "``--package`` and ``--plugin-id``: the module name and the plugin ID are otherwise derived from the plugin name. The module name must start with ``datalab_``." +msgstr "``--package`` et ``--plugin-id`` : à défaut, le nom du module et l'ID du plugin sont déduits du nom du plugin. Le nom du module doit commencer par ``datalab_``." + +msgid "An existing file is never overwritten. To share the plugin, send its file: other users install it with **Plugins > Configure plugins... > Install plugins** (see :ref:`plugin_install_from_file`)." +msgstr "Un fichier existant n'est jamais écrasé. Pour partager le plugin, envoyez son fichier : les autres utilisateurs l'installent avec **Plugins > Configurer les plugins... > Installer des plugins** (voir :ref:`plugin_install_from_file`)." + +msgid "To publish a plugin in the plugin catalog, or to develop a larger plugin with tests, create a layered project with ``--project`` instead (see :ref:`plugin_project`)." +msgstr "Pour publier un plugin dans le catalogue de plugins, ou pour développer un plugin plus important avec des tests, créez plutôt un projet structuré en couches avec ``--project`` (voir :ref:`plugin_project`)." + msgid "Creating a layered plugin project" msgstr "Créer un projet de plugin structuré en couches" -msgid "The installed ``datalab-plugin`` command creates a small, installable project that follows the stable plugin and feature ownership contracts. Run it without options for an interactive setup:" -msgstr "La commande installée ``datalab-plugin`` crée un petit projet installable qui respecte les contrats stables des plugins et de propriété des fonctionnalités. Exécutez-la sans option pour une configuration interactive :" +msgid "With ``--project``, the installed ``datalab-plugin`` command creates a small, installable project that follows the stable plugin and feature ownership contracts. Run it without other options for an interactive setup:" +msgstr "Avec ``--project``, la commande installée ``datalab-plugin`` crée un petit projet installable qui respecte les contrats stables des plugins et de propriété des fonctionnalités. Exécutez-la sans autre option pour une configuration interactive :" msgid "The command asks for the display name and the GitHub account (user or organization) that will host the project, and offers defaults for the Python package, reverse-domain plugin ID, description, and destination. The default plugin ID is ``io.github..``; ``org.datalab.`` IDs are reserved for the DataLab-Platform organization, and ``org.example.`` is used when no account is given. For scripts or reproducible setup, pass the values explicitly:" msgstr "La commande demande le nom d'affichage et le compte GitHub (utilisateur ou organisation) qui hébergera le projet, et propose des valeurs par défaut pour le package Python, l'ID de plugin en notation de domaine inversé, la description et la destination. L'ID de plugin par défaut est ``io.github..`` ; les ID ``org.datalab.`` sont réservés à l'organisation DataLab-Platform, et ``org.example.`` est utilisé lorsqu'aucun compte n'est indiqué. Pour les scripts ou une configuration reproductible, indiquez explicitement les valeurs :" 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 9088fc241..60b66c3a1 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 @@ -135,6 +135,9 @@ msgstr "Un catalogue illisible, comme un partage réseau non connecté ou le cat msgid "**Plugin project generator:**" msgstr "**Générateur de projets de plugins :**" +msgid "`datalab-plugin create` now creates a single-file plugin by default, the simplest way to extend DataLab: it only asks for the plugin name and description, then writes a `datalab_.py` file to the user plugin directory. Once the plugins are reloaded, the plugin adds its submenu to the **Plugins** menu with a sample processing of the selected signals (or images, with `--object-kind image`), whose short NumPy function is the only part to edit. The file never overwrites an existing one, and can be shared and installed with **Plugins > Configure plugins... > Install plugins**. The layered installable project, described below, is created with `--project` (implements [Issue #413](https://github.com/DataLab-Platform/DataLab/issues/413))" +msgstr "`datalab-plugin create` crée désormais par défaut un plugin en fichier unique, la façon la plus simple d'étendre DataLab : la commande demande seulement le nom du plugin et sa description, puis écrit un fichier `datalab_.py` dans le répertoire de plugins de l'utilisateur. Une fois les plugins rechargés, le plugin ajoute son sous-menu au menu **Plugins**, avec un traitement d'exemple des signaux sélectionnés (ou des images, avec `--object-kind image`), dont la courte fonction NumPy est la seule partie à modifier. Le fichier n'écrase jamais un fichier existant, et peut être partagé et installé avec **Plugins > Configurer les plugins... > Installer des plugins**. Le projet installable structuré en couches, décrit ci-dessous, est créé avec `--project` (implémente [l'issue #413](https://github.com/DataLab-Platform/DataLab/issues/413))" + msgid "Hardened ``datalab-plugin create`` from the Camera pilot feedback: generated projects now separate host-independent ``core`` code, headless ``workflow`` orchestration, and Desktop/Web ``adapters`` from the first commit" msgstr "Durcissement de ``datalab-plugin create`` à partir du retour du pilote Camera : les projets générés séparent désormais dès le premier commit le code ``core`` indépendant de l'hôte, l'orchestration ``workflow`` sans interface et les ``adapters`` Desktop/Web" @@ -150,8 +153,8 @@ msgstr "Les noms et descriptions de plugins longs sont désormais mis en forme a msgid "Generated projects are ready to publish: a release workflow attaches the wheel to a GitHub release when a version tag is pushed and prints the entry to submit to the DataLab plugin catalog; `pyproject.toml` requires the current DataLab major version and declares the project URLs" msgstr "Les projets générés sont prêts à publier : un workflow de release joint le wheel à une release GitHub lorsqu'une étiquette de version est poussée et affiche la fiche à soumettre au catalogue de plugins DataLab ; `pyproject.toml` exige la version majeure courante de DataLab et déclare les URL du projet" -msgid "`datalab-plugin create` asks for the GitHub account hosting the project and derives the default plugin ID from it (`io.github..`), `org.datalab.` IDs being reserved for DataLab-Platform plugins" -msgstr "`datalab-plugin create` demande le compte GitHub qui héberge le projet et en déduit l'ID de plugin par défaut (`io.github..`), les ID `org.datalab.` étant réservés aux plugins de DataLab-Platform" +msgid "`datalab-plugin create --project` asks for the GitHub account hosting the project and derives the default plugin ID from it (`io.github..`), `org.datalab.` IDs being reserved for DataLab-Platform plugins" +msgstr "`datalab-plugin create --project` demande le compte GitHub qui héberge le projet et en déduit l'ID de plugin par défaut (`io.github..`), les ID `org.datalab.` étant réservés aux plugins de DataLab-Platform" msgid "**Add metadata:**" msgstr "**Ajout de métadonnées :**" @@ -342,8 +345,8 @@ msgstr "**Édition > Retraiter** recalcule désormais aussi les résultats des o 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 organized as a folder, such as those created by `datalab-plugin create --project`, 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 --project`, 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 426e40662..e0dca80ed 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -53,12 +53,13 @@ **Plugin project generator:** +* `datalab-plugin create` now creates a single-file plugin by default, the simplest way to extend DataLab: it only asks for the plugin name and description, then writes a `datalab_.py` file to the user plugin directory. Once the plugins are reloaded, the plugin adds its submenu to the **Plugins** menu with a sample processing of the selected signals (or images, with `--object-kind image`), whose short NumPy function is the only part to edit. The file never overwrites an existing one, and can be shared and installed with **Plugins > Configure plugins... > Install plugins**. The layered installable project, described below, is created with `--project` (implements [Issue #413](https://github.com/DataLab-Platform/DataLab/issues/413)) * Hardened ``datalab-plugin create`` from the Camera pilot feedback: generated projects now separate host-independent ``core`` code, headless ``workflow`` orchestration, and Desktop/Web ``adapters`` from the first commit * Generated package roots expose stable identity without importing Qt; the installed entry point targets the Desktop adapter while Web support is explicitly marked unsupported * Every generated project includes an executable architecture regression test, contribution and architecture documentation, and a changelog * Long plugin names and descriptions are now formatted so newly generated projects pass their bundled Ruff checks without manual source edits * Generated projects are ready to publish: a release workflow attaches the wheel to a GitHub release when a version tag is pushed and prints the entry to submit to the DataLab plugin catalog; `pyproject.toml` requires the current DataLab major version and declares the project URLs -* `datalab-plugin create` asks for the GitHub account hosting the project and derives the default plugin ID from it (`io.github..`), `org.datalab.` IDs being reserved for DataLab-Platform plugins +* `datalab-plugin create --project` asks for the GitHub account hosting the project and derives the default plugin ID from it (`io.github..`), `org.datalab.` IDs being reserved for DataLab-Platform plugins **Add metadata:** @@ -151,7 +152,7 @@ DataLab now uses stable UUID-based object references across object trees, result **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 organized as a folder, such as those created by `datalab-plugin create --project`, 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:** From 4a8d849b99bd2e4572271b35b7e55af7cbe6aca4 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:08:25 +0200 Subject: [PATCH 4/4] refactor(plugins): register the generated NumPy function as it is (#413) Since #416 (#415), 1-to-1 features accept a NumPy function wrapped with Sigima's `Wrap1to1Func`. Generated single-file plugins now register `Wrap1to1Func(sample_processing)` directly, without the decorated `compute_sample_processing` function and the imports it needed: the generated file is shorter, and its only function is the one to edit. Assisted-by: Claude Opus 5.5 --- datalab/plugins/generator.py | 32 ++++++------------- .../plugins/plugin_generator_unit_test.py | 5 +-- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/datalab/plugins/generator.py b/datalab/plugins/generator.py index a54c39919..a695e60ae 100644 --- a/datalab/plugins/generator.py +++ b/datalab/plugins/generator.py @@ -39,22 +39,17 @@ "from datalab.plugins import PluginBase, PluginCapability, PluginInfo\n" ) #: Sample processing of single-file plugins, by object kind: argument, summary and -#: returned expression of the NumPy function that users edit, then object class -#: and summary of the computation function registered in DataLab +#: returned expression of the NumPy function that users edit _SAMPLE_FUNCTIONS = { "signal": ( "y", "Return new Y values computed from those of the selected signal.", "np.maximum(y, 0.0)", - "SignalObj", - "Return a new signal whose Y values are computed by ``sample_processing``.", ), "image": ( "data", "Return new image data computed from that of the selected image.", "data.max() - data", - "ImageObj", - "Return a new image whose data are computed by ``sample_processing``.", ), } @@ -337,17 +332,16 @@ def _check_single_file_module(project: PluginProject) -> None: def _render_sample_processing(project: PluginProject) -> tuple[str, str, str]: - """Render the imports, function and methods of the sample processing.""" + """Render the imports, function and methods of the sample processing. + + The NumPy function is registered wrapped with ``Wrap1to1Func``, which applies + it to the data of the selected signals or images. + """ kind = project.object_kind alias = "sips" if kind == "signal" else "sipi" - argument, summary, expression, object_class, computation_summary = ( - _SAMPLE_FUNCTIONS[kind] - ) + argument, summary, expression = _SAMPLE_FUNCTIONS[kind] imports = ( - f"import numpy as np\nimport sigima.proc.{kind} as {alias}\n" - f"{_PLUGIN_IMPORT}" - f"from sigima.objects import {object_class}\n" - "from sigima.proc.decorator import computation_function\n" + f"import numpy as np\nimport sigima.proc.{kind} as {alias}\n{_PLUGIN_IMPORT}" ) function = f'''FEATURE_ID = f"{{PLUGIN_ID}}:sample-processing" @@ -357,18 +351,12 @@ def sample_processing({argument}: np.ndarray) -> np.ndarray: Edit this function to implement your own processing. """ - return {expression} - - -@computation_function() -def compute_sample_processing(src: {object_class}) -> {object_class}: - """{computation_summary}""" - return {alias}.Wrap1to1Func(sample_processing)(src)''' + return {expression}''' methods = f''' def register_computations(self) -> None: """Register the processing of this plugin.""" self.{kind}panel.processor.register_1_to_1( - compute_sample_processing, + {alias}.Wrap1to1Func(sample_processing), "Sample processing", feature_id=FEATURE_ID, owner_plugin_id=self.plugin_id, diff --git a/datalab/tests/backbone/plugins/plugin_generator_unit_test.py b/datalab/tests/backbone/plugins/plugin_generator_unit_test.py index 808e44ae4..58339a83a 100644 --- a/datalab/tests/backbone/plugins/plugin_generator_unit_test.py +++ b/datalab/tests/backbone/plugins/plugin_generator_unit_test.py @@ -618,8 +618,9 @@ def unexpected_prompt(prompt: str) -> str: "PLUGIN_NAME": "My Filters", } assert "def sample_processing(y: np.ndarray) -> np.ndarray:" in source - assert "@computation_function()\ndef compute_sample_processing(" in source - assert "sips.Wrap1to1Func(sample_processing)" in source + # The NumPy function is registered as it is, wrapped with Wrap1to1Func + assert " sips.Wrap1to1Func(sample_processing),\n" in source + assert "computation_function" not in source assert "handler.action_for(FEATURE_ID)" in source