Skip to content
Closed
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ DataDesignerPlugins/
└── docs/ # Authoring guide, plugin catalog
```

Each plugin is an independent Python package with its own `pyproject.toml`, tests, and CODEOWNERS. The root workspace auto-discovers plugins via `plugins/*`.
Each plugin is an independent Python package with its own `pyproject.toml`, tests, and CODEOWNERS. The root workspace auto-discovers plugin packages via `plugins/*`. A package can provide one or more Data Designer plugins through the `data_designer.plugins` group.

## Development

Expand Down
236 changes: 217 additions & 19 deletions devtools/ddp/src/ddp/catalog.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,70 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Generate a markdown plugin catalog from plugin metadata."""
"""Generate a markdown plugin catalog from package metadata and plugin objects."""

from __future__ import annotations

import importlib.metadata
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from ddp._repo import find_repo_root, load_toml

PLUGIN_ENTRY_POINT_GROUP = "data_designer.plugins"


class CatalogError(RuntimeError):
"""Raised when a catalog entry cannot be generated."""


@dataclass(frozen=True)
class CatalogRow:
"""One rendered row in the plugin catalog.

Attributes:
plugin_package: Python package name from ``[project].name``.
version: Package version from ``[project].version``.
name: Runtime DataDesigner plugin name.
plugin_type: Runtime DataDesigner plugin type value.
description: Package description from ``[project].description``.
"""

plugin_package: str
version: str
name: str
plugin_type: str
description: str


def main() -> None:
"""Generate a markdown table of all plugins and print to stdout."""
repo_root = find_repo_root()
plugins_dir = repo_root / "plugins"
"""Generate a markdown table of all plugin entry points and print to stdout."""
try:
rows = discover_catalog_rows(find_repo_root() / "plugins")
except CatalogError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
sys.exit(1)

print(render_catalog(rows))


def discover_catalog_rows(plugins_dir: Path) -> list[CatalogRow]:
"""Discover catalog rows for local plugin packages.

rows: list[tuple[str, str, str, str]] = []
Args:
plugins_dir: Repository ``plugins/`` directory.

Returns:
Rows sorted by package name, then runtime plugin name.

Raises:
CatalogError: If a local entry point is not installed, cannot be loaded,
or does not load to a DataDesigner ``Plugin`` object.
"""
rows: list[CatalogRow] = []
for toml_path in sorted(plugins_dir.glob("*/pyproject.toml")):
data = load_toml(toml_path)

Expand All @@ -23,29 +73,177 @@ def main() -> None:
version = project.get("version", "unknown")
description = project.get("description", "")

entry_points = project.get("entry-points", {}).get("data_designer.plugins", {})
entry_points = project.get("entry-points", {}).get(PLUGIN_ENTRY_POINT_GROUP, {})
for entry_point_name in sorted(entry_points):
rows.append(catalog_row_for_entry_point(name, version, description, entry_point_name))

return sorted(rows, key=lambda row: (row.plugin_package, row.name))


def catalog_row_for_entry_point(
package_name: str,
version: str,
description: str,
entry_point_name: str,
) -> CatalogRow:
"""Build a catalog row from an installed DataDesigner plugin entry point.

Args:
package_name: Local plugin package name.
version: Local plugin package version.
description: Local plugin package description.
entry_point_name: Entry point name in the ``data_designer.plugins`` group.

Returns:
Catalog row with runtime plugin metadata.

Raises:
CatalogError: If plugin metadata cannot be loaded or read.
"""
plugin = load_plugin_from_entry_point(package_name, entry_point_name)
try:
plugin_name = plugin.name
plugin_type = plugin.plugin_type.value
except Exception as exc:
raise CatalogError(
f"could not read runtime metadata for package {package_name!r} entry point {entry_point_name!r}: {exc}"
) from exc

if not isinstance(plugin_name, str) or not plugin_name:
raise CatalogError(
f"package {package_name!r} entry point {entry_point_name!r} has invalid plugin.name {plugin_name!r}"
)
if not isinstance(plugin_type, str) or not plugin_type:
raise CatalogError(
f"package {package_name!r} entry point {entry_point_name!r} has invalid plugin.plugin_type.value "
f"{plugin_type!r}"
)

return CatalogRow(
plugin_package=package_name,
version=version,
name=plugin_name,
plugin_type=plugin_type,
description=description,
)


def load_plugin_from_entry_point(package_name: str, entry_point_name: str) -> Any:
"""Load and validate an installed DataDesigner plugin entry point.

Args:
package_name: Local plugin package name.
entry_point_name: Entry point name in the ``data_designer.plugins`` group.

Returns:
Loaded DataDesigner ``Plugin`` object.

Raises:
CatalogError: If the entry point is missing, fails to load, or returns
a non-``Plugin`` object.
"""
try:
from data_designer.plugins.plugin import Plugin
except Exception as exc:
raise CatalogError(
f"could not import DataDesigner Plugin while loading package {package_name!r} "
f"entry point {entry_point_name!r}: {exc}"
) from exc

entry_point = find_installed_entry_point(package_name, entry_point_name)
try:
plugin = entry_point.load()
except Exception as exc:
raise CatalogError(f"could not load package {package_name!r} entry point {entry_point_name!r}: {exc}") from exc

if not isinstance(plugin, Plugin):
raise CatalogError(
f"package {package_name!r} entry point {entry_point_name!r} loaded {type(plugin).__name__}, "
"expected data_designer.plugins.plugin.Plugin"
)
return plugin


def find_installed_entry_point(package_name: str, entry_point_name: str) -> importlib.metadata.EntryPoint:
"""Find an installed entry point owned by a local package.

Args:
package_name: Local plugin package name.
entry_point_name: Entry point name in the ``data_designer.plugins`` group.

Returns:
Matching installed entry point.

Raises:
CatalogError: If no installed entry point matches the package and name.
"""
normalized_package_name = normalize_distribution_name(package_name)
for entry_point in importlib.metadata.entry_points(group=PLUGIN_ENTRY_POINT_GROUP):
distribution_name = entry_point_distribution_name(entry_point)
if distribution_name is None:
continue
if (
normalize_distribution_name(distribution_name) == normalized_package_name
and entry_point.name == entry_point_name
):
return entry_point

raise CatalogError(
f"package {package_name!r} entry point {entry_point_name!r} is not installed; "
"run `make sync` before regenerating the catalog"
)


def entry_point_distribution_name(entry_point: importlib.metadata.EntryPoint) -> str | None:
"""Return the distribution name that owns an entry point.

Args:
entry_point: Installed entry point.

Returns:
Owning distribution name, or ``None`` if it cannot be determined.
"""
distribution = getattr(entry_point, "dist", None)
if distribution is None:
return None
return distribution.metadata.get("Name")


def normalize_distribution_name(name: str) -> str:
"""Normalize a Python distribution name for comparison.

Args:
name: Distribution name.

Returns:
PEP 503-style normalized distribution name.
"""
return re.sub(r"[-_.]+", "-", name).lower()


if entry_points:
for ep_key in sorted(entry_points):
rows.append((name, version, ep_key, description))
else:
rows.append((name, version, "", description))
def render_catalog(rows: list[CatalogRow]) -> str:
"""Render catalog rows as a markdown table.

rows.sort(key=lambda r: (r[0], r[2]))
Args:
rows: Catalog rows to render.

Returns:
Markdown catalog content.
"""
lines = [
"# Plugin Catalog",
"",
"Auto-generated from plugin metadata. Do not edit manually.",
"Auto-generated from installed local DataDesigner plugins and package metadata. Do not edit manually.",
"",
"| Plugin | Version | Column Type | Description |",
"|--------|---------|-------------|-------------|",
"| Plugin Package | Version | Name | Type | Description |",
"|----------------|---------|------|------|-------------|",
]
for name, version, column_type, description in rows:
ct = f"`{column_type}`" if column_type else ""
lines.append(f"| {name} | {version} | {ct} | {description} |")
for row in rows:
lines.append(
f"| {row.plugin_package} | {row.version} | `{row.name}` | `{row.plugin_type}` | {row.description} |"
)

print("\n".join(lines))
return "\n".join(lines)


if __name__ == "__main__":
Expand Down
4 changes: 2 additions & 2 deletions devtools/ddp/src/ddp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ def build_parser() -> argparse.ArgumentParser:
"catalog",
help="Generate plugin catalog to stdout",
description=(
"Generate a markdown table of all plugins and their metadata "
"(name, version, column type, description) to stdout. "
"Generate a markdown table of local DataDesigner plugins and package metadata "
"(package, version, name, type, description) to stdout. "
"Typically redirected to docs/catalog.md."
),
)
Expand Down
Loading
Loading