Skip to content

Commit 371b513

Browse files
Merge pull request #390 from DataLab-Platform/feat/catalog-ready-plugin-generator
feat(plugins): generate projects ready for GitHub releases and the catalog
2 parents 72fe6f5 + 0b9152d commit 371b513

7 files changed

Lines changed: 225 additions & 13 deletions

File tree

‎datalab/locale/fr/LC_MESSAGES/datalab.po‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3750,6 +3750,9 @@ msgstr "Le type d'objet doit être 'signal' ou 'image'"
37503750
msgid "The minimal template currently supports BSD-3-Clause"
37513751
msgstr "Le modèle minimal prend actuellement en charge BSD-3-Clause"
37523752

3753+
msgid "GitHub account must be a GitHub user or organization"
3754+
msgstr "Le compte GitHub doit être un utilisateur ou une organisation GitHub"
3755+
37533756
msgid "Plugin name must contain letters or digits"
37543757
msgstr "Le nom du plugin doit contenir des lettres ou des chiffres"
37553758

@@ -3764,6 +3767,9 @@ msgstr "%s est requis"
37643767
msgid "Plugin name"
37653768
msgstr "Nom du plugin"
37663769

3770+
msgid "GitHub account (optional)"
3771+
msgstr "Compte GitHub (facultatif)"
3772+
37673773
msgid "Package name"
37683774
msgstr "Nom du package"
37693775

@@ -3798,6 +3804,9 @@ msgstr "ID stable en notation de domaine inversé"
37983804
msgid "One-line description"
37993805
msgstr "Description sur une ligne"
38003806

3807+
msgid "GitHub user or organization hosting the project"
3808+
msgstr "Utilisateur ou organisation GitHub hébergeant le projet"
3809+
38013810
msgid "Plugin capability; may be repeated"
38023811
msgstr "Capacité du plugin ; l'option peut être répétée"
38033812

‎datalab/plugins/generator.py‎

Lines changed: 129 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@
1212
from collections.abc import Sequence
1313
from pathlib import Path
1414

15+
from datalab import __version__
1516
from datalab.config import _
1617

1718
_PACKAGE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
1819
_PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[.-][a-z0-9]+)+$")
20+
_GITHUB_ACCOUNT_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$")
1921
_CAPABILITIES = ("application", "processing", "io", "visualization")
22+
#: GitHub organization whose plugins use the reserved ``org.datalab.`` IDs
23+
OFFICIAL_GITHUB_ACCOUNT = "DataLab-Platform"
2024

2125

2226
@dataclasses.dataclass(frozen=True)
@@ -31,6 +35,7 @@ class PluginProject:
3135
capabilities: tuple[str, ...] = ("processing",)
3236
object_kind: str = "signal"
3337
license_id: str = "BSD-3-Clause"
38+
github_account: str = ""
3439

3540
def __post_init__(self) -> None:
3641
"""Validate values used as paths, Python identifiers, and metadata."""
@@ -64,6 +69,17 @@ def __post_init__(self) -> None:
6469
raise ValueError(_("Object kind must be 'signal' or 'image'"))
6570
if self.license_id != "BSD-3-Clause":
6671
raise ValueError(_("The minimal template currently supports BSD-3-Clause"))
72+
if self.github_account and not _GITHUB_ACCOUNT_PATTERN.fullmatch(
73+
self.github_account
74+
):
75+
raise ValueError(_("GitHub account must be a GitHub user or organization"))
76+
77+
@property
78+
def repository_url(self) -> str:
79+
"""Return the GitHub repository URL, if the account is known."""
80+
if not self.github_account:
81+
return ""
82+
return f"https://github.com/{self.github_account}/{self.distribution}"
6783

6884
@property
6985
def distribution(self) -> str:
@@ -100,6 +116,15 @@ def _render_python_string_assignment(name: str, value: str) -> str:
100116

101117
def _render_pyproject(project: PluginProject) -> str:
102118
"""Render generated project metadata and plugin entry point."""
119+
major, minor = __version__.split(".")[:2]
120+
requirement = f"datalab-platform >= {major}.{minor}, < {int(major) + 1}"
121+
urls = ""
122+
if project.repository_url:
123+
urls = f"""
124+
[project.urls]
125+
Homepage = "{project.repository_url}"
126+
Issues = "{project.repository_url}/issues"
127+
"""
103128
return f'''[build-system]
104129
requires = ["setuptools >= 77"]
105130
build-backend = "setuptools.build_meta"
@@ -112,8 +137,9 @@ def _render_pyproject(project: PluginProject) -> str:
112137
license = "{project.license_id}"
113138
license-files = ["LICENSE"]
114139
requires-python = ">=3.9"
115-
dependencies = ["datalab-platform >= 1.3"]
116-
140+
dependencies = ["{requirement}"]
141+
keywords = ["datalab", "datalab-plugin"]
142+
{urls}
117143
[project.entry-points."datalab.plugins"]
118144
{project.package} = "{project.package}.adapters.desktop:{project.class_name}"
119145
@@ -389,9 +415,87 @@ def _render_readme(project: PluginProject) -> str:
389415
compose them into headless recipes in `workflow`, and keep DataLab or browser
390416
integration in `adapters`. The generated architecture test preserves these
391417
dependency boundaries as the plugin grows.
418+
419+
## Publishing
420+
421+
Set the new version in `pyproject.toml` and in the package `__init__.py`, then
422+
push a tag such as `v0.1.0`. The **Release** workflow builds the wheel and
423+
attaches it to a GitHub release: DataLab users install it with
424+
**Plugins > Configure plugins... > Install plugins**.
425+
426+
To list the plugin in the DataLab plugin catalog, open a pull request on
427+
<https://github.com/DataLab-Platform/plugins> with the entry printed in the
428+
summary of the **Release** workflow run.
429+
"""
430+
431+
432+
_RELEASE_WORKFLOW = """name: Release
433+
434+
on:
435+
push:
436+
tags: ["v*"]
437+
438+
permissions:
439+
contents: write
440+
441+
jobs:
442+
release:
443+
runs-on: ubuntu-latest
444+
steps:
445+
- uses: actions/checkout@v4
446+
447+
- uses: actions/setup-python@v5
448+
with:
449+
python-version: "3.11"
450+
451+
- run: python -m pip install build
452+
453+
- run: python -m build --wheel
454+
455+
- name: Attach the wheel to a GitHub release
456+
env:
457+
GH_TOKEN: ${{ github.token }}
458+
run: |
459+
wheel=$(ls dist/*-py3-none-any.whl)
460+
case "$wheel" in
461+
*-"${GITHUB_REF_NAME#v}"-py3-none-any.whl) ;;
462+
*) echo "Tag $GITHUB_REF_NAME does not match $wheel" >&2; exit 1 ;;
463+
esac
464+
gh release create "$GITHUB_REF_NAME" "$wheel" --verify-tag --generate-notes
465+
466+
- name: Print the plugin catalog entry
467+
run: |
468+
sha256=$(sha256sum dist/*-py3-none-any.whl | cut -d " " -f 1)
469+
{
470+
echo "To list this release in the DataLab plugin catalog, add"
471+
echo "\\`plugins/__PLUGIN_ID__.yaml\\` to"
472+
echo "https://github.com/DataLab-Platform/plugins"
473+
echo "(or only the new release if the plugin is already listed):"
474+
echo
475+
echo '```yaml'
476+
cat <<'EOF'
477+
id: __PLUGIN_ID__
478+
name: __PLUGIN_NAME__
479+
EOF
480+
echo "repository: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
481+
echo "capabilities: [__CAPABILITIES__]"
482+
echo "releases:"
483+
echo " - version: ${GITHUB_REF_NAME#v}"
484+
echo " sha256: $sha256"
485+
echo '```'
486+
} >> "$GITHUB_STEP_SUMMARY"
392487
"""
393488

394489

490+
def _render_release_workflow(project: PluginProject) -> str:
491+
"""Render the workflow publishing tagged wheels as GitHub releases."""
492+
return (
493+
_RELEASE_WORKFLOW.replace("__PLUGIN_ID__", project.plugin_id)
494+
.replace("__PLUGIN_NAME__", _toml_string(project.name))
495+
.replace("__CAPABILITIES__", ", ".join(project.capabilities))
496+
)
497+
498+
395499
def _render_architecture_doc(project: PluginProject) -> str:
396500
"""Render the generated dependency-boundary documentation."""
397501
return f"""# Architecture
@@ -487,6 +591,7 @@ def create_plugin_project(project: PluginProject) -> Path:
487591
if destination.exists():
488592
raise FileExistsError(_("Destination already exists: %s") % destination)
489593
files = {
594+
".github/workflows/release.yml": _render_release_workflow(project),
490595
".gitignore": _GITIGNORE,
491596
"CHANGELOG.md": _render_changelog(),
492597
"CONTRIBUTING.md": _render_contributing(),
@@ -523,10 +628,14 @@ def _default_package(name: str) -> str:
523628
return slug
524629

525630

526-
def _default_plugin_id(package: str) -> str:
527-
"""Derive a reverse-domain plugin ID from an import package."""
631+
def _default_plugin_id(package: str, github_account: str = "") -> str:
632+
"""Derive a reverse-domain plugin ID from an import package and its owner."""
528633
slug = package.removeprefix("datalab_").replace("_", "-")
529-
return f"org.datalab.{slug}"
634+
if github_account.lower() == OFFICIAL_GITHUB_ACCOUNT.lower():
635+
return f"org.datalab.{slug}"
636+
if github_account:
637+
return f"io.github.{github_account.lower()}.{slug}"
638+
return f"org.example.{slug}"
530639

531640

532641
def _prompt(label: str, default: str | None = None) -> str:
@@ -543,8 +652,17 @@ def _prompt(label: str, default: str | None = None) -> str:
543652
def _project_from_args(args: argparse.Namespace) -> PluginProject:
544653
"""Resolve explicit options and interactive defaults into a project."""
545654
name = args.name or _prompt(_("Plugin name"))
655+
github_account = args.github_account
656+
if github_account is None:
657+
github_account = (
658+
_prompt(_("GitHub account (optional)"), "")
659+
if args.plugin_id is None
660+
else ""
661+
)
546662
package = args.package or _prompt(_("Package name"), _default_package(name))
547-
plugin_id = args.plugin_id or _prompt(_("Plugin ID"), _default_plugin_id(package))
663+
plugin_id = args.plugin_id or _prompt(
664+
_("Plugin ID"), _default_plugin_id(package, github_account)
665+
)
548666
description = args.description or _prompt(
549667
_("Description"), _("%s plugin for DataLab") % name
550668
)
@@ -561,6 +679,7 @@ def _project_from_args(args: argparse.Namespace) -> PluginProject:
561679
capabilities=capabilities,
562680
object_kind=args.object_kind,
563681
license_id=args.license_id,
682+
github_account=github_account,
564683
)
565684

566685

@@ -579,6 +698,10 @@ def build_parser() -> argparse.ArgumentParser:
579698
create_parser.add_argument("--package", help=_("Python import package name"))
580699
create_parser.add_argument("--plugin-id", help=_("Stable reverse-domain ID"))
581700
create_parser.add_argument("--description", help=_("One-line description"))
701+
create_parser.add_argument(
702+
"--github-account",
703+
help=_("GitHub user or organization hosting the project"),
704+
)
582705
create_parser.add_argument(
583706
"--capability",
584707
dest="capabilities",

‎datalab/tests/backbone/plugins/plugin_generator_unit_test.py‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
from pathlib import Path
1212

1313
import pytest
14+
import yaml
1415

16+
from datalab import __version__
1517
from datalab.plugins import PluginCapability, PluginRegistry
16-
from datalab.plugins.generator import main
18+
from datalab.plugins.generator import _default_plugin_id, main
1719

1820
PROJECT_ROOT = Path(__file__).parents[4]
1921

@@ -66,13 +68,16 @@ def test_create_layered_plugin_project(
6668
"processing",
6769
"--object-kind",
6870
"image",
71+
"--github-account",
72+
"DataLab-Platform",
6973
]
7074
)
7175
== 0
7276
)
7377
assert str(destination.resolve()) in capsys.readouterr().out
7478

7579
expected_files = {
80+
".github/workflows/release.yml",
7681
".gitignore",
7782
"CHANGELOG.md",
7883
"CONTRIBUTING.md",
@@ -106,6 +111,15 @@ def test_create_layered_plugin_project(
106111
assert 'name = "datalab-camera-characterization"' in pyproject
107112
assert 'license = "BSD-3-Clause"' in pyproject
108113
assert 'license-files = ["LICENSE"]' in pyproject
114+
major, minor = __version__.split(".")[:2]
115+
assert (
116+
f'dependencies = ["datalab-platform >= {major}.{minor}, < {int(major) + 1}"]'
117+
in pyproject
118+
)
119+
assert 'keywords = ["datalab", "datalab-plugin"]' in pyproject
120+
repository = "https://github.com/DataLab-Platform/datalab-camera-characterization"
121+
assert f'Homepage = "{repository}"' in pyproject
122+
assert f'Issues = "{repository}/issues"' in pyproject
109123
assert '[project.entry-points."datalab.plugins"]' in pyproject
110124
assert (
111125
"datalab_camera_characterization = "
@@ -115,6 +129,15 @@ def test_create_layered_plugin_project(
115129

116130
_run_generated_project_checks(destination)
117131

132+
release = (destination / ".github/workflows/release.yml").read_text(
133+
encoding="utf-8"
134+
)
135+
steps = yaml.safe_load(release)["jobs"]["release"]["steps"]
136+
assert "gh release create" in steps[4]["run"]
137+
assert "id: org.datalab.camera-characterization\n" in steps[5]["run"]
138+
assert 'name: "Camera Characterization"\n' in steps[5]["run"]
139+
assert "capabilities: [application, processing]" in steps[5]["run"]
140+
118141
web_adapter = (
119142
destination / "src/datalab_camera_characterization/adapters/web.py"
120143
).read_text(encoding="utf-8")
@@ -294,7 +317,7 @@ def test_create_interactive_defaults(
294317
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
295318
) -> None:
296319
"""The bare create command infers safe defaults from the plugin name."""
297-
answers = iter(("Pulse Characterization", "", "", "", ""))
320+
answers = iter(("Pulse Characterization", "DataLab-Platform", "", "", "", ""))
298321
monkeypatch.setattr("builtins.input", lambda _prompt: next(answers))
299322
monkeypatch.chdir(tmp_path)
300323

@@ -312,6 +335,51 @@ def test_create_interactive_defaults(
312335
assert 'PLUGIN_ID = "org.datalab.pulse-characterization"' in package
313336
assert 'PLUGIN_DESCRIPTION = "Pulse Characterization plugin for DataLab"' in package
314337
assert "id=PLUGIN_ID" in plugin
338+
assert (
339+
'Homepage = "https://github.com/DataLab-Platform/'
340+
'datalab-pulse-characterization"' in pyproject
341+
)
342+
343+
344+
@pytest.mark.parametrize(
345+
("github_account", "plugin_id"),
346+
[
347+
("", "org.example.spectrum-tools"),
348+
("Alice-Lab", "io.github.alice-lab.spectrum-tools"),
349+
("datalab-platform", "org.datalab.spectrum-tools"),
350+
],
351+
)
352+
def test_default_plugin_id_follows_the_github_account(
353+
github_account: str, plugin_id: str
354+
) -> None:
355+
"""Only DataLab-Platform projects default to the reserved org.datalab IDs."""
356+
assert _default_plugin_id("datalab_spectrum_tools", github_account) == plugin_id
357+
358+
359+
def test_create_rejects_invalid_github_account(tmp_path: Path) -> None:
360+
"""The account becomes part of URLs and of the default plugin ID."""
361+
destination = tmp_path / "invalid-account"
362+
363+
with pytest.raises(SystemExit) as exc_info:
364+
main(
365+
[
366+
"create",
367+
str(destination),
368+
"--name",
369+
"Account Plugin",
370+
"--package",
371+
"datalab_account_plugin",
372+
"--plugin-id",
373+
"org.example.account-plugin",
374+
"--description",
375+
"Invalid account example",
376+
"--github-account",
377+
"alice/lab",
378+
]
379+
)
380+
381+
assert exc_info.value.code == 2
382+
assert not destination.exists()
315383

316384

317385
def test_create_pulse_project_with_long_metadata_passes_checks(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)