From 0b9152d17da83e86f336fd9a5a84e16e7fc1bea6 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:41:48 +0200 Subject: [PATCH] feat(plugins): generate projects ready for GitHub releases and the catalog Assisted-by: Claude Opus 5.5 --- datalab/locale/fr/LC_MESSAGES/datalab.po | 9 ++ datalab/plugins/generator.py | 135 +++++++++++++++++- .../plugins/plugin_generator_unit_test.py | 72 +++++++++- doc/features/advanced/plugins.rst | 7 +- .../LC_MESSAGES/features/advanced/plugins.po | 7 +- .../LC_MESSAGES/release_notes/release_1.04.po | 6 + doc/release_notes/release_1.04.md | 2 + 7 files changed, 225 insertions(+), 13 deletions(-) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index c9b49549e..19517b59b 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -3750,6 +3750,9 @@ msgstr "Le type d'objet doit être 'signal' ou 'image'" msgid "The minimal template currently supports BSD-3-Clause" msgstr "Le modèle minimal prend actuellement en charge BSD-3-Clause" +msgid "GitHub account must be a GitHub user or organization" +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" @@ -3764,6 +3767,9 @@ msgstr "%s est requis" msgid "Plugin name" msgstr "Nom du plugin" +msgid "GitHub account (optional)" +msgstr "Compte GitHub (facultatif)" + msgid "Package name" msgstr "Nom du package" @@ -3798,6 +3804,9 @@ msgstr "ID stable en notation de domaine inversé" msgid "One-line description" msgstr "Description sur une ligne" +msgid "GitHub user or organization hosting the project" +msgstr "Utilisateur ou organisation GitHub hébergeant le projet" + msgid "Plugin capability; may be repeated" msgstr "Capacité du plugin ; l'option peut être répétée" diff --git a/datalab/plugins/generator.py b/datalab/plugins/generator.py index 88d87e025..f6ee2f1c0 100644 --- a/datalab/plugins/generator.py +++ b/datalab/plugins/generator.py @@ -12,11 +12,15 @@ from collections.abc import Sequence from pathlib import Path +from datalab import __version__ from datalab.config import _ _PACKAGE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") _PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[.-][a-z0-9]+)+$") +_GITHUB_ACCOUNT_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$") _CAPABILITIES = ("application", "processing", "io", "visualization") +#: GitHub organization whose plugins use the reserved ``org.datalab.`` IDs +OFFICIAL_GITHUB_ACCOUNT = "DataLab-Platform" @dataclasses.dataclass(frozen=True) @@ -31,6 +35,7 @@ class PluginProject: capabilities: tuple[str, ...] = ("processing",) object_kind: str = "signal" license_id: str = "BSD-3-Clause" + github_account: str = "" def __post_init__(self) -> None: """Validate values used as paths, Python identifiers, and metadata.""" @@ -64,6 +69,17 @@ def __post_init__(self) -> None: raise ValueError(_("Object kind must be 'signal' or 'image'")) if self.license_id != "BSD-3-Clause": raise ValueError(_("The minimal template currently supports BSD-3-Clause")) + if self.github_account and not _GITHUB_ACCOUNT_PATTERN.fullmatch( + self.github_account + ): + raise ValueError(_("GitHub account must be a GitHub user or organization")) + + @property + def repository_url(self) -> str: + """Return the GitHub repository URL, if the account is known.""" + if not self.github_account: + return "" + return f"https://github.com/{self.github_account}/{self.distribution}" @property def distribution(self) -> str: @@ -100,6 +116,15 @@ def _render_python_string_assignment(name: str, value: str) -> str: def _render_pyproject(project: PluginProject) -> str: """Render generated project metadata and plugin entry point.""" + major, minor = __version__.split(".")[:2] + requirement = f"datalab-platform >= {major}.{minor}, < {int(major) + 1}" + urls = "" + if project.repository_url: + urls = f""" +[project.urls] +Homepage = "{project.repository_url}" +Issues = "{project.repository_url}/issues" +""" return f'''[build-system] requires = ["setuptools >= 77"] build-backend = "setuptools.build_meta" @@ -112,8 +137,9 @@ def _render_pyproject(project: PluginProject) -> str: license = "{project.license_id}" license-files = ["LICENSE"] requires-python = ">=3.9" -dependencies = ["datalab-platform >= 1.3"] - +dependencies = ["{requirement}"] +keywords = ["datalab", "datalab-plugin"] +{urls} [project.entry-points."datalab.plugins"] {project.package} = "{project.package}.adapters.desktop:{project.class_name}" @@ -389,9 +415,87 @@ def _render_readme(project: PluginProject) -> str: 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 + +Set the new version in `pyproject.toml` and in the package `__init__.py`, then +push a tag such as `v0.1.0`. The **Release** workflow builds the wheel and +attaches it to a GitHub release: DataLab users install it with +**Plugins > Configure plugins... > Install plugins**. + +To list the plugin in the DataLab plugin catalog, open a pull request on + with the entry printed in the +summary of the **Release** workflow run. +""" + + +_RELEASE_WORKFLOW = """name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - run: python -m pip install build + + - run: python -m build --wheel + + - name: Attach the wheel to a GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + wheel=$(ls dist/*-py3-none-any.whl) + case "$wheel" in + *-"${GITHUB_REF_NAME#v}"-py3-none-any.whl) ;; + *) echo "Tag $GITHUB_REF_NAME does not match $wheel" >&2; exit 1 ;; + esac + gh release create "$GITHUB_REF_NAME" "$wheel" --verify-tag --generate-notes + + - name: Print the plugin catalog entry + run: | + sha256=$(sha256sum dist/*-py3-none-any.whl | cut -d " " -f 1) + { + echo "To list this release in the DataLab plugin catalog, add" + echo "\\`plugins/__PLUGIN_ID__.yaml\\` to" + echo "https://github.com/DataLab-Platform/plugins" + echo "(or only the new release if the plugin is already listed):" + echo + echo '```yaml' + cat <<'EOF' + id: __PLUGIN_ID__ + name: __PLUGIN_NAME__ + EOF + echo "repository: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + echo "capabilities: [__CAPABILITIES__]" + echo "releases:" + echo " - version: ${GITHUB_REF_NAME#v}" + echo " sha256: $sha256" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" """ +def _render_release_workflow(project: PluginProject) -> str: + """Render the workflow publishing tagged wheels as GitHub releases.""" + return ( + _RELEASE_WORKFLOW.replace("__PLUGIN_ID__", project.plugin_id) + .replace("__PLUGIN_NAME__", _toml_string(project.name)) + .replace("__CAPABILITIES__", ", ".join(project.capabilities)) + ) + + def _render_architecture_doc(project: PluginProject) -> str: """Render the generated dependency-boundary documentation.""" return f"""# Architecture @@ -487,6 +591,7 @@ def create_plugin_project(project: PluginProject) -> Path: if destination.exists(): raise FileExistsError(_("Destination already exists: %s") % destination) files = { + ".github/workflows/release.yml": _render_release_workflow(project), ".gitignore": _GITIGNORE, "CHANGELOG.md": _render_changelog(), "CONTRIBUTING.md": _render_contributing(), @@ -523,10 +628,14 @@ def _default_package(name: str) -> str: return slug -def _default_plugin_id(package: str) -> str: - """Derive a reverse-domain plugin ID from an import package.""" +def _default_plugin_id(package: str, github_account: str = "") -> str: + """Derive a reverse-domain plugin ID from an import package and its owner.""" slug = package.removeprefix("datalab_").replace("_", "-") - return f"org.datalab.{slug}" + if github_account.lower() == OFFICIAL_GITHUB_ACCOUNT.lower(): + return f"org.datalab.{slug}" + if github_account: + return f"io.github.{github_account.lower()}.{slug}" + return f"org.example.{slug}" def _prompt(label: str, default: str | None = None) -> str: @@ -543,8 +652,17 @@ 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.""" 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 "" + ) package = args.package or _prompt(_("Package name"), _default_package(name)) - plugin_id = args.plugin_id or _prompt(_("Plugin ID"), _default_plugin_id(package)) + 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 ) @@ -561,6 +679,7 @@ def _project_from_args(args: argparse.Namespace) -> PluginProject: capabilities=capabilities, object_kind=args.object_kind, license_id=args.license_id, + github_account=github_account, ) @@ -579,6 +698,10 @@ def build_parser() -> argparse.ArgumentParser: create_parser.add_argument("--package", help=_("Python import package name")) create_parser.add_argument("--plugin-id", help=_("Stable reverse-domain ID")) create_parser.add_argument("--description", help=_("One-line description")) + create_parser.add_argument( + "--github-account", + help=_("GitHub user or organization hosting the project"), + ) create_parser.add_argument( "--capability", dest="capabilities", diff --git a/datalab/tests/backbone/plugins/plugin_generator_unit_test.py b/datalab/tests/backbone/plugins/plugin_generator_unit_test.py index e8a41cb0c..d4138f251 100644 --- a/datalab/tests/backbone/plugins/plugin_generator_unit_test.py +++ b/datalab/tests/backbone/plugins/plugin_generator_unit_test.py @@ -11,9 +11,11 @@ from pathlib import Path import pytest +import yaml +from datalab import __version__ from datalab.plugins import PluginCapability, PluginRegistry -from datalab.plugins.generator import main +from datalab.plugins.generator import _default_plugin_id, main PROJECT_ROOT = Path(__file__).parents[4] @@ -66,6 +68,8 @@ def test_create_layered_plugin_project( "processing", "--object-kind", "image", + "--github-account", + "DataLab-Platform", ] ) == 0 @@ -73,6 +77,7 @@ def test_create_layered_plugin_project( assert str(destination.resolve()) in capsys.readouterr().out expected_files = { + ".github/workflows/release.yml", ".gitignore", "CHANGELOG.md", "CONTRIBUTING.md", @@ -106,6 +111,15 @@ def test_create_layered_plugin_project( assert 'name = "datalab-camera-characterization"' in pyproject assert 'license = "BSD-3-Clause"' in pyproject assert 'license-files = ["LICENSE"]' in pyproject + major, minor = __version__.split(".")[:2] + assert ( + f'dependencies = ["datalab-platform >= {major}.{minor}, < {int(major) + 1}"]' + in pyproject + ) + assert 'keywords = ["datalab", "datalab-plugin"]' in pyproject + repository = "https://github.com/DataLab-Platform/datalab-camera-characterization" + assert f'Homepage = "{repository}"' in pyproject + assert f'Issues = "{repository}/issues"' in pyproject assert '[project.entry-points."datalab.plugins"]' in pyproject assert ( "datalab_camera_characterization = " @@ -115,6 +129,15 @@ def test_create_layered_plugin_project( _run_generated_project_checks(destination) + release = (destination / ".github/workflows/release.yml").read_text( + encoding="utf-8" + ) + steps = yaml.safe_load(release)["jobs"]["release"]["steps"] + assert "gh release create" in steps[4]["run"] + assert "id: org.datalab.camera-characterization\n" in steps[5]["run"] + assert 'name: "Camera Characterization"\n' in steps[5]["run"] + assert "capabilities: [application, processing]" in steps[5]["run"] + web_adapter = ( destination / "src/datalab_camera_characterization/adapters/web.py" ).read_text(encoding="utf-8") @@ -294,7 +317,7 @@ def test_create_interactive_defaults( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The bare create command infers safe defaults from the plugin name.""" - answers = iter(("Pulse Characterization", "", "", "", "")) + answers = iter(("Pulse Characterization", "DataLab-Platform", "", "", "", "")) monkeypatch.setattr("builtins.input", lambda _prompt: next(answers)) monkeypatch.chdir(tmp_path) @@ -312,6 +335,51 @@ def test_create_interactive_defaults( assert 'PLUGIN_ID = "org.datalab.pulse-characterization"' in package assert 'PLUGIN_DESCRIPTION = "Pulse Characterization plugin for DataLab"' in package assert "id=PLUGIN_ID" in plugin + assert ( + 'Homepage = "https://github.com/DataLab-Platform/' + 'datalab-pulse-characterization"' in pyproject + ) + + +@pytest.mark.parametrize( + ("github_account", "plugin_id"), + [ + ("", "org.example.spectrum-tools"), + ("Alice-Lab", "io.github.alice-lab.spectrum-tools"), + ("datalab-platform", "org.datalab.spectrum-tools"), + ], +) +def test_default_plugin_id_follows_the_github_account( + github_account: str, plugin_id: str +) -> None: + """Only DataLab-Platform projects default to the reserved org.datalab IDs.""" + assert _default_plugin_id("datalab_spectrum_tools", github_account) == plugin_id + + +def test_create_rejects_invalid_github_account(tmp_path: Path) -> None: + """The account becomes part of URLs and of the default plugin ID.""" + destination = tmp_path / "invalid-account" + + with pytest.raises(SystemExit) as exc_info: + main( + [ + "create", + str(destination), + "--name", + "Account Plugin", + "--package", + "datalab_account_plugin", + "--plugin-id", + "org.example.account-plugin", + "--description", + "Invalid account example", + "--github-account", + "alice/lab", + ] + ) + + assert exc_info.value.code == 2 + assert not destination.exists() def test_create_pulse_project_with_long_metadata_passes_checks(tmp_path: Path) -> None: diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst index f1cbffcb3..98169b023 100644 --- a/doc/features/advanced/plugins.rst +++ b/doc/features/advanced/plugins.rst @@ -589,14 +589,13 @@ options for an interactive setup: datalab-plugin create -The command asks for the display name and offers defaults for the Python -package, reverse-domain plugin ID, description, and destination. For scripts or -reproducible setup, pass the values explicitly: +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 \ --name "Camera Characterization" \ + --github-account DataLab-Platform \ --package datalab_camera_characterization \ --plugin-id org.datalab.camera-characterization \ --description "Characterize scientific cameras" \ @@ -638,6 +637,8 @@ them. From the generated directory, install and validate the project with: python -m pytest python -m ruff check . +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`). + How to develop a plugin? ------------------------ diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po index c31c4ad89..fb28bdf34 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po @@ -382,8 +382,8 @@ 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 "The command asks for the display name and offers defaults for the Python package, reverse-domain plugin ID, description, and destination. For scripts or reproducible setup, pass the values explicitly:" -msgstr "La commande demande le nom d'affichage 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. Pour les scripts ou une configuration reproductible, indiquez explicitement les valeurs :" +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 :" msgid "The generated ``src``-layout project applies the architecture validated by the Camera pilot:" msgstr "Le projet généré avec une organisation ``src`` applique l'architecture validée par le pilote Camera :" @@ -397,6 +397,9 @@ msgstr "Le projet comprend également un :class:`datalab.plugins.PluginInfo` sta 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 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`)." +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`)." + msgid "How to develop a plugin?" msgstr "Comment développer un plugin ?" 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 b8b37eae1..7d90ee772 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,12 @@ msgstr "Chaque projet généré comprend un test de non-régression exécutable msgid "Long plugin names and descriptions are now formatted so newly generated projects pass their bundled Ruff checks without manual source edits" msgstr "Les noms et descriptions de plugins longs sont désormais mis en forme afin que les projets nouvellement générés réussissent leurs contrôles Ruff intégrés sans modification manuelle du code source" +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 "**Add metadata:**" msgstr "**Ajout de métadonnées :**" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index 3065a9b3c..065dae3a2 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -53,6 +53,8 @@ * 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 **Add metadata:**