mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-14 04:59:28 +08:00
361 lines
11 KiB
Python
361 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.9"
|
|
# dependencies = [
|
|
# "PyYAML==6.0.2",
|
|
# ]
|
|
# ///
|
|
|
|
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
import yaml
|
|
|
|
SLUG_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
YamlMapping = dict[str, object]
|
|
|
|
|
|
@dataclass
|
|
class VersionEntry:
|
|
slug: str
|
|
display_name: str
|
|
path: str
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Sync or remove one docs snapshot in the docs-website branch."
|
|
)
|
|
parser.add_argument("--operation", choices=["sync", "remove"], default="sync")
|
|
parser.add_argument("--source-root", type=Path)
|
|
parser.add_argument("--docs-website-root", required=True, type=Path)
|
|
parser.add_argument(
|
|
"--channel", required=True, choices=["dev", "latest", "version"]
|
|
)
|
|
parser.add_argument("--source-ref", default="")
|
|
parser.add_argument("--version-slug", default="")
|
|
parser.add_argument("--display-name", default="")
|
|
return parser.parse_args()
|
|
|
|
|
|
def clean_input(value: str | None) -> str:
|
|
return (value or "").strip()
|
|
|
|
|
|
def resolve_slug(channel: str, version_slug: str) -> str:
|
|
if channel == "dev":
|
|
return "dev"
|
|
if channel == "latest":
|
|
return "latest"
|
|
if not version_slug:
|
|
raise ValueError("--version-slug is required when --channel=version")
|
|
if not SLUG_RE.fullmatch(version_slug):
|
|
raise ValueError(
|
|
f"version slug contains unsupported characters: {version_slug}"
|
|
)
|
|
return version_slug
|
|
|
|
|
|
def resolve_display_name(
|
|
channel: str, slug: str, source_ref: str, override: str
|
|
) -> str:
|
|
if override:
|
|
return override
|
|
if channel == "dev":
|
|
return "dev"
|
|
if channel == "latest":
|
|
return f"Latest ({source_ref})" if source_ref.startswith("v") else "Latest"
|
|
return slug
|
|
|
|
|
|
def ensure_existing(path: Path, label: str) -> None:
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"{label} does not exist: {path}")
|
|
|
|
|
|
def reset_directory(src: Path, dst: Path, *, preserve_components: bool) -> None:
|
|
ensure_existing(src, "source directory")
|
|
preserved_components: Path | None = None
|
|
if preserve_components and (dst / "_components").is_dir():
|
|
preserved_components = Path(tempfile.mkdtemp()) / "_components"
|
|
shutil.copytree(dst / "_components", preserved_components)
|
|
if dst.exists():
|
|
shutil.rmtree(dst)
|
|
shutil.copytree(src, dst)
|
|
if preserved_components is not None:
|
|
if (dst / "_components").exists():
|
|
shutil.rmtree(dst / "_components")
|
|
shutil.copytree(preserved_components, dst / "_components")
|
|
|
|
|
|
def merge_directory(src: Path, dst: Path, *, overwrite: bool) -> None:
|
|
if not src.exists():
|
|
return
|
|
if overwrite:
|
|
shutil.copytree(src, dst, dirs_exist_ok=True)
|
|
return
|
|
for copied in src.rglob("*"):
|
|
relative = copied.relative_to(src)
|
|
target = dst / relative
|
|
if copied.is_dir():
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
continue
|
|
if target.exists():
|
|
continue
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(copied, target)
|
|
|
|
|
|
def copy_if_exists(src: Path, dst: Path) -> None:
|
|
if src.exists():
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dst)
|
|
|
|
|
|
def read_yaml(path: Path) -> YamlMapping:
|
|
ensure_existing(path, "YAML file")
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"expected YAML mapping in {path}")
|
|
return cast("YamlMapping", data)
|
|
|
|
|
|
def write_yaml(path: Path, data: YamlMapping) -> None:
|
|
path.write_text(
|
|
yaml.safe_dump(data, sort_keys=False, allow_unicode=True),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def prefix_path(value: object, pages_dir: str) -> object:
|
|
if not isinstance(value, str):
|
|
return value
|
|
if value.startswith(("../", "/", "http://", "https://")):
|
|
return value
|
|
return f"../{pages_dir}/{value}"
|
|
|
|
|
|
def prefix_navigation_paths(value: object, pages_dir: str) -> object:
|
|
if isinstance(value, dict):
|
|
mapping = cast("YamlMapping", value)
|
|
for key in ("path", "folder"):
|
|
if key in mapping:
|
|
mapping[key] = prefix_path(mapping[key], pages_dir)
|
|
for child in mapping.values():
|
|
prefix_navigation_paths(child, pages_dir)
|
|
elif isinstance(value, list):
|
|
for child in cast("list[object]", value):
|
|
prefix_navigation_paths(child, pages_dir)
|
|
return value
|
|
|
|
|
|
def version_navigation(source_index: Path, pages_dir: str) -> YamlMapping:
|
|
data = read_yaml(source_index)
|
|
prefix_navigation_paths(data, pages_dir)
|
|
return data
|
|
|
|
|
|
def parse_versions(raw_versions: object) -> list[VersionEntry]:
|
|
if raw_versions is None:
|
|
return []
|
|
if not isinstance(raw_versions, list):
|
|
raise ValueError("docs.yml versions must be a list")
|
|
entries: list[VersionEntry] = []
|
|
for raw in cast("list[object]", raw_versions):
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
entry = cast("YamlMapping", raw)
|
|
slug = entry.get("slug")
|
|
display_name = entry.get("display-name")
|
|
path = entry.get("path")
|
|
if (
|
|
isinstance(slug, str)
|
|
and isinstance(display_name, str)
|
|
and isinstance(path, str)
|
|
):
|
|
entries.append(
|
|
VersionEntry(slug=slug, display_name=display_name, path=path)
|
|
)
|
|
return entries
|
|
|
|
|
|
def ordered_entries(
|
|
existing: list[VersionEntry], updated: VersionEntry
|
|
) -> list[VersionEntry]:
|
|
by_slug = {entry.slug: entry for entry in existing}
|
|
by_slug[updated.slug] = updated
|
|
existing_order = [entry.slug for entry in existing if entry.slug != updated.slug]
|
|
|
|
order: list[str] = []
|
|
for slug in ("latest", "dev"):
|
|
if slug in by_slug:
|
|
order.append(slug)
|
|
for slug in existing_order:
|
|
if slug not in order and slug in by_slug:
|
|
order.append(slug)
|
|
if updated.slug not in order:
|
|
order.append(updated.slug)
|
|
return [by_slug[slug] for slug in order]
|
|
|
|
|
|
def render_versions(entries: list[VersionEntry]) -> list[dict[str, str]]:
|
|
return [
|
|
{
|
|
"display-name": entry.display_name,
|
|
"path": entry.path,
|
|
"slug": entry.slug,
|
|
}
|
|
for entry in entries
|
|
]
|
|
|
|
|
|
def component_dirs(fern_dir: Path) -> list[str]:
|
|
dirs: list[str] = []
|
|
preferred = ["pages-latest", "pages-dev"]
|
|
all_page_dirs = sorted(
|
|
path.name for path in fern_dir.glob("pages-*") if path.is_dir()
|
|
)
|
|
for name in preferred + all_page_dirs:
|
|
path = fern_dir / name / "_components"
|
|
component = f"./{name}/_components"
|
|
if path.is_dir() and component not in dirs:
|
|
dirs.append(component)
|
|
dirs.append("./components")
|
|
return dirs
|
|
|
|
|
|
def update_docs_yml(docs_yml: Path, updated: VersionEntry, fern_dir: Path) -> None:
|
|
data = read_yaml(docs_yml)
|
|
data["experimental"] = {
|
|
"mdx-components": component_dirs(fern_dir),
|
|
}
|
|
data["versions"] = render_versions(
|
|
ordered_entries(parse_versions(data.get("versions")), updated)
|
|
)
|
|
write_yaml(docs_yml, data)
|
|
|
|
|
|
def remove_docs_yml_entry(docs_yml: Path, slug: str, fern_dir: Path) -> None:
|
|
data = read_yaml(docs_yml)
|
|
entries = [
|
|
entry for entry in parse_versions(data.get("versions")) if entry.slug != slug
|
|
]
|
|
data["experimental"] = {
|
|
"mdx-components": component_dirs(fern_dir),
|
|
}
|
|
data["versions"] = render_versions(entries)
|
|
write_yaml(docs_yml, data)
|
|
|
|
|
|
def sync_docs(args: argparse.Namespace) -> None:
|
|
if args.source_root is None:
|
|
raise ValueError("--source-root is required when --operation=sync")
|
|
source_root = args.source_root.resolve()
|
|
docs_root = args.docs_website_root.resolve()
|
|
source_docs = source_root / "docs"
|
|
source_fern = source_root / "fern"
|
|
target_fern = docs_root / "fern"
|
|
|
|
ensure_existing(source_docs, "source docs")
|
|
ensure_existing(source_fern, "source fern config")
|
|
ensure_existing(target_fern, "docs website fern directory")
|
|
|
|
channel = clean_input(args.channel)
|
|
source_ref = clean_input(args.source_ref)
|
|
if not source_ref:
|
|
raise ValueError("--source-ref is required when --operation=sync")
|
|
version_slug = clean_input(args.version_slug)
|
|
display_override = clean_input(args.display_name)
|
|
slug = resolve_slug(channel, version_slug)
|
|
display_name = resolve_display_name(channel, slug, source_ref, display_override)
|
|
pages_dir = f"pages-{slug}"
|
|
refresh_shared = channel in {"dev", "latest"}
|
|
|
|
reset_directory(
|
|
source_docs,
|
|
target_fern / pages_dir,
|
|
preserve_components=not refresh_shared,
|
|
)
|
|
merge_directory(
|
|
source_fern / "assets", target_fern / "assets", overwrite=refresh_shared
|
|
)
|
|
merge_directory(
|
|
source_fern / "components", target_fern / "components", overwrite=refresh_shared
|
|
)
|
|
if refresh_shared:
|
|
copy_if_exists(source_fern / "main.css", target_fern / "main.css")
|
|
copy_if_exists(
|
|
source_fern / "fern.config.json", target_fern / "fern.config.json"
|
|
)
|
|
|
|
versions_dir = target_fern / "versions"
|
|
versions_dir.mkdir(parents=True, exist_ok=True)
|
|
write_yaml(
|
|
versions_dir / f"{slug}.yml",
|
|
version_navigation(source_docs / "index.yml", pages_dir),
|
|
)
|
|
|
|
update_docs_yml(
|
|
target_fern / "docs.yml",
|
|
VersionEntry(
|
|
slug=slug,
|
|
display_name=display_name,
|
|
path=f"./versions/{slug}.yml",
|
|
),
|
|
target_fern,
|
|
)
|
|
|
|
print(
|
|
f"Synced {channel} docs from {source_ref} to fern/{pages_dir} ({display_name})"
|
|
)
|
|
|
|
|
|
def remove_docs(args: argparse.Namespace) -> None:
|
|
docs_root = args.docs_website_root.resolve()
|
|
target_fern = docs_root / "fern"
|
|
|
|
ensure_existing(target_fern, "docs website fern directory")
|
|
|
|
channel = clean_input(args.channel)
|
|
version_slug = clean_input(args.version_slug)
|
|
slug = resolve_slug(channel, version_slug)
|
|
|
|
pages_dir = target_fern / f"pages-{slug}"
|
|
if pages_dir.exists():
|
|
shutil.rmtree(pages_dir)
|
|
|
|
version_file = target_fern / "versions" / f"{slug}.yml"
|
|
if version_file.exists():
|
|
version_file.unlink()
|
|
|
|
remove_docs_yml_entry(target_fern / "docs.yml", slug, target_fern)
|
|
|
|
print(f"Removed {slug} docs from docs website branch")
|
|
|
|
|
|
def main() -> None:
|
|
try:
|
|
args = parse_args()
|
|
if args.operation == "sync":
|
|
sync_docs(args)
|
|
else:
|
|
remove_docs(args)
|
|
except Exception as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
raise SystemExit(2) from exc
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|