summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@vyos.io>2026-05-06 23:24:45 +0300
committerYuriy Andamasov <yuriy@vyos.io>2026-05-06 23:24:45 +0300
commit89f86b481456437f3e9f16895e7408fe460de3f3 (patch)
treef1bb09b203d56234c4518e6ab2d7fa5ca8610dec /scripts
parent6ce9145fa101e623c61f009c9cf5bb75a8a4a108 (diff)
parent7cf51e1c2901f6d1b01e9bff194f7188bc29e417 (diff)
downloadvyos-documentation-89f86b481456437f3e9f16895e7408fe460de3f3.tar.gz
vyos-documentation-89f86b481456437f3e9f16895e7408fe460de3f3.zip
Merge remote-tracking branch 'origin/current' into feat/docs-llms-txt-current
# Conflicts: # docs/conf.py
Diffstat (limited to 'scripts')
-rw-r--r--scripts/swap_sources.py344
1 files changed, 344 insertions, 0 deletions
diff --git a/scripts/swap_sources.py b/scripts/swap_sources.py
new file mode 100644
index 00000000..8a03085e
--- /dev/null
+++ b/scripts/swap_sources.py
@@ -0,0 +1,344 @@
+#!/usr/bin/env python3
+"""swap_sources.py — Pre-build swap/restore for the RST override mechanism.
+
+In this repo MD is the canonical source for migrated pages. RST kept around
+under the ``rst-<stem>.rst`` prefix is a *fallback* — only consulted when a
+page is explicitly listed in ``docs/_rst_overrides.txt`` to revert to the
+legacy RST rendering.
+
+This script flips the source files in place at build time so Sphinx renders
+the RST instead of the MD for the listed stems, then restores everything
+after the build.
+
+Usage
+-----
+ python scripts/swap_sources.py --swap # activate RST overrides
+ python scripts/swap_sources.py --restore # undo activated overrides
+ python scripts/swap_sources.py --dry-run # show what would be activated
+ python scripts/swap_sources.py --status # show current state
+ python scripts/swap_sources.py --swap --docs-dir /path/to/docs
+
+The ``--swap`` flag name is kept for backward compatibility with the Makefile
+and Read the Docs config; semantically it now applies RST overrides.
+
+Override list
+-------------
+ docs/_rst_overrides.txt — one stem per line, relative to docs/, comments
+ (#) skipped. Pages listed here render from rst-<stem>.rst instead of
+ <stem>.md at build time.
+
+State files (in docs/_build/, gitignored)
+-----------------------------------------
+ _rst_override_state.json — records completed RST activations
+ _md_exclude.txt — MD source paths to pass to Sphinx exclude_patterns
+"""
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+STATE_FILE = "_rst_override_state.json"
+EXCLUDE_FILE = "_md_exclude.txt"
+OVERRIDE_LIST_FILE = "_rst_overrides.txt"
+STATE_VERSION = 2 # bumped from 1 when the swap direction was inverted
+
+
+# ---------------------------------------------------------------------------
+# Core helpers
+# ---------------------------------------------------------------------------
+
+def parse_override_list(path: Path) -> list:
+ """Read *path* and return a list of stems (comments/blanks skipped).
+
+ Returns [] if the file does not exist.
+ """
+ if not path.exists():
+ return []
+ stems = []
+ for line in path.read_text(encoding="utf-8").splitlines():
+ stripped = line.strip()
+ if stripped and not stripped.startswith("#"):
+ stems.append(stripped)
+ return stems
+
+
+def _build_dir(docs_dir: Path) -> Path:
+ build = docs_dir / "_build"
+ build.mkdir(parents=True, exist_ok=True)
+ return build
+
+
+def _state_path(docs_dir: Path) -> Path:
+ return docs_dir / "_build" / STATE_FILE
+
+
+def _exclude_path(docs_dir: Path) -> Path:
+ return docs_dir / "_build" / EXCLUDE_FILE
+
+
+def _resolve_stem_paths(docs_dir: Path, stem: str):
+ """Return (rst_from, rst_to, md_path) as absolute Paths for a given stem."""
+ p = Path(stem)
+ name = p.name
+ parent = p.parent # may be PosixPath('.')
+ if str(parent) == ".":
+ target_dir = docs_dir
+ else:
+ target_dir = docs_dir / parent
+
+ rst_from = target_dir / f"rst-{name}.rst" # source: rst-prefixed
+ rst_to = target_dir / f"{name}.rst" # destination: plain .rst
+ md_path = target_dir / f"{name}.md" # MD that will be excluded
+ return rst_from, rst_to, md_path
+
+
+# ---------------------------------------------------------------------------
+# do_swap (apply RST overrides)
+# ---------------------------------------------------------------------------
+
+def do_swap(docs_dir: Path) -> None:
+ """Rename rst-{name}.rst → {name}.rst for each stem in _rst_overrides.txt.
+
+ The matching <stem>.md is recorded in _md_exclude.txt so Sphinx ignores it
+ during the build. Validates collisions, records state, writes exclude file.
+ Rolls back all completed renames on any failure.
+ """
+ docs_dir = Path(docs_dir)
+ override_list = parse_override_list(docs_dir / OVERRIDE_LIST_FILE)
+ if not override_list:
+ print("swap_sources: no stems in override list, nothing to do.", file=sys.stderr)
+ return
+
+ # Stale state: warn and auto-restore first
+ state_path = _state_path(docs_dir)
+ if state_path.exists():
+ print(
+ "swap_sources: WARNING — stale override state detected. Auto-restoring before re-applying.",
+ file=sys.stderr,
+ )
+ do_restore(docs_dir)
+
+ # Validate all stems before touching the filesystem
+ planned = [] # list of (rst_from, rst_to, md_path, stem)
+ for stem in override_list:
+ rst_from, rst_to, md_path = _resolve_stem_paths(docs_dir, stem)
+
+ if not rst_from.exists():
+ print(
+ f"swap_sources: skipping {stem!r} — rst- source file not found: {rst_from}",
+ file=sys.stderr,
+ )
+ continue
+
+ if not md_path.exists():
+ print(
+ f"swap_sources: skipping {stem!r} — MD file not found: {md_path}",
+ file=sys.stderr,
+ )
+ continue
+
+ if rst_to.exists():
+ raise RuntimeError(
+ f"collision: both rst- and unprefixed .rst exist for stem {stem!r}. "
+ f"Remove {rst_to} before applying the override."
+ )
+
+ planned.append((rst_from, rst_to, md_path, stem))
+
+ # Execute renames with rollback on failure
+ completed = [] # list of (rst_from, rst_to) that succeeded
+ try:
+ for rst_from, rst_to, md_path, stem in planned:
+ os.rename(rst_from, rst_to)
+ completed.append((rst_from, rst_to))
+ except Exception as exc:
+ # Rollback completed renames in reverse order
+ for from_path, to_path in reversed(completed):
+ try:
+ os.rename(to_path, from_path)
+ except Exception as rollback_exc:
+ print(
+ f"swap_sources: ROLLBACK ERROR for {to_path}: {rollback_exc}",
+ file=sys.stderr,
+ )
+ raise RuntimeError(f"swap_sources: rename failed, rolled back. Cause: {exc}") from exc
+
+ if not completed:
+ return
+
+ # Build state and exclude data using relative paths (relative to docs_dir)
+ _build_dir(docs_dir)
+ overrides = []
+ exclude_lines = []
+ for rst_from, rst_to, md_path, stem in planned:
+ rel_from = rst_from.relative_to(docs_dir)
+ rel_to = rst_to.relative_to(docs_dir)
+ rel_md = md_path.relative_to(docs_dir)
+ overrides.append({
+ "stem": stem,
+ "rst_from": str(rel_from),
+ "rst_to": str(rel_to),
+ "md_excluded": str(rel_md),
+ })
+ exclude_lines.append(str(rel_md))
+
+ state = {"version": STATE_VERSION, "overrides": overrides}
+ state_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
+ _exclude_path(docs_dir).write_text("\n".join(exclude_lines) + "\n", encoding="utf-8")
+
+ print(f"swap_sources: applied {len(completed)} RST override(s).", file=sys.stderr)
+
+
+# ---------------------------------------------------------------------------
+# do_restore
+# ---------------------------------------------------------------------------
+
+def do_restore(docs_dir: Path) -> None:
+ """Undo all renames recorded in _rst_override_state.json (reverse order)."""
+ docs_dir = Path(docs_dir)
+ state_path = _state_path(docs_dir)
+
+ if not state_path.exists():
+ return # no-op
+
+ try:
+ state = json.loads(state_path.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, OSError) as exc:
+ raise RuntimeError(
+ f"swap_sources: cannot read state file {state_path}: {exc}. "
+ f"Inspect or delete it manually."
+ ) from exc
+
+ version = state.get("version")
+ if version != STATE_VERSION:
+ raise RuntimeError(
+ f"swap_sources: state file version {version!r} does not match "
+ f"expected {STATE_VERSION!r}. Inspect {state_path} manually."
+ )
+
+ overrides = state.get("overrides", [])
+
+ for entry in reversed(overrides):
+ if not isinstance(entry, dict) or "rst_from" not in entry or "rst_to" not in entry:
+ raise RuntimeError(
+ f"swap_sources: malformed entry in {state_path}: {entry!r}. "
+ f"Inspect manually."
+ )
+ rst_from = docs_dir / entry["rst_from"] # original source (rst-prefixed)
+ rst_to = docs_dir / entry["rst_to"] # current location (plain .rst)
+ if rst_to.exists():
+ os.rename(rst_to, rst_from)
+ else:
+ print(
+ f"swap_sources: WARNING — expected overridden file not found: {rst_to}",
+ file=sys.stderr,
+ )
+
+ # Clean up state and exclude files
+ state_path.unlink(missing_ok=True)
+ exclude_path = _exclude_path(docs_dir)
+ exclude_path.unlink(missing_ok=True)
+
+ print(f"swap_sources: restored {len(overrides)} RST override(s).", file=sys.stderr)
+
+
+# ---------------------------------------------------------------------------
+# do_dry_run / do_status
+# ---------------------------------------------------------------------------
+
+def do_dry_run(docs_dir: Path) -> None:
+ """Print what would be activated without making any changes."""
+ docs_dir = Path(docs_dir)
+ override_list = parse_override_list(docs_dir / OVERRIDE_LIST_FILE)
+ if not override_list:
+ print("(dry-run) No stems in override list.")
+ return
+
+ for stem in override_list:
+ rst_from, rst_to, md_path = _resolve_stem_paths(docs_dir, stem)
+ issues = []
+ if not rst_from.exists():
+ issues.append("rst- source missing")
+ if not md_path.exists():
+ issues.append("MD missing")
+ if rst_to.exists():
+ issues.append("COLLISION: plain .rst already exists")
+ if issues:
+ print(f" SKIP {stem}: {', '.join(issues)}")
+ else:
+ rel_dir = rst_from.parent.relative_to(docs_dir) if rst_from.parent != docs_dir else "."
+ print(f" APPLY {rst_from.name} → {rst_to.name} (in {rel_dir})")
+
+
+def do_status(docs_dir: Path) -> None:
+ """Show current override state."""
+ docs_dir = Path(docs_dir)
+ state_path = _state_path(docs_dir)
+ if not state_path.exists():
+ print("swap_sources: no active RST overrides.")
+ return
+
+ state = json.loads(state_path.read_text(encoding="utf-8"))
+ overrides = state.get("overrides", [])
+ print(f"swap_sources: {len(overrides)} RST override(s) currently active:")
+ for entry in overrides:
+ print(f" {entry['rst_from']} → {entry['rst_to']} (excludes {entry['md_excluded']})")
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description="Activate rst-prefixed RST files as the build source for stems "
+ "listed in docs/_rst_overrides.txt."
+ )
+ group = parser.add_mutually_exclusive_group(required=True)
+ group.add_argument("--swap", action="store_true",
+ help="Apply RST overrides (rename rst-<stem>.rst → <stem>.rst)")
+ group.add_argument("--restore", action="store_true",
+ help="Restore original files after a previous --swap")
+ group.add_argument("--dry-run", action="store_true",
+ help="Show what would be applied")
+ group.add_argument("--status", action="store_true",
+ help="Show current override state")
+
+ parser.add_argument(
+ "--docs-dir",
+ type=Path,
+ default=None,
+ help="Path to docs/ directory (default: auto-detected from script location)",
+ )
+
+ args = parser.parse_args(argv)
+
+ if args.docs_dir is None:
+ # Default: repo_root/docs where repo_root = parent of scripts/
+ repo_root = Path(__file__).resolve().parent.parent
+ docs_dir = repo_root / "docs"
+ else:
+ docs_dir = args.docs_dir.resolve()
+
+ if not docs_dir.is_dir():
+ parser.error(f"docs directory not found: {docs_dir}")
+
+ if args.swap:
+ do_swap(docs_dir)
+ elif args.restore:
+ do_restore(docs_dir)
+ elif args.dry_run:
+ do_dry_run(docs_dir)
+ elif args.status:
+ do_status(docs_dir)
+
+
+if __name__ == "__main__":
+ main()