diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index fd51071..2ee336a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,7 +8,8 @@ - [ ] `assert_valid_plugin(plugin)` passes - [ ] Unit tests included and passing (`make test-plugin PLUGIN=`) - [ ] Plugin installs standalone (`uv pip install -e plugins/`) -- [ ] `docs/catalog.md` regenerated if plugin list or metadata changed (`make catalog`) +- [ ] Plugin docs regenerated if plugin docs, list, or metadata changed (`make plugin-docs`) +- [ ] Documentation builds if docs changed (`make docs`) - [ ] `.github/CODEOWNERS` regenerated if ownership changed (`make codeowners`) - [ ] Per-plugin `CODEOWNERS` file included (auto-created by `ddp new`) - [ ] NVIDIA SPDX headers on all files (`make check-license-headers`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bd6d8f..f55a23d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,7 @@ jobs: with: name: generated-metadata path: | - docs/catalog.md.new + docs/plugins/** + zensical.toml .github/CODEOWNERS.new if-no-files-found: ignore diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..9819efe --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Documentation + +on: + push: + branches: [main] + pull_request: + paths: + - ".github/workflows/docs.yml" + - "Makefile" + - "devtools/ddp/src/ddp/plugin_docs.py" + - "docs/**" + - "plugins/**/docs/**" + - "pyproject.toml" + - "uv.lock" + - "zensical.toml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/configure-pages@v5 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + - uses: astral-sh/setup-uv@v4 + with: + python-version: "3.10" + enable-cache: true + + - run: make sync + - run: make docs + + - uses: actions/upload-pages-artifact@v4 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + with: + path: site + + - uses: actions/upload-artifact@v4 + if: github.event_name == 'pull_request' + with: + name: docs-site + path: site + if-no-files-found: error + + deploy: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.gitignore b/.gitignore index b2f39c4..e841089 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ Thumbs.db .coverage htmlcov/ .mypy_cache/ +site/ # Distribution *.tar.gz diff --git a/Makefile b/Makefile index 1270a11..ba00d36 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -.PHONY: sync lint format test validate catalog codeowners check-catalog check-codeowners check-license-headers update-license-headers check all bump release build-plugin validate-release test-plugin check-owner +.PHONY: sync lint format test validate docs docs-server plugin-docs check-plugin-docs codeowners check-codeowners check-license-headers update-license-headers check all bump release build-plugin validate-release test-plugin check-owner # ── Setup ──────────────────────────────────────────────────────────────── @@ -32,7 +32,7 @@ test: continue; \ fi; \ echo "── Testing $$plugin_name (isolated) ──"; \ - uv venv ".venv-$$plugin_name"; \ + uv venv --clear ".venv-$$plugin_name"; \ . ".venv-$$plugin_name/bin/activate"; \ uv pip install -e "$$plugin_dir"; \ uv pip install pytest; \ @@ -47,18 +47,26 @@ test: validate: uv run ddp validate -# ── Catalog & CODEOWNERS ───────────────────────────────────────────────── +# ── Documentation ─────────────────────────────────────────────────────── -catalog: - uv run ddp catalog > docs/catalog.md +docs: plugin-docs + uv run zensical build --clean --strict + +DOCS_DEV_ADDR ?= localhost:8000 + +docs-server: plugin-docs + uv run zensical serve --dev-addr $(DOCS_DEV_ADDR) + +# ── Plugin docs & CODEOWNERS ────────────────────────────────────────────── + +plugin-docs: + uv run ddp plugin-docs codeowners: uv run ddp codeowners > .github/CODEOWNERS -check-catalog: - uv run ddp catalog > docs/catalog.md.new - diff docs/catalog.md docs/catalog.md.new - @rm -f docs/catalog.md.new +check-plugin-docs: + uv run ddp plugin-docs --check check-codeowners: uv run ddp codeowners > .github/CODEOWNERS.new @@ -73,9 +81,9 @@ update-license-headers: # ── Aggregate targets ──────────────────────────────────────────────────── -check: check-catalog check-codeowners check-license-headers +check: check-plugin-docs check-codeowners check-license-headers -all: lint test validate check +all: lint test validate check docs # ── Release ───────────────────────────────────────────────────────────── # Usage: make release PLUGIN=data-designer-template @@ -96,7 +104,7 @@ validate-release: test-plugin: @if [ -z "$(PLUGIN)" ]; then echo "ERROR: Set PLUGIN="; exit 1; fi - uv venv ".venv-$(PLUGIN)" + uv venv --clear ".venv-$(PLUGIN)" . ".venv-$(PLUGIN)/bin/activate" && \ uv pip install -e "$(PLUGIN_DIR)" && \ uv pip install pytest && \ diff --git a/README.md b/README.md index fb2948f..688c473 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🔌 🎨 NeMo Data Designer Plugins +# NeMo Data Designer Plugins First-class NVIDIA-provided plugins for [NeMo Data Designer](https://github.com/NVIDIA-NeMo/DataDesigner). @@ -16,20 +16,20 @@ Create a new plugin: uv run ddp new my-plugin ``` -This generates a complete plugin skeleton under `plugins/data-designer-my-plugin/` with config, implementation, entry point, tests, and CODEOWNERS. See [docs/adding-a-plugin.md](docs/adding-a-plugin.md) for the full authoring guide. +This generates a complete plugin skeleton under `plugins/data-designer-my-plugin/` with config, implementation, entry point, docs, tests, and CODEOWNERS. See [docs/authoring.md](docs/authoring.md) for the full authoring guide. ## Repository Structure ``` DataDesignerPlugins/ -├── devtools/ -│ └── ddp/ # Monorepo management tooling (ddp CLI, dev-only) -├── plugins/ # One directory per plugin (auto-discovered by uv) -│ └── data-designer-template/ # Reference implementation -└── docs/ # Authoring guide, plugin catalog +|-- devtools/ +| `-- ddp/ # Monorepo management tooling (ddp CLI, dev-only) +|-- plugins/ # One directory per plugin (auto-discovered by uv) +| `-- data-designer-template/ # Reference implementation +`-- docs/ # Zensical documentation source ``` -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`, docs, tests, and CODEOWNERS. The root workspace auto-discovers plugins via `plugins/*`. ## Development @@ -41,8 +41,11 @@ make lint # Lint and format check (ruff) make format # Auto-fix lint issues and reformat make test # Test each plugin in an isolated venv make validate # Run assert_valid_plugin on all entry points -make check # Verify catalog, CODEOWNERS, and license headers are up to date -make all # lint + test + validate + check (full local CI) +make check # Verify generated plugin docs, CODEOWNERS, and license headers are up to date +make plugin-docs # Regenerate docs/plugins/ from per-plugin docs and metadata +make docs # Build the Zensical documentation site +make docs-server # Serve docs locally at http://localhost:8000 +make all # lint + test + validate + check + docs (full local CI) ``` To test a single plugin in isolation: @@ -51,10 +54,10 @@ To test a single plugin in isolation: make test-plugin PLUGIN=data-designer-my-plugin ``` -If you change plugin metadata or ownership, regenerate derived files: +If you change plugin docs, plugin metadata, or ownership, regenerate derived files: ```bash -make catalog # Regenerate docs/catalog.md +make plugin-docs # Regenerate plugin documentation site inputs make codeowners # Regenerate CODEOWNERS make update-license-headers # Fix SPDX headers ``` @@ -67,7 +70,7 @@ The `ddp` command manages the monorepo. Run `uv run ddp --help` to see all subco |---------|-------------| | `ddp new ` | Scaffold a new plugin | | `ddp validate` | Validate all installed plugins | -| `ddp catalog` | Generate plugin catalog to stdout | +| `ddp plugin-docs` | Generate plugin docs site inputs | | `ddp codeowners` | Aggregate CODEOWNERS to stdout | | `ddp license-headers` | Add or check SPDX license headers | | `ddp bump ` | Bump a plugin's semantic version | @@ -83,7 +86,7 @@ make release PLUGIN=data-designer-my-plugin # Tag + build git push origin data-designer-my-plugin/v0.1.1 # Triggers CI publish ``` -See [docs/adding-a-plugin.md](docs/adding-a-plugin.md) for the full release guide. +See [docs/releasing.md](docs/releasing.md) for the full release guide. ## License diff --git a/devtools/ddp/src/ddp/__init__.py b/devtools/ddp/src/ddp/__init__.py index 809b23b..a2aa58e 100644 --- a/devtools/ddp/src/ddp/__init__.py +++ b/devtools/ddp/src/ddp/__init__.py @@ -4,6 +4,6 @@ """Local dev-only monorepo management tooling for data-designer-plugins. This package is NOT a runtime dependency of any plugin. It provides CLI -tools for scaffolding, validation, catalog generation, CODEOWNERS +tools for scaffolding, validation, plugin docs generation, CODEOWNERS aggregation, and license-header management across the monorepo. """ diff --git a/devtools/ddp/src/ddp/catalog.py b/devtools/ddp/src/ddp/catalog.py deleted file mode 100644 index 11943ed..0000000 --- a/devtools/ddp/src/ddp/catalog.py +++ /dev/null @@ -1,52 +0,0 @@ -# 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.""" - -from __future__ import annotations - -from ddp._repo import find_repo_root, load_toml - - -def main() -> None: - """Generate a markdown table of all plugins and print to stdout.""" - repo_root = find_repo_root() - plugins_dir = repo_root / "plugins" - - rows: list[tuple[str, str, str, str]] = [] - - for toml_path in sorted(plugins_dir.glob("*/pyproject.toml")): - data = load_toml(toml_path) - - project = data.get("project", {}) - name = project.get("name", toml_path.parent.name) - version = project.get("version", "unknown") - description = project.get("description", "") - - entry_points = project.get("entry-points", {}).get("data_designer.plugins", {}) - - if entry_points: - for ep_key in sorted(entry_points): - rows.append((name, version, ep_key, description)) - else: - rows.append((name, version, "", description)) - - rows.sort(key=lambda r: (r[0], r[2])) - - lines = [ - "# Plugin Catalog", - "", - "Auto-generated from plugin metadata. Do not edit manually.", - "", - "| Plugin | Version | Column 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} |") - - print("\n".join(lines)) - - -if __name__ == "__main__": - main() diff --git a/devtools/ddp/src/ddp/cli.py b/devtools/ddp/src/ddp/cli.py index 8af3319..cb7861c 100644 --- a/devtools/ddp/src/ddp/cli.py +++ b/devtools/ddp/src/ddp/cli.py @@ -7,6 +7,7 @@ ddp --help # List all subcommands ddp new my-plugin # Scaffold a new plugin + ddp plugin-docs # Generate plugin documentation pages ddp validate # Validate all installed plugins ddp bump patch # Bump a plugin version """ @@ -42,17 +43,21 @@ def build_parser() -> argparse.ArgumentParser: p_new.add_argument("name", help="Plugin name in kebab-case (e.g., my-cool-thing)") p_new.set_defaults(func=_run_new) - # ddp catalog - p_catalog = sub.add_parser( - "catalog", - help="Generate plugin catalog to stdout", + # ddp plugin-docs + p_plugin_docs = sub.add_parser( + "plugin-docs", + help="Generate plugin documentation pages", description=( - "Generate a markdown table of all plugins and their metadata " - "(name, version, column type, description) to stdout. " - "Typically redirected to docs/catalog.md." + "Generate docs/plugins/ from each plugin's docs/ directory and " + "package metadata, then update the generated plugin navigation in zensical.toml." ), ) - p_catalog.set_defaults(func=_run_catalog) + p_plugin_docs.add_argument( + "--check", + action="store_true", + help="Check generated plugin docs without modifying files", + ) + p_plugin_docs.set_defaults(func=_run_plugin_docs) # ddp codeowners p_codeowners = sub.add_parser( @@ -136,11 +141,11 @@ def _run_new(args: argparse.Namespace) -> int: return 0 -def _run_catalog(args: argparse.Namespace) -> int: - from ddp.catalog import main as catalog_main +def _run_plugin_docs(args: argparse.Namespace) -> int: + from ddp.plugin_docs import main as plugin_docs_main - catalog_main() - return 0 + argv = ["--check"] if args.check else [] + return plugin_docs_main(argv) def _run_codeowners(args: argparse.Namespace) -> int: diff --git a/devtools/ddp/src/ddp/plugin_docs.py b/devtools/ddp/src/ddp/plugin_docs.py new file mode 100644 index 0000000..c18fb76 --- /dev/null +++ b/devtools/ddp/src/ddp/plugin_docs.py @@ -0,0 +1,591 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Generate top-level documentation pages from per-plugin documentation.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import sys +import tempfile +from dataclasses import dataclass +from html import escape +from pathlib import Path + +from ddp._repo import find_repo_root, load_toml + +GENERATED_NOTICE = "" +NAV_START_TOKEN = "# BEGIN GENERATED PLUGIN DOCS NAV" +NAV_END_TOKEN = "# END GENERATED PLUGIN DOCS NAV" + + +@dataclass(frozen=True) +class PluginDocs: + """Metadata needed to publish one plugin's documentation. + + Args: + package_name: Python distribution name from the plugin pyproject. + docs_slug: URL-safe directory name used under ``docs/plugins``. + version: Package version from plugin metadata. + description: Package description from plugin metadata. + column_types: Data Designer entry point keys exposed by the plugin. + plugin_dir: Source plugin package directory. + source_docs_dir: Optional source documentation directory. + """ + + package_name: str + docs_slug: str + version: str + description: str + column_types: tuple[str, ...] + plugin_dir: Path + source_docs_dir: Path | None + + +@dataclass(frozen=True) +class NavPage: + """One generated Zensical navigation page entry. + + Args: + title: Navigation title. + path: Markdown path relative to the top-level docs directory. + """ + + title: str + path: str + + +def normalize_docs_slug(package_name: str) -> str: + """Normalize a package name into a stable docs URL path segment. + + Args: + package_name: Python package distribution name. + + Returns: + URL-safe slug suitable for ``docs/plugins/``. + """ + slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", package_name.lower()).strip("-") + return slug or "plugin" + + +def discover_plugin_docs(repo_root: Path) -> list[PluginDocs]: + """Discover plugin metadata and optional documentation source directories. + + Args: + repo_root: Repository root containing ``plugins/``. + + Returns: + Sorted plugin documentation metadata. + """ + plugins_dir = repo_root / "plugins" + plugins: list[PluginDocs] = [] + seen_slugs: set[str] = set() + + for toml_path in sorted(plugins_dir.glob("*/pyproject.toml")): + data = load_toml(toml_path) + project = data.get("project", {}) + package_name = str(project.get("name", toml_path.parent.name)) + docs_slug = normalize_docs_slug(package_name) + if docs_slug in seen_slugs: + raise ValueError(f"Duplicate generated plugin docs slug: {docs_slug}") + seen_slugs.add(docs_slug) + + entry_points = project.get("entry-points", {}).get("data_designer.plugins", {}) + source_docs_dir = toml_path.parent / "docs" + plugins.append( + PluginDocs( + package_name=package_name, + docs_slug=docs_slug, + version=str(project.get("version", "unknown")), + description=str(project.get("description", "")), + column_types=tuple(sorted(str(key) for key in entry_points)), + plugin_dir=toml_path.parent, + source_docs_dir=source_docs_dir if source_docs_dir.is_dir() else None, + ) + ) + + return sorted(plugins, key=lambda plugin: plugin.package_name) + + +def format_markdown_code_list(values: tuple[str, ...]) -> str: + """Format values as a comma-separated Markdown code list. + + Args: + values: Values to format. + + Returns: + Markdown inline code list, or a fallback sentence when empty. + """ + if not values: + return "None declared" + return ", ".join(f"`{value}`" for value in values) + + +def format_html_code_list(values: tuple[str, ...]) -> str: + """Format values as a comma-separated HTML code list. + + Args: + values: Values to format. + + Returns: + HTML inline code list, or a fallback sentence when empty. + """ + if not values: + return "No entry points" + return ", ".join(f"{escape(value)}" for value in values) + + +def format_html_chips(values: tuple[str, ...]) -> str: + """Format values as card badge chips. + + Args: + values: Values to format. + + Returns: + HTML span chips, or a fallback chip when empty. + """ + if not values: + return 'No entry points' + return "".join(f'{escape(value)}' for value in values) + + +def render_plugins_index(plugins: list[PluginDocs], repo_root: Path) -> str: + """Render the generated plugin documentation landing page. + + Args: + plugins: Discovered plugin documentation metadata. + repo_root: Repository root used to format source paths. + + Returns: + Markdown page content. + """ + lines = [ + GENERATED_NOTICE, + "", + "# Plugins", + "", + "Browse available Data Designer plugins by what they add to your data generation workflow.", + "", + ] + + if not plugins: + lines.append("No plugins were discovered under `plugins/`.") + return "\n".join(lines) + "\n" + + lines.append('") + return "\n".join(lines) + "\n" + + +def render_fallback_plugin_page(plugin: PluginDocs, repo_root: Path) -> str: + """Render a metadata fallback page for a plugin without docs content. + + Args: + plugin: Plugin metadata. + repo_root: Repository root. + + Returns: + Markdown page content. + """ + source_path = plugin.plugin_dir.relative_to(repo_root).as_posix() + lines = [ + GENERATED_NOTICE, + "", + f"# {plugin.package_name}", + "", + ] + if plugin.description: + lines.extend([plugin.description, ""]) + + lines.extend( + [ + "This page is generated from package metadata because the plugin has not added", + f"custom documentation under `{source_path}/docs/` yet.", + "", + "## Metadata", + "", + f"- Version: `{plugin.version}`", + f"- Column types: {format_markdown_code_list(plugin.column_types)}", + "", + "## Installation", + "", + "```bash", + f"pip install {plugin.package_name}", + "```", + "", + ] + ) + return "\n".join(lines) + + +def should_copy_doc_file(path: Path) -> bool: + """Return whether a plugin documentation file should be copied. + + Args: + path: Source file path. + + Returns: + True when the file should be included in generated docs. + """ + ignored_parts = {"__pycache__", ".pytest_cache"} + return not any(part in ignored_parts or part.startswith(".") for part in path.parts) + + +def destination_doc_relative_path(source_path: Path, source_root: Path, has_index: bool) -> Path: + """Map a plugin docs source file to its generated relative path. + + Args: + source_path: Source documentation file. + source_root: Root plugin documentation directory. + has_index: Whether the plugin source already contains ``index.md``. + + Returns: + Relative path under ``docs/plugins/``. + """ + relative_path = source_path.relative_to(source_root) + if relative_path == Path("README.md") and not has_index: + return Path("index.md") + return relative_path + + +def copy_plugin_docs(plugin: PluginDocs, destination_dir: Path) -> None: + """Copy a plugin's authored documentation into the generated docs tree. + + Args: + plugin: Plugin metadata and source docs location. + destination_dir: Generated docs directory for this plugin. + """ + if plugin.source_docs_dir is None: + return + + has_index = (plugin.source_docs_dir / "index.md").is_file() + for source_path in sorted(plugin.source_docs_dir.rglob("*")): + if not source_path.is_file() or not should_copy_doc_file(source_path.relative_to(plugin.source_docs_dir)): + continue + relative_path = destination_doc_relative_path(source_path, plugin.source_docs_dir, has_index) + target_path = destination_dir / relative_path + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, target_path) + + +def write_text(path: Path, content: str) -> None: + """Write UTF-8 text after ensuring the parent directory exists. + + Args: + path: Destination file path. + content: File content. + """ + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def build_plugin_docs_tree(repo_root: Path, plugins: list[PluginDocs], destination_dir: Path) -> None: + """Build the generated ``docs/plugins`` tree. + + Args: + repo_root: Repository root. + plugins: Discovered plugin documentation metadata. + destination_dir: Generated plugin docs output directory. + """ + destination_dir.mkdir(parents=True, exist_ok=True) + write_text(destination_dir / "index.md", render_plugins_index(plugins, repo_root)) + + for plugin in plugins: + plugin_destination = destination_dir / plugin.docs_slug + plugin_destination.mkdir(parents=True, exist_ok=True) + copy_plugin_docs(plugin, plugin_destination) + if not (plugin_destination / "index.md").is_file(): + write_text(plugin_destination / "index.md", render_fallback_plugin_page(plugin, repo_root)) + + +def markdown_title_fallback(relative_path: Path) -> str: + """Create a readable title from a Markdown relative path. + + Args: + relative_path: Markdown path relative to a plugin docs root. + + Returns: + Human-readable fallback title. + """ + if relative_path.name in {"index.md", "README.md"}: + return "Overview" + name = relative_path.with_suffix("").as_posix().replace("/", " / ") + words = re.split(r"[-_ ]+", name) + return " ".join(word.capitalize() for word in words if word) + + +def extract_markdown_title(path: Path, fallback: str) -> str: + """Extract the first H1 title from a Markdown file. + + Args: + path: Markdown source path. + fallback: Title to use when no H1 is present. + + Returns: + Extracted or fallback title. + """ + if path.name in {"index.md", "README.md"}: + return "Overview" + + try: + lines = path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + return fallback + + for line in lines: + stripped = line.strip() + if stripped.startswith("# "): + title = stripped[2:].strip().strip("#").strip() + if title: + return title + return fallback + + +def collect_plugin_nav_pages(plugin_docs_dir: Path, docs_root: Path) -> list[NavPage]: + """Collect generated Markdown pages for one plugin's nav section. + + Args: + plugin_docs_dir: Generated docs directory for a plugin. + docs_root: Top-level docs directory. + + Returns: + Zensical navigation pages sorted with index pages first. + """ + pages: list[tuple[tuple[int, str], NavPage]] = [] + for page_path in sorted(plugin_docs_dir.rglob("*.md")): + relative_path = page_path.relative_to(plugin_docs_dir) + fallback = markdown_title_fallback(relative_path) + title = extract_markdown_title(page_path, fallback) + docs_path = page_path.relative_to(docs_root).as_posix() + sort_key = (0 if relative_path.name in {"index.md", "README.md"} else 1, relative_path.as_posix()) + pages.append((sort_key, NavPage(title=title, path=docs_path))) + return [page for _, page in sorted(pages, key=lambda item: item[0])] + + +def toml_string(value: str) -> str: + """Format a string for use in generated TOML. + + Args: + value: String value. + + Returns: + Double-quoted TOML-compatible string. + """ + return json.dumps(value) + + +def render_plugin_nav_entries(plugin_docs_root: Path, docs_root: Path, plugins: list[PluginDocs]) -> list[str]: + """Render plugin entries inside the generated Zensical nav block. + + Args: + plugin_docs_root: Generated ``docs/plugins`` root. + docs_root: Top-level docs directory. + plugins: Discovered plugin documentation metadata. + + Returns: + Lines to insert between nav block markers. + """ + lines: list[str] = [] + for plugin in plugins: + pages = collect_plugin_nav_pages(plugin_docs_root / plugin.docs_slug, docs_root) + lines.append(f" {{{toml_string(plugin.package_name)} = [") + for page in pages: + lines.append(f" {{{toml_string(page.title)} = {toml_string(page.path)}}},") + lines.append(" ]},") + return lines + + +def replace_generated_nav_block(config_text: str, generated_lines: list[str]) -> str: + """Replace the generated plugin docs nav block in ``zensical.toml``. + + Args: + config_text: Current configuration file content. + generated_lines: Replacement lines for the generated block. + + Returns: + Updated configuration text. + """ + lines = config_text.splitlines() + start_indexes = [index for index, line in enumerate(lines) if NAV_START_TOKEN in line] + end_indexes = [index for index, line in enumerate(lines) if NAV_END_TOKEN in line] + + if len(start_indexes) != 1 or len(end_indexes) != 1: + raise ValueError( + f"zensical.toml must contain exactly one {NAV_START_TOKEN!r} and one {NAV_END_TOKEN!r} marker." + ) + + start = start_indexes[0] + end = end_indexes[0] + if end <= start: + raise ValueError(f"{NAV_END_TOKEN!r} must appear after {NAV_START_TOKEN!r}.") + + updated_lines = lines[: start + 1] + generated_lines + lines[end:] + return "\n".join(updated_lines) + "\n" + + +def render_zensical_config( + config_path: Path, + plugin_docs_root: Path, + plugins: list[PluginDocs], + docs_root: Path | None = None, +) -> str: + """Render ``zensical.toml`` with current generated plugin navigation. + + Args: + config_path: Path to ``zensical.toml``. + plugin_docs_root: Generated ``docs/plugins`` root. + plugins: Discovered plugin documentation metadata. + docs_root: Optional top-level docs directory for computing nav paths. + + Returns: + Updated Zensical configuration content. + """ + nav_docs_root = docs_root or config_path.parent / "docs" + generated_lines = render_plugin_nav_entries(plugin_docs_root, nav_docs_root, plugins) + return replace_generated_nav_block(config_path.read_text(encoding="utf-8"), generated_lines) + + +def list_relative_files(root: Path) -> set[Path]: + """List files under a root as relative paths. + + Args: + root: Directory to inspect. + + Returns: + Set of relative file paths. + """ + if not root.exists(): + return set() + return {path.relative_to(root) for path in root.rglob("*") if path.is_file()} + + +def compare_generated_tree(expected_dir: Path, actual_dir: Path) -> list[str]: + """Compare the generated docs tree with the checked-in docs tree. + + Args: + expected_dir: Temporary expected generated tree. + actual_dir: Actual generated tree under the repo docs directory. + + Returns: + Human-readable drift messages. + """ + drift: list[str] = [] + expected_files = list_relative_files(expected_dir) + actual_files = list_relative_files(actual_dir) + + for relative_path in sorted(expected_files - actual_files): + drift.append(f"missing generated docs file: {actual_dir / relative_path}") + for relative_path in sorted(actual_files - expected_files): + drift.append(f"stale generated docs file: {actual_dir / relative_path}") + for relative_path in sorted(expected_files & actual_files): + if (expected_dir / relative_path).read_bytes() != (actual_dir / relative_path).read_bytes(): + drift.append(f"outdated generated docs file: {actual_dir / relative_path}") + + return drift + + +def check_plugin_docs(repo_root: Path) -> list[str]: + """Check whether generated plugin documentation files are current. + + Args: + repo_root: Repository root. + + Returns: + Human-readable drift messages. + """ + plugins = discover_plugin_docs(repo_root) + actual_plugin_docs_root = repo_root / "docs" / "plugins" + + with tempfile.TemporaryDirectory() as tmp_dir: + expected_plugin_docs_root = Path(tmp_dir) / "plugins" + build_plugin_docs_tree(repo_root, plugins, expected_plugin_docs_root) + drift = compare_generated_tree(expected_plugin_docs_root, actual_plugin_docs_root) + expected_config = render_zensical_config( + repo_root / "zensical.toml", + expected_plugin_docs_root, + plugins, + docs_root=expected_plugin_docs_root.parent, + ) + + if (repo_root / "zensical.toml").read_text(encoding="utf-8") != expected_config: + drift.append("outdated generated plugin docs navigation in zensical.toml") + + return drift + + +def sync_plugin_docs(repo_root: Path) -> list[PluginDocs]: + """Regenerate plugin documentation pages and Zensical navigation. + + Args: + repo_root: Repository root. + + Returns: + Discovered plugins included in the generated docs. + """ + plugins = discover_plugin_docs(repo_root) + plugin_docs_root = repo_root / "docs" / "plugins" + + if plugin_docs_root.exists(): + shutil.rmtree(plugin_docs_root) + + build_plugin_docs_tree(repo_root, plugins, plugin_docs_root) + config_path = repo_root / "zensical.toml" + config_path.write_text(render_zensical_config(config_path, plugin_docs_root, plugins), encoding="utf-8") + return plugins + + +def main(args: list[str] | None = None) -> int: + """Run the plugin documentation generator CLI. + + Args: + args: Optional command line arguments. + + Returns: + Process exit code. + """ + parser = argparse.ArgumentParser(description="Generate plugin documentation pages for Zensical") + parser.add_argument( + "--check", + action="store_true", + help="Check generated plugin docs without modifying files", + ) + parsed_args = parser.parse_args(args) + repo_root = find_repo_root() + + if parsed_args.check: + drift = check_plugin_docs(repo_root) + if drift: + print("Generated plugin documentation is out of date. Run `make plugin-docs`.", file=sys.stderr) + for message in drift: + print(f" - {message}", file=sys.stderr) + return 1 + print("Generated plugin documentation is current.") + return 0 + + plugins = sync_plugin_docs(repo_root) + print(f"Generated plugin documentation for {len(plugins)} plugin(s).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/devtools/ddp/src/ddp/scaffold.py b/devtools/ddp/src/ddp/scaffold.py index 2fe5362..555268d 100644 --- a/devtools/ddp/src/ddp/scaffold.py +++ b/devtools/ddp/src/ddp/scaffold.py @@ -80,7 +80,7 @@ def generate_readme(slug: str) -> str: ## Installation ```bash -pip install data-designer-{slug} +uv add data-designer data-designer-{slug} ``` ## Usage @@ -89,7 +89,28 @@ def generate_readme(slug: str) -> str: [NeMo Data Designer](https://github.com/NVIDIA-NeMo/DataDesigner). For the full plugin authoring guide, see the -[main repository docs](https://github.com/NVIDIA-NeMo/DataDesignerPlugins/blob/main/docs/adding-a-plugin.md). +[main repository docs](https://nvidia-nemo.github.io/DataDesignerPlugins/authoring/). + +Plugin documentation for the repository site lives in this package's `docs/` +directory. +""" + + +def generate_docs_index(slug: str) -> str: + return f"""# data-designer-{slug} + +Data Designer {slug} plugin. + +## Installation + +```bash +uv add data-designer data-designer-{slug} +``` + +## Usage + +Once installed, the `{slug}` column type is automatically discovered by +[NeMo Data Designer](https://github.com/NVIDIA-NeMo/DataDesigner). """ @@ -239,10 +260,12 @@ def main(args: list[str] | None = None) -> None: src_dir = plugin_dir / "src" / import_name test_dir = plugin_dir / "tests" + docs_dir = plugin_dir / "docs" # Create directories src_dir.mkdir(parents=True) test_dir.mkdir(parents=True) + docs_dir.mkdir(parents=True) # Write files owner = _discover_owner() @@ -250,6 +273,7 @@ def main(args: list[str] | None = None) -> None: plugin_dir / "pyproject.toml": generate_pyproject(slug, import_name), plugin_dir / "README.md": generate_readme(slug), plugin_dir / "CODEOWNERS": generate_codeowners(owner), + docs_dir / "index.md": generate_docs_index(slug), src_dir / "__init__.py": generate_init(), src_dir / "config.py": generate_config(slug, import_name, class_prefix), src_dir / "impl.py": generate_impl(slug, import_name, class_prefix), @@ -270,8 +294,9 @@ def main(args: list[str] | None = None) -> None: print(f" 1. cd {plugin_dir}") print(f" 2. Edit src/{import_name}/config.py to define your column config") print(f" 3. Edit src/{import_name}/impl.py to implement generation logic") - print(" 4. uv sync --all-packages && uv run pytest tests/") - print(f" 5. make release PLUGIN=data-designer-{slug}") + print(" 4. Edit docs/index.md to document your plugin") + print(" 5. uv sync --all-packages && uv run pytest tests/") + print(f" 6. make release PLUGIN=data-designer-{slug}") if __name__ == "__main__": diff --git a/devtools/ddp/tests/test_catalog.py b/devtools/ddp/tests/test_catalog.py deleted file mode 100644 index b49809d..0000000 --- a/devtools/ddp/tests/test_catalog.py +++ /dev/null @@ -1,28 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for ddp.catalog.""" - -from __future__ import annotations - -import io -from contextlib import redirect_stdout - -from ddp.catalog import main - - -def test_main_produces_markdown_table() -> None: - buf = io.StringIO() - with redirect_stdout(buf): - main() - output = buf.getvalue() - assert "# Plugin Catalog" in output - assert "| Plugin | Version | Column Type | Description |" in output - - -def test_main_includes_template_plugin() -> None: - buf = io.StringIO() - with redirect_stdout(buf): - main() - output = buf.getvalue() - assert "data-designer-template" in output diff --git a/devtools/ddp/tests/test_cli.py b/devtools/ddp/tests/test_cli.py index 6fa9b3f..2e05d5d 100644 --- a/devtools/ddp/tests/test_cli.py +++ b/devtools/ddp/tests/test_cli.py @@ -15,7 +15,15 @@ class TestBuildParser: """Tests for build_parser producing correct subcommands.""" - EXPECTED_COMMANDS = ("new", "catalog", "codeowners", "license-headers", "validate", "check-release", "bump") + EXPECTED_COMMANDS = ( + "new", + "plugin-docs", + "codeowners", + "license-headers", + "validate", + "check-release", + "bump", + ) def test_all_subcommands_registered(self) -> None: parser = build_parser() @@ -58,6 +66,11 @@ def test_license_headers_default_no_check(self) -> None: args = parser.parse_args(["license-headers"]) assert args.check is False + def test_plugin_docs_check_flag(self) -> None: + parser = build_parser() + args = parser.parse_args(["plugin-docs", "--check"]) + assert args.check is True + def test_no_command_prints_help(self) -> None: parser = build_parser() args = parser.parse_args([]) @@ -75,11 +88,11 @@ def test_new_dispatches(self, mock_run: MagicMock) -> None: args.func(args) mock_run.assert_called_once_with(args) - @patch("ddp.cli._run_catalog") - def test_catalog_dispatches(self, mock_run: MagicMock) -> None: + @patch("ddp.cli._run_plugin_docs") + def test_plugin_docs_dispatches(self, mock_run: MagicMock) -> None: mock_run.return_value = 0 parser = build_parser() - args = parser.parse_args(["catalog"]) + args = parser.parse_args(["plugin-docs"]) args.func(args) mock_run.assert_called_once_with(args) diff --git a/devtools/ddp/tests/test_plugin_docs.py b/devtools/ddp/tests/test_plugin_docs.py new file mode 100644 index 0000000..689fe66 --- /dev/null +++ b/devtools/ddp/tests/test_plugin_docs.py @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for ddp.plugin_docs.""" + +from __future__ import annotations + +from pathlib import Path + +from ddp.plugin_docs import check_plugin_docs, discover_plugin_docs, sync_plugin_docs + +ZENSICAL_TEMPLATE = """\ +[project] +site_name = "Test" +nav = [ + {"Home" = "index.md"}, + {"Plugins" = [ + {"Overview" = "plugins/index.md"}, + # BEGIN GENERATED PLUGIN DOCS NAV + # END GENERATED PLUGIN DOCS NAV + ]}, +] +""" + + +def write_file(path: Path, content: str) -> None: + """Write a UTF-8 fixture file.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def write_plugin_pyproject(repo_root: Path, package_name: str, column_type: str) -> None: + """Write minimal plugin pyproject metadata.""" + plugin_dir = repo_root / "plugins" / package_name + write_file( + plugin_dir / "pyproject.toml", + f"""\ +[project] +name = "{package_name}" +version = "0.1.0" +description = "{package_name} description" + +[project.entry-points."data_designer.plugins"] +{column_type} = "{package_name.replace("-", "_")}.plugin:plugin" +""", + ) + + +def write_repo_skeleton(repo_root: Path) -> None: + """Write minimal repository files needed by the docs generator.""" + write_file(repo_root / "pyproject.toml", '[project]\nname = "workspace"\n') + write_file(repo_root / "docs" / "index.md", "# Home\n") + write_file(repo_root / "zensical.toml", ZENSICAL_TEMPLATE) + + +def test_discover_plugin_docs_reads_metadata_and_source_docs(tmp_path: Path) -> None: + write_repo_skeleton(tmp_path) + write_plugin_pyproject(tmp_path, "data-designer-alpha", "alpha") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "index.md", "# Alpha\n") + + plugins = discover_plugin_docs(tmp_path) + + assert len(plugins) == 1 + assert plugins[0].package_name == "data-designer-alpha" + assert plugins[0].column_types == ("alpha",) + assert plugins[0].source_docs_dir == tmp_path / "plugins" / "data-designer-alpha" / "docs" + + +def test_sync_plugin_docs_copies_plugin_docs_and_updates_nav(tmp_path: Path) -> None: + write_repo_skeleton(tmp_path) + write_plugin_pyproject(tmp_path, "data-designer-alpha", "alpha") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "index.md", "# Alpha docs\n") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "usage.md", "# Usage\n") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "assets" / "sample.txt", "asset\n") + + sync_plugin_docs(tmp_path) + + generated_root = tmp_path / "docs" / "plugins" + index_page = (generated_root / "index.md").read_text(encoding="utf-8") + assert "data-designer-alpha" in index_page + assert "Browse available Data Designer plugins" in index_page + assert "make plugin-docs" not in index_page + assert (generated_root / "data-designer-alpha" / "index.md").read_text(encoding="utf-8") == "# Alpha docs\n" + assert (generated_root / "data-designer-alpha" / "usage.md").read_text(encoding="utf-8") == "# Usage\n" + assert (generated_root / "data-designer-alpha" / "assets" / "sample.txt").read_text(encoding="utf-8") == "asset\n" + + zensical = (tmp_path / "zensical.toml").read_text(encoding="utf-8") + assert '{"data-designer-alpha" = [' in zensical + assert '{"Overview" = "plugins/data-designer-alpha/index.md"}' in zensical + assert '{"Usage" = "plugins/data-designer-alpha/usage.md"}' in zensical + + +def test_sync_plugin_docs_generates_fallback_page_without_plugin_docs(tmp_path: Path) -> None: + write_repo_skeleton(tmp_path) + write_plugin_pyproject(tmp_path, "data-designer-alpha", "alpha") + + sync_plugin_docs(tmp_path) + + page = (tmp_path / "docs" / "plugins" / "data-designer-alpha" / "index.md").read_text(encoding="utf-8") + assert "# data-designer-alpha" in page + assert "custom documentation" in page + assert "Column types: `alpha`" in page + + +def test_sync_plugin_docs_maps_root_readme_to_index(tmp_path: Path) -> None: + write_repo_skeleton(tmp_path) + write_plugin_pyproject(tmp_path, "data-designer-alpha", "alpha") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "README.md", "# Alpha README\n") + + sync_plugin_docs(tmp_path) + + page = (tmp_path / "docs" / "plugins" / "data-designer-alpha" / "index.md").read_text(encoding="utf-8") + assert page == "# Alpha README\n" + + +def test_check_plugin_docs_detects_stale_generated_content(tmp_path: Path) -> None: + write_repo_skeleton(tmp_path) + write_plugin_pyproject(tmp_path, "data-designer-alpha", "alpha") + write_file(tmp_path / "plugins" / "data-designer-alpha" / "docs" / "index.md", "# Alpha docs\n") + sync_plugin_docs(tmp_path) + write_file(tmp_path / "docs" / "plugins" / "data-designer-alpha" / "index.md", "# Stale\n") + + drift = check_plugin_docs(tmp_path) + + assert any("outdated generated docs file" in message for message in drift) diff --git a/docs/adding-a-plugin.md b/docs/adding-a-plugin.md deleted file mode 100644 index 241fcb0..0000000 --- a/docs/adding-a-plugin.md +++ /dev/null @@ -1,198 +0,0 @@ -# Adding a Plugin - -## 1. Scaffold - -```bash -uv run ddp new my-plugin -``` - -Generates `plugins/data-designer-my-plugin/` with all required files. Your git email is auto-detected for the CODEOWNERS file. Run `uv run ddp --help` to see all available repo management commands. - -Generated structure: - -``` -plugins/data-designer-my-plugin/ -├── pyproject.toml # Package metadata + entry point registration -├── CODEOWNERS # Plugin ownership (auto-populated) -├── tests/ -│ └── test_plugin.py # Validation test stub -└── src/ - └── data_designer_my_plugin/ - ├── __init__.py - ├── config.py # Column config (params, dependencies, emoji) - ├── impl.py # Generation logic - └── plugin.py # Wires config + impl for discovery -``` - -## 2. Implement - -Plugins are self-contained and fairly freeform. You have the space to craft your plugin as you see fit and accordingly -to your own needs, conventions, and dependencies. However, to get you started, the scaffold initialization provides you -with the following starting point. - -**config.py**: Subclass any required configuration types that will be required to build a DataDesigner config. -**impl.py**: Subclass any required types and implement the logic of your plugin. -**plugin.py** is already wired by the scaffolder. Update the qualified names only if you rename your classes. - -## 3. Test - -Run tests for your plugin only using the repo's isolated test target: - -```bash -make test-plugin PLUGIN=data-designer-my-plugin -``` - -This installs your plugin into a temporary venv and runs its test suite in isolation, without touching other plugins. - -For a faster feedback loop during development you can also run pytest directly: - -```bash -uv run pytest plugins/data-designer-my-plugin/tests/ -v -``` - -The scaffolded test validates plugin structure via `assert_valid_plugin`. Add functional tests for your generation logic. - -```python -from data_designer.engine.testing.utils import assert_valid_plugin -from data_designer_my_plugin.plugin import plugin - -def test_valid_plugin(): - assert_valid_plugin(plugin) -``` - -## 4. Regenerate Metadata - -```bash -uv run ddp catalog > docs/catalog.md -uv run ddp codeowners > .github/CODEOWNERS -``` - -CI will reject your PR if these are stale. - -## 5. Submit - -```bash -git checkout -b feature/my-plugin -git add plugins/data-designer-my-plugin/ docs/catalog.md .github/CODEOWNERS -git commit -m "feat: add my-plugin" -git push -u origin feature/my-plugin -gh pr create -``` - -CI runs four checks on your PR: lint (ruff), isolated install + pytest per plugin, `assert_valid_plugin` on all entry points, and catalog/CODEOWNERS freshness. - -## 6. Release to PyPI - -Once your plugin's MR is merged to `main`, you can publish it to PyPI. CI handles the actual upload; you just bump the version, tag, and push. - -### First-time setup: register your package name - -PyPI claims package names on first upload (there's no separate registration step). Before your first release, verify the name is available: - -1. Go to `https://pypi.org/project/data-designer-my-plugin/` and confirm you get a 404 (name is free). All plugins in this repo use the `data-designer-` prefix. - -2. Check your `pyproject.toml` metadata. The scaffolder populates the required fields (`description`, `license`, `readme`, `authors`), but review them before your first publish: - - ```toml - [project] - name = "data-designer-my-plugin" - version = "0.1.0" - description = "One-line description of what your plugin does" - license = "Apache-2.0" - readme = "README.md" - authors = [ - {name = "NVIDIA Corporation"}, - ] - ``` - -3. Write a `README.md` in your plugin directory. This is what users see on the PyPI page. The scaffolder creates one, but customize it with usage examples specific to your plugin. - -4. Do a local dry-run to make sure everything builds: - - ```bash - make build-plugin PLUGIN=data-designer-my-plugin - ``` - - This validates metadata and produces a wheel and sdist in `dist/`. Inspect the wheel if you want to verify contents: - - ```bash - unzip -l dist/data_designer_my_plugin-0.1.0-py3-none-any.whl - ``` - -### Publishing a release - -```bash -make release PLUGIN=data-designer-my-plugin -git push origin data-designer-my-plugin/v0.1.0 -``` - -`make release` runs the full local pipeline (ownership check, tests, validation, build), creates a git tag, and prints the push command. Pushing the tag triggers the CI publish job, which uploads to PyPI. - -The same process works for all subsequent releases. Bump the version with `make bump`, merge to `main`, then run `make release` again: - -```bash -make bump PLUGIN=data-designer-my-plugin PART=minor -git add plugins/data-designer-my-plugin/pyproject.toml -git commit -m "chore(data-designer-my-plugin): bump version to 0.2.0" -# merge to main, then: -make release PLUGIN=data-designer-my-plugin -git push origin data-designer-my-plugin/v0.2.0 -``` - -`PART` accepts `major`, `minor`, or `patch` (default). Pre-release versions (e.g. `0.2.0a1`) are not supported by `make bump`; edit `pyproject.toml` manually for those. - -### What `make release` does - -First it compares your git email against the plugin's `CODEOWNERS` and warns if you're not listed. Then it installs the plugin in an isolated venv and runs pytest, validates that `pyproject.toml` has all required PyPI fields with a consistent version, and builds the wheel and sdist into `dist/`. Finally it creates a git tag `data-designer-my-plugin/v` from the version in `pyproject.toml`. - -### What CI does when you push the tag - -1. Validates the plugin directory exists and the tagged commit is on `main`. -2. Runs `validate_release.py` (version match + metadata check). -3. Checks CODEOWNERS (hard gate). The tag pusher must be listed in the plugin's `CODEOWNERS` file. -4. Installs and tests the plugin in an isolated venv. -5. Builds and uploads to PyPI using the repo's `PYPI_TOKEN`. - -### Tag convention - -Tags follow the pattern `/v`: - -``` -data-designer-my-plugin/v0.1.0 -data-designer-my-plugin/v0.2.0a1 -data-designer-my-plugin/v1.0.0 -``` - -Each plugin is tagged and released independently. Releasing one plugin doesn't affect any others. - -### Pre-release versions - -Use PEP 440 pre-release suffixes in your `pyproject.toml` version: - -```toml -version = "0.2.0a1" # alpha -version = "0.2.0b1" # beta -version = "0.2.0rc1" # release candidate -``` - -Pre-release versions won't be installed by default with `pip install`. Users must explicitly request them with `pip install data-designer-my-plugin==0.2.0a1` or `pip install --pre data-designer-my-plugin`. - -### Troubleshooting - -| Problem | Fix | -|---------|-----| -| `ERROR: PLUGIN_DIR not found` | Check `PLUGIN=` matches the directory name under `plugins/` | -| Version mismatch | The tag version must match `project.version` in `pyproject.toml` | -| CODEOWNERS failure in CI | Add your GitHub `@username`, `@org/team`, or email to the plugin's `CODEOWNERS` file | -| `ERROR: tagged commit is not on main` | Tags must point to commits on the `main` branch | -| Package name taken on PyPI | Choose a different name; all plugins must use the `data-designer-` prefix | -| `PYPI_TOKEN` not set | A repo maintainer needs to add the `PYPI_TOKEN` secret in GitHub repository settings (Settings → Secrets and variables → Actions) | - -## Entry Point Discovery - -Plugins register via `[project.entry-points."data_designer.plugins"]` in `pyproject.toml`. The key is your column type slug; the value points to the `Plugin` instance. Data Designer discovers all installed plugins automatically through this mechanism. - -```toml -[project.entry-points."data_designer.plugins"] -my-plugin = "data_designer_my_plugin.plugin:plugin" -``` diff --git a/docs/assets/images/ndd-plugins.png b/docs/assets/images/ndd-plugins.png new file mode 100644 index 0000000..5e4a961 Binary files /dev/null and b/docs/assets/images/ndd-plugins.png differ diff --git a/docs/authoring.md b/docs/authoring.md new file mode 100644 index 0000000..e63f502 --- /dev/null +++ b/docs/authoring.md @@ -0,0 +1,132 @@ +# Plugin Authoring + +Create plugins through the repository tooling first. The scaffold is the source +of truth for package shape, entry point registration, tests, and ownership +files. + +## Scaffold a plugin + +From the repository root: + +```bash +make sync +uv run ddp new my-plugin +``` + +This creates a package named `data-designer-my-plugin`: + +```text +plugins/data-designer-my-plugin/ +|-- pyproject.toml +|-- README.md +|-- CODEOWNERS +|-- docs/ +| `-- index.md +|-- tests/ +| `-- test_plugin.py +`-- src/ + `-- data_designer_my_plugin/ + |-- __init__.py + |-- config.py + |-- impl.py + `-- plugin.py +``` + +Use `plugins/data-designer-template/` as the reference implementation before +introducing a new structure. + +## Naming and discovery + +Plugin packages use the `data-designer-` prefix. The entry point key is the +column type slug that Data Designer discovers at runtime: + +```toml +[project] +name = "data-designer-my-plugin" + +[project.entry-points."data_designer.plugins"] +my-plugin = "data_designer_my_plugin.plugin:plugin" +``` + +The module path must use absolute imports: + +```python +from data_designer_my_plugin.config import MyPluginColumnConfig +``` + +Do not use relative imports in this repository. + +## Implement the plugin + +The scaffold separates the plugin into three concerns: + +| File | Responsibility | +| --- | --- | +| `config.py` | Column configuration, parameters, dependencies, and metadata. | +| `impl.py` | Runtime generation logic. | +| `plugin.py` | Data Designer plugin object and entry point target. | + +Keep functions and methods short enough to read in one pass. Prefer reusable +helpers over nested private closures. Dependencies should be declared by the +plugin package that needs them, not by another local plugin. + +## Test public behavior + +The scaffold includes a validation test: + +```python +from data_designer.engine.testing.utils import assert_valid_plugin + +from data_designer_my_plugin.plugin import plugin + + +def test_valid_plugin() -> None: + assert_valid_plugin(plugin) +``` + +Add functional tests for the behavior in `impl.py` and for any meaningful +configuration validation. Tests should exercise public interfaces and expected +Data Designer behavior instead of private implementation details. + +Run the isolated plugin test target while developing: + +```bash +make test-plugin PLUGIN=data-designer-my-plugin +``` + +For a faster loop, run the package tests directly: + +```bash +uv run pytest plugins/data-designer-my-plugin/tests/ -v +``` + +## Document the plugin + +Each plugin owns its site documentation under its package directory: + +```text +plugins/data-designer-my-plugin/docs/ +|-- index.md +`-- usage.md +``` + +The top-level docs build copies this content into the generated +`docs/plugins/` tree and adds it to the Zensical navigation. Keep links and +assets relative to the plugin's `docs/` directory so they continue to work after +generation. + +Every plugin gets a generated fallback page from package metadata when it does +not provide docs yet, but plugin-authored pages should be the source of truth +for usage, configuration, and examples. + +## Regenerate metadata + +When plugin docs, plugin metadata, or ownership changes, regenerate the derived +files: + +```bash +make plugin-docs +make codeowners +``` + +CI verifies that generated plugin docs and `.github/CODEOWNERS` are current. diff --git a/docs/catalog.md b/docs/catalog.md deleted file mode 100644 index d3c1211..0000000 --- a/docs/catalog.md +++ /dev/null @@ -1,7 +0,0 @@ -# Plugin Catalog - -Auto-generated from plugin metadata. Do not edit manually. - -| Plugin | Version | Column Type | Description | -|--------|---------|-------------|-------------| -| data-designer-template | 0.1.0 | `text-transform` | Template Data Designer plugin — text transform column generator | diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..489dc84 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,41 @@ +# Data Designer Plugins + +This repository contains first-class NVIDIA-provided plugins for +[NeMo Data Designer](https://github.com/NVIDIA-NeMo/DataDesigner). Use these +docs when you need to create, review, validate, or release a plugin. + +## What lives here + +Each plugin is an independent Python package under `plugins/`. The root +workspace only provides shared development tooling and CI; plugins should not +depend on each other through local paths. + +```text +DataDesignerPlugins/ +|-- devtools/ +| `-- ddp/ # Repo management CLI +|-- plugins/ +| `-- data-designer-template/ # Reference plugin implementation +`-- docs/ # Zensical documentation source +``` + +## Start here + +- [Plugin authoring](authoring.md) covers the scaffold flow, package layout, + entry point contract, implementation expectations, and test shape. +- [Development workflow](workflow.md) covers local checks, generated metadata, + documentation builds, and GitHub CI. +- [Releasing](releasing.md) covers version bumps, tags, ownership checks, and + PyPI publishing. +- [Plugins](plugins/index.md) lists generated plugin pages assembled from each + plugin package's own docs and metadata. + +## Repository contract + +- Use the `ddp` CLI to scaffold new plugins. +- Keep plugins self-contained. +- Target Python 3.10 and newer. +- Write tests around public interfaces. +- Regenerate generated metadata when plugin docs, plugin metadata, or ownership + changes. +- Run the Makefile targets locally before opening or updating a pull request. diff --git a/docs/plugins/data-designer-template/index.md b/docs/plugins/data-designer-template/index.md new file mode 100644 index 0000000..038c36c --- /dev/null +++ b/docs/plugins/data-designer-template/index.md @@ -0,0 +1,28 @@ +# data-designer-template + +The template plugin is the reference implementation for a simple Data Designer +column generator. It adds a `text-transform` column type that writes an output +column by transforming text from an existing source column. + +## Installation + +```bash +uv add data-designer data-designer-template +``` + +## Column type + +Use the `text-transform` column type when a dataset needs a derived text column +using one of the supported string transforms. + +| Field | Required | Description | +| --- | --- | --- | +| `name` | Yes | Output column name. | +| `source_column` | Yes | Existing text column to transform. | +| `transform` | No | One of `upper`, `lower`, or `title`; defaults to `upper`. | + +## Implementation notes + +The package keeps plugin registration in `plugin.py`, configuration in +`config.py`, and generation logic in `impl.py`. New plugins should follow the +same separation unless their behavior needs a different shape. diff --git a/docs/plugins/data-designer-template/usage.md b/docs/plugins/data-designer-template/usage.md new file mode 100644 index 0000000..0133ad7 --- /dev/null +++ b/docs/plugins/data-designer-template/usage.md @@ -0,0 +1,22 @@ +# Usage + +The template plugin is intentionally small so plugin authors can inspect the +full package quickly. Its generator reads a source text column and writes a new +column using the configured transform. + +```python +from data_designer.config.config_builder import DataDesignerConfigBuilder +from data_designer.config.seed_source_dataframe import DataFrameSeedSource + +builder = DataDesignerConfigBuilder() +builder.with_seed_dataset(DataFrameSeedSource(df=seed_df)) +builder.add_column( + name="name_upper", + column_type="text-transform", + source_column="name", + transform="upper", +) +``` + +The package tests cover the public config object, generator behavior, and a +preview flow through Data Designer. diff --git a/docs/plugins/index.md b/docs/plugins/index.md new file mode 100644 index 0000000..4e54e2e --- /dev/null +++ b/docs/plugins/index.md @@ -0,0 +1,19 @@ + + +# Plugins + +Browse available Data Designer plugins by what they add to your data generation workflow. + + diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..05e5a74 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,60 @@ +# Releasing Plugins + +Do not publish a plugin version unless the release has been requested and you +have permission to publish it. + +## Version bump + +Use the repo tooling to bump released plugin versions: + +```bash +make bump PLUGIN=data-designer-my-plugin PART=patch +``` + +`PART` can be `patch`, `minor`, or `major`. Commit the resulting +`pyproject.toml` change: + +```bash +git add plugins/data-designer-my-plugin/pyproject.toml +git commit -m "chore(data-designer-my-plugin): bump version to 0.2.0" +``` + +Pre-release versions such as `0.2.0a1` are not supported by `ddp bump`; edit the +plugin `pyproject.toml` manually only when a pre-release is explicitly needed. + +## Release from main + +After the version bump is merged to `main`, create the release tag: + +```bash +make release PLUGIN=data-designer-my-plugin +``` + +The release target: + +- warns if your git email is not listed in the plugin `CODEOWNERS`; +- runs that plugin's isolated tests; +- validates release metadata; +- builds the wheel and source distribution; +- creates a tag named `data-designer-my-plugin/v`. + +Push the tag printed by the release command: + +```bash +git push origin data-designer-my-plugin/v0.2.0 +``` + +## Release CI + +Tag pushes trigger the publish workflow. It verifies that: + +- the plugin directory exists; +- the tagged commit is reachable from `main`; +- the tag version matches plugin metadata; +- the tag pusher is authorized by the plugin `CODEOWNERS`; +- plugin tests pass in an isolated virtual environment; +- the package builds successfully. + +If all checks pass, CI publishes the package to PyPI using the repository +`PYPI_TOKEN` secret. + diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..7ec3311 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,109 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +.plugin-doc-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + margin: 1.15rem 0 1.5rem; +} + +.md-typeset .plugin-doc-card { + background: var(--md-default-bg-color); + background: + linear-gradient(135deg, color-mix(in srgb, var(--md-accent-fg-color) 14%, transparent), transparent 42%), + var(--md-default-bg-color); + border: 1px solid var(--md-typeset-table-color); + border-radius: 0.35rem; + color: var(--md-typeset-color); + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 10rem; + overflow: hidden; + padding: 0.95rem 1rem; + position: relative; + text-decoration: none !important; + transition: + border-color 160ms ease, + transform 160ms ease; +} + +.md-typeset .plugin-doc-card:hover { + border-color: var(--md-accent-fg-color); + color: var(--md-accent-fg-color); + transform: translateY(-0.06rem); +} + +.md-typeset .plugin-doc-card * { + border-bottom: 0 !important; + text-decoration: none !important; +} + +.plugin-doc-card__header { + align-items: flex-start; + display: flex; + gap: 0.75rem; + justify-content: space-between; +} + +.plugin-doc-card__title { + color: var(--md-default-fg-color); + font-size: 0.84rem; + font-weight: 700; + line-height: 1.25; +} + +.plugin-doc-card__version { + background: color-mix(in srgb, var(--md-accent-fg-color) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--md-accent-fg-color) 35%, transparent); + border-radius: 999px; + color: var(--md-accent-fg-color); + flex: 0 0 auto; + font-size: 0.56rem; + font-weight: 700; + line-height: 1; + padding: 0.25rem 0.42rem; +} + +.plugin-doc-card__description { + color: var(--md-default-fg-color--light); + font-size: 0.68rem; + line-height: 1.45; +} + +.plugin-doc-card__section { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.plugin-doc-card__label { + color: var(--md-default-fg-color--light); + font-size: 0.55rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.plugin-doc-card__chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.plugin-doc-chip { + background: color-mix(in srgb, var(--md-code-bg-color) 88%, var(--md-accent-fg-color)); + border: 1px solid var(--md-typeset-table-color); + border-radius: 0.25rem; + color: var(--md-default-fg-color); + font-family: var(--md-code-font-family); + font-size: 0.58rem; + line-height: 1.2; + padding: 0.25rem 0.38rem; +} + +.plugin-doc-chip--muted { + color: var(--md-default-fg-color--light); + font-family: var(--md-text-font-family); +} diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 0000000..2f2fcf2 --- /dev/null +++ b/docs/workflow.md @@ -0,0 +1,85 @@ +# Development Workflow + +Use the Makefile targets as the local interface for this repository. They match +the checks used in GitHub CI and keep plugin packages isolated from each other. + +## Local setup + +```bash +make sync +``` + +This syncs the `uv` workspace, including the shared `ddp` development tooling +and the documentation build tool. + +## Local checks + +Run the full local pipeline before opening a pull request: + +```bash +make all +``` + +The target runs: + +| Target | What it verifies | +| --- | --- | +| `make lint` | Ruff linting and formatting. | +| `make test` | Each plugin's tests in an isolated virtual environment. | +| `make validate` | Installed `data_designer.plugins` entry points with `assert_valid_plugin`. | +| `make check` | Generated plugin docs, generated CODEOWNERS, and SPDX license headers. | +| `make docs` | Zensical builds the documentation site in strict mode. | +| `make docs-server` | Zensical serves the documentation site locally while you edit. | + +## Documentation + +Repository documentation source lives under `docs/`, and plugin-specific site +pages live under each plugin's `docs/` directory. The top-level docs build +regenerates `docs/plugins/` before running [Zensical](https://zensical.org/): + +```bash +make docs +``` + +The build writes static output to `site/`, which is ignored by git. Zensical +validates internal links during the build, and this repository runs the build +with `--strict` so documentation warnings fail CI. + +To preview documentation while editing: + +```bash +make docs-server +``` + +The server listens at `http://localhost:8000` by default. Override the address +when needed: + +```bash +make docs-server DOCS_DEV_ADDR=localhost:8080 +``` + +## Generated files + +Generated site inputs come from repository metadata and plugin docs: + +```bash +make plugin-docs +make codeowners +``` + +`docs/plugins/` and the plugin section of `zensical.toml` are generated from +plugin package metadata and `plugins/*/docs/`. Do not edit generated plugin site +pages directly. + +## GitHub CI + +Pull requests run the main CI workflow: + +- lint +- isolated plugin tests +- plugin validation +- generated metadata and license header checks + +Documentation changes also run the documentation workflow. On pull requests the +workflow builds the site and uploads a preview artifact. On pushes to `main`, it +builds the same site and deploys `site/` to GitHub Pages. diff --git a/plugins/data-designer-template/README.md b/plugins/data-designer-template/README.md index 598b047..543867d 100644 --- a/plugins/data-designer-template/README.md +++ b/plugins/data-designer-template/README.md @@ -5,7 +5,7 @@ Template Data Designer plugin and reference implementation for plugin authors. ## Installation ```bash -pip install data-designer-template +uv add data-designer data-designer-template ``` ## Usage @@ -15,4 +15,7 @@ This plugin provides a `text-transform` column type for Once installed, the column type is automatically discovered by Data Designer. For the full plugin authoring guide, see the -[main repository docs](https://github.com/NVIDIA-NeMo/DataDesignerPlugins/blob/main/docs/adding-a-plugin.md). +[main repository docs](https://nvidia-nemo.github.io/DataDesignerPlugins/authoring/). + +Plugin documentation for the repository site lives in this package's `docs/` +directory. diff --git a/plugins/data-designer-template/docs/index.md b/plugins/data-designer-template/docs/index.md new file mode 100644 index 0000000..038c36c --- /dev/null +++ b/plugins/data-designer-template/docs/index.md @@ -0,0 +1,28 @@ +# data-designer-template + +The template plugin is the reference implementation for a simple Data Designer +column generator. It adds a `text-transform` column type that writes an output +column by transforming text from an existing source column. + +## Installation + +```bash +uv add data-designer data-designer-template +``` + +## Column type + +Use the `text-transform` column type when a dataset needs a derived text column +using one of the supported string transforms. + +| Field | Required | Description | +| --- | --- | --- | +| `name` | Yes | Output column name. | +| `source_column` | Yes | Existing text column to transform. | +| `transform` | No | One of `upper`, `lower`, or `title`; defaults to `upper`. | + +## Implementation notes + +The package keeps plugin registration in `plugin.py`, configuration in +`config.py`, and generation logic in `impl.py`. New plugins should follow the +same separation unless their behavior needs a different shape. diff --git a/plugins/data-designer-template/docs/usage.md b/plugins/data-designer-template/docs/usage.md new file mode 100644 index 0000000..0133ad7 --- /dev/null +++ b/plugins/data-designer-template/docs/usage.md @@ -0,0 +1,22 @@ +# Usage + +The template plugin is intentionally small so plugin authors can inspect the +full package quickly. Its generator reads a source text column and writes a new +column using the configured transform. + +```python +from data_designer.config.config_builder import DataDesignerConfigBuilder +from data_designer.config.seed_source_dataframe import DataFrameSeedSource + +builder = DataDesignerConfigBuilder() +builder.with_seed_dataset(DataFrameSeedSource(df=seed_df)) +builder.add_column( + name="name_upper", + column_type="text-transform", + source_column="name", + transform="upper", +) +``` + +The package tests cover the public config object, generator behavior, and a +preview flow through Data Designer. diff --git a/pyproject.toml b/pyproject.toml index 296d95b..cffc13f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,3 +59,8 @@ line-ending = "auto" [tool.pytest.ini_options] testpaths = ["plugins"] + +[dependency-groups] +dev = [ + "zensical>=0.0.40", +] diff --git a/uv.lock b/uv.lock index bf6a01c..97c44fd 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", @@ -427,6 +427,16 @@ name = "data-designer-plugins-workspace" version = "0.0.0" source = { virtual = "." } +[package.dev-dependencies] +dev = [ + { name = "zensical" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "zensical", specifier = ">=0.0.40" }] + [[package]] name = "data-designer-template" version = "0.1.0" @@ -449,6 +459,15 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.1.0" }] +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, +] + [[package]] name = "diff-cover" version = "10.2.0" @@ -972,6 +991,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/44/3ee09a5b60cb44c4f2fbc1c9015cfd6ff5afc08f991cab295d3024dcbf2d/lxml-6.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace", size = 3508860, upload-time = "2026-04-18T04:32:48.619Z" }, ] +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1720,6 +1748,19 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -2596,3 +2637,33 @@ sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a3 wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] + +[[package]] +name = "zensical" +version = "0.0.40" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/a6/88062f7e235f58a5f05d82005fc35d9dbaed27c024fe9ffae5bce7f33661/zensical-0.0.40.tar.gz", hash = "sha256:5c294751977a664614cb84e987186ad8e282af77ce0d0d800fe48ee57791279d", size = 3920555, upload-time = "2026-05-04T16:19:07.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/c4/3066f4442923ca1e49269147b70ca7c84467524e8f5228724693b9ac85c2/zensical-0.0.40-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b65a7143c9c6a460880bf3e65b777952bd2dcede9dd17a6c6bac9b4a0686ad9b", size = 12691533, upload-time = "2026-05-04T16:18:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cb/03e961cbd01620ea91aeb835b0b4e8848c7bcdf5a799a620fb3e57bfc277/zensical-0.0.40-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:045bdcb6d00a11ddcab7d379d0d986cdf78dba8e9287d8e628ef11958241507d", size = 12556486, upload-time = "2026-05-04T16:18:35.278Z" }, + { url = "https://files.pythonhosted.org/packages/60/76/7dde50220808bdc5f5e63b97866a684418410b3cae9d00cdae1d449bcc20/zensical-0.0.40-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d48ec476c2e8ce3f8585a1278083aabc35ec80361f2c4fc4a53b9a525778f7fc", size = 12935602, upload-time = "2026-05-04T16:18:38.308Z" }, + { url = "https://files.pythonhosted.org/packages/51/55/6c8ef951c390b42249738f4338498e7a1fd64ff09e44d7cc19f5c948c45b/zensical-0.0.40-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c38e0ae314c25f2e5e64210bbad9be6e970f2d40fe9da106586ad90ce5e85e", size = 12904314, upload-time = "2026-05-04T16:18:41.007Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ae/95008f5dc2ee441efcdc2fab36ff29ce24d7477e53390fc340c8add39342/zensical-0.0.40-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25f62dcd61f6306cab890dfa34c81d2709f5db290b4c3f2675343771db28c90", size = 13269946, upload-time = "2026-05-04T16:18:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/cdbb2bf04255ccaaa07861bdda1ee8dd1630d2233fc2f09636abbd5e084c/zensical-0.0.40-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:168fe3489dd93ae92978b4db11d9300c63e10d382b81634232c2872ce9e746c2", size = 12974962, upload-time = "2026-05-04T16:18:47.462Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ce/66e86f89fc15bbe667794ba67d7efc8fa72fe7a1be19e1efb4246ff55442/zensical-0.0.40-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8652ba203bd588ebf2d66bda4457a4a7d8e193c886960859c75081c0e3b946de", size = 13111599, upload-time = "2026-05-04T16:18:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/87/76/3d71ebdabb02d79a5c523b5e646141c362c9559947078c8d56a9f3bd7a30/zensical-0.0.40-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9ffa6cf208b7ab6b771703be827d4d8c7f07f173abeffb35a8015a0b832b2a40", size = 13175406, upload-time = "2026-05-04T16:18:53.209Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/2bb5f730786d590f02cb0fef796c148d5ac0d5c1556f2d78c987ad4e1346/zensical-0.0.40-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:7101ba0c739c78bc3a57d22130b59b9e6fdf96c21c8a6b4244070de6b34527d4", size = 13324783, upload-time = "2026-05-04T16:18:56.41Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8c/1d2ba1454360ee948dd0f0807b048c076d9578d0d9ebba2a438ecfa9f82f/zensical-0.0.40-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:39bf728a68a5418feeda8f3385cd1063fdb8d896a6812c3dede4267b2868df12", size = 13260045, upload-time = "2026-05-04T16:18:59.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/61/efd51c5c5e15cfd5498d59df250f60294cc44d36d8ce4dc2a76fa3669c2f/zensical-0.0.40-cp310-abi3-win32.whl", hash = "sha256:bc750c3ba8d11833d9b9ac8fc14adc3435225b6d17314a21a91eb60209511ca5", size = 12244913, upload-time = "2026-05-04T16:19:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/f3f2118fbcfd1c2dc705491c8864c596b1a748b67ffe2a024e512b9201ab/zensical-0.0.40-cp310-abi3-win_amd64.whl", hash = "sha256:c5c86ac468df2dfe515ff54ffa97725c38226f1e5c970059b7e88078abab89ab", size = 12475762, upload-time = "2026-05-04T16:19:05.025Z" }, +] diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000..3f1af80 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[project] +site_name = "Data Designer Plugins" +site_url = "https://nvidia-nemo.github.io/DataDesignerPlugins/" +site_description = "Plugin authoring guide and documentation for Data Designer plugins." +site_author = "NVIDIA" +repo_url = "https://github.com/NVIDIA-NeMo/DataDesignerPlugins" +repo_name = "NVIDIA-NeMo/DataDesignerPlugins" +edit_uri = "edit/main/docs/" +copyright = "Copyright © 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved." +extra_css = ["stylesheets/extra.css"] +nav = [ + {"Home" = "index.md"}, + {"Plugin authoring" = "authoring.md"}, + {"Development workflow" = "workflow.md"}, + {"Releasing" = "releasing.md"}, + {"Plugins" = [ + {"Overview" = "plugins/index.md"}, + # BEGIN GENERATED PLUGIN DOCS NAV + {"data-designer-template" = [ + {"Overview" = "plugins/data-designer-template/index.md"}, + {"Usage" = "plugins/data-designer-template/usage.md"}, + ]}, + # END GENERATED PLUGIN DOCS NAV + ]}, +] + +[project.theme] +language = "en" +favicon = "assets/images/ndd-plugins.png" +logo = "assets/images/ndd-plugins.png" +features = [ + "content.action.edit", + "content.action.view", + "navigation.expand", + "navigation.path", + "navigation.sections", + "navigation.top", + "toc.follow", +] + +[project.theme.icon] +repo = "fontawesome/brands/github" + +[[project.theme.palette]] +scheme = "slate" +toggle.icon = "lucide/sun" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +scheme = "default" +toggle.icon = "lucide/moon" +toggle.name = "Switch to dark mode"