1212from collections .abc import Sequence
1313from pathlib import Path
1414
15+ from datalab import __version__
1516from 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
101117def _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]
104129requires = ["setuptools >= 77"]
105130build-backend = "setuptools.build_meta"
@@ -112,8 +137,9 @@ def _render_pyproject(project: PluginProject) -> str:
112137license = "{ project .license_id } "
113138license-files = ["LICENSE"]
114139requires-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:
389415compose them into headless recipes in `workflow`, and keep DataLab or browser
390416integration in `adapters`. The generated architecture test preserves these
391417dependency 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+
395499def _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
532641def _prompt (label : str , default : str | None = None ) -> str :
@@ -543,8 +652,17 @@ def _prompt(label: str, default: str | None = None) -> str:
543652def _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" ,
0 commit comments