Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions datalab/locale/fr/LC_MESSAGES/datalab.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"

Expand Down Expand Up @@ -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"

Expand Down
135 changes: 129 additions & 6 deletions datalab/plugins/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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}"

Expand Down Expand Up @@ -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
<https://github.com/DataLab-Platform/plugins> 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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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:
Expand All @@ -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
)
Expand All @@ -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,
)


Expand All @@ -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",
Expand Down
72 changes: 70 additions & 2 deletions datalab/tests/backbone/plugins/plugin_generator_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -66,13 +68,16 @@ def test_create_layered_plugin_project(
"processing",
"--object-kind",
"image",
"--github-account",
"DataLab-Platform",
]
)
== 0
)
assert str(destination.resolve()) in capsys.readouterr().out

expected_files = {
".github/workflows/release.yml",
".gitignore",
"CHANGELOG.md",
"CONTRIBUTING.md",
Expand Down Expand Up @@ -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 = "
Expand All @@ -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")
Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down
Loading
Loading