summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@vyos.io>2026-04-27 04:39:27 +0300
committerYuriy Andamasov <yuriy@vyos.io>2026-05-06 16:18:02 +0300
commitb73f17d61c79475c2e741f62455bb5e90ad1e287 (patch)
tree6a5fa91bdf141619cb4d1e6eb7ffc3d73fe20ab1
parentb26a59dbfe0a1e96a71cfd8e9f4bf68e20eb14ec (diff)
downloadvyos-documentation-b73f17d61c79475c2e741f62455bb5e90ad1e287.tar.gz
vyos-documentation-b73f17d61c79475c2e741f62455bb5e90ad1e287.zip
feat: add swap_sources.py for incremental RST-to-MyST migration
Pre-build swap/restore script that renames md-{name}.md → {name}.md before Sphinx builds and restores after. Includes state tracking, exclude file generation, collision detection, and partial-failure rollback. 10 tests cover all specified behaviors plus rollback path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--scripts/swap_sources.py310
-rw-r--r--tests/test_swap_sources.py238
2 files changed, 548 insertions, 0 deletions
diff --git a/scripts/swap_sources.py b/scripts/swap_sources.py
new file mode 100644
index 00000000..f983dbfa
--- /dev/null
+++ b/scripts/swap_sources.py
@@ -0,0 +1,310 @@
+#!/usr/bin/env python3
+"""swap_sources.py — Pre-build swap/restore for incremental RST-to-MyST migration.
+
+Usage
+-----
+ python scripts/swap_sources.py --swap # rename md-{name}.md → {name}.md
+ python scripts/swap_sources.py --restore # undo renames
+ python scripts/swap_sources.py --dry-run # show what would be swapped
+ python scripts/swap_sources.py --status # show current swap state
+ python scripts/swap_sources.py --swap --docs-dir /path/to/docs
+
+Swap list
+---------
+ docs/_swap.txt — one stem per line, relative to docs/, comments (#) skipped.
+ Example:
+ # migrated pages
+ configuration/firewall/zone
+ quick-start
+
+State files (in docs/_build/, gitignored)
+-----------------------------------------
+ _swap_state.json — records completed renames
+ _swap_exclude.txt — RST paths to pass to Sphinx exclude_patterns
+"""
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+STATE_FILE = "_swap_state.json"
+EXCLUDE_FILE = "_swap_exclude.txt"
+SWAP_LIST_FILE = "_swap.txt"
+STATE_VERSION = 1
+
+
+# ---------------------------------------------------------------------------
+# Core helpers
+# ---------------------------------------------------------------------------
+
+def parse_swap_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().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 (md_from, md_to, rst_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
+
+ md_from = target_dir / f"md-{name}.md" # source: md-prefixed
+ md_to = target_dir / f"{name}.md" # destination: plain .md
+ rst_path = target_dir / f"{name}.rst" # RST that will be excluded
+ return md_from, md_to, rst_path
+
+
+# ---------------------------------------------------------------------------
+# do_swap
+# ---------------------------------------------------------------------------
+
+def do_swap(docs_dir: Path) -> None:
+ """Rename md-{name}.md → {name}.md for each stem in _swap.txt.
+
+ Validates collisions, records state, writes exclude file.
+ Rolls back all completed renames on any failure.
+ """
+ docs_dir = Path(docs_dir)
+ swap_list = parse_swap_list(docs_dir / SWAP_LIST_FILE)
+ if not swap_list:
+ print("swap_sources: no stems in swap 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 swap state detected. Auto-restoring before new swap.",
+ file=sys.stderr,
+ )
+ do_restore(docs_dir)
+
+ # Validate all stems before touching the filesystem
+ planned = [] # list of (md_from, md_to, rst_path, stem)
+ for stem in swap_list:
+ md_from, md_to, rst_path = _resolve_stem_paths(docs_dir, stem)
+
+ if not md_from.exists():
+ print(
+ f"swap_sources: skipping {stem!r} — md- source file not found: {md_from}",
+ file=sys.stderr,
+ )
+ continue
+
+ if not rst_path.exists():
+ print(
+ f"swap_sources: skipping {stem!r} — RST file not found: {rst_path}",
+ file=sys.stderr,
+ )
+ continue
+
+ if md_to.exists():
+ raise RuntimeError(
+ f"collision: both RST and unprefixed .md exist for stem {stem!r}. "
+ f"Remove {md_to} before swapping."
+ )
+
+ planned.append((md_from, md_to, rst_path, stem))
+
+ # Execute renames with rollback on failure
+ completed = [] # list of (md_from, md_to) that succeeded
+ try:
+ for md_from, md_to, rst_path, stem in planned:
+ os.rename(md_from, md_to)
+ completed.append((md_from, md_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)
+ swaps = []
+ exclude_lines = []
+ for md_from, md_to, rst_path, stem in planned:
+ rel_from = md_from.relative_to(docs_dir)
+ rel_to = md_to.relative_to(docs_dir)
+ rel_rst = rst_path.relative_to(docs_dir)
+ swaps.append({
+ "stem": stem,
+ "md_from": str(rel_from),
+ "md_to": str(rel_to),
+ "rst_excluded": str(rel_rst),
+ })
+ exclude_lines.append(str(rel_rst))
+
+ state = {"version": STATE_VERSION, "swaps": swaps}
+ state_path.write_text(json.dumps(state, indent=2))
+ _exclude_path(docs_dir).write_text("\n".join(exclude_lines) + "\n")
+
+ print(f"swap_sources: swapped {len(completed)} file(s).", file=sys.stderr)
+
+
+# ---------------------------------------------------------------------------
+# do_restore
+# ---------------------------------------------------------------------------
+
+def do_restore(docs_dir: Path) -> None:
+ """Undo all renames recorded in _swap_state.json (reverse order)."""
+ docs_dir = Path(docs_dir)
+ state_path = _state_path(docs_dir)
+
+ if not state_path.exists():
+ return # no-op
+
+ state = json.loads(state_path.read_text())
+ swaps = state.get("swaps", [])
+
+ for entry in reversed(swaps):
+ md_from = docs_dir / entry["md_from"] # original source (md-prefixed)
+ md_to = docs_dir / entry["md_to"] # current location (plain .md)
+ if md_to.exists():
+ os.rename(md_to, md_from)
+ else:
+ print(
+ f"swap_sources: WARNING — expected swapped file not found: {md_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(swaps)} file(s).", file=sys.stderr)
+
+
+# ---------------------------------------------------------------------------
+# do_dry_run / do_status
+# ---------------------------------------------------------------------------
+
+def do_dry_run(docs_dir: Path) -> None:
+ """Print what would be swapped without making any changes."""
+ docs_dir = Path(docs_dir)
+ swap_list = parse_swap_list(docs_dir / SWAP_LIST_FILE)
+ if not swap_list:
+ print("(dry-run) No stems in swap list.")
+ return
+
+ for stem in swap_list:
+ md_from, md_to, rst_path = _resolve_stem_paths(docs_dir, stem)
+ issues = []
+ if not md_from.exists():
+ issues.append("md- source missing")
+ if not rst_path.exists():
+ issues.append("RST missing")
+ if md_to.exists():
+ issues.append("COLLISION: plain .md already exists")
+ if issues:
+ print(f" SKIP {stem}: {', '.join(issues)}")
+ else:
+ print(f" SWAP {md_from.name} → {md_to.name} (in {md_from.parent.relative_to(docs_dir) if md_from.parent != docs_dir else '.'})")
+
+
+def do_status(docs_dir: Path) -> None:
+ """Show current swap state."""
+ docs_dir = Path(docs_dir)
+ state_path = _state_path(docs_dir)
+ if not state_path.exists():
+ print("swap_sources: no active swap state.")
+ return
+
+ state = json.loads(state_path.read_text())
+ swaps = state.get("swaps", [])
+ print(f"swap_sources: {len(swaps)} file(s) currently swapped:")
+ for entry in swaps:
+ print(f" {entry['md_from']} → {entry['md_to']} (excludes {entry['rst_excluded']})")
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description="Swap md-prefixed MyST files in place of RST files before Sphinx builds."
+ )
+ group = parser.add_mutually_exclusive_group(required=True)
+ group.add_argument("--swap", action="store_true", help="Perform swap")
+ group.add_argument("--restore", action="store_true", help="Restore original files")
+ group.add_argument("--dry-run", action="store_true", help="Show what would be swapped")
+ group.add_argument("--status", action="store_true", help="Show current swap 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:
+ import atexit
+ atexit.register(do_restore, docs_dir)
+ 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()
diff --git a/tests/test_swap_sources.py b/tests/test_swap_sources.py
new file mode 100644
index 00000000..8576e056
--- /dev/null
+++ b/tests/test_swap_sources.py
@@ -0,0 +1,238 @@
+"""Tests for swap_sources.py — run with:
+ cd tests && PYTHONPATH=../scripts python -m pytest test_swap_sources.py -v
+"""
+import json
+import os
+import sys
+from pathlib import Path
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Helper
+# ---------------------------------------------------------------------------
+
+def _setup_docs(tmp_path: Path, stems: list[str]) -> Path:
+ """Create a minimal docs/ tree with RST + md-prefixed MD files for each stem."""
+ docs = tmp_path / "docs"
+ for stem in stems:
+ p = Path(stem)
+ name = p.name
+ parent = p.parent # may be "." for flat stems
+ target_dir = docs / parent if str(parent) != "." else docs
+ target_dir.mkdir(parents=True, exist_ok=True)
+ # RST file that would normally be the Sphinx source
+ (target_dir / f"{name}.rst").write_text(f".. _{name}:\n\nDummy RST for {stem}\n")
+ # md- prefixed MyST file waiting to be swapped in
+ (target_dir / f"md-{name}.md").write_text(f"# {name}\nDummy MyST for {stem}\n")
+ return docs
+
+
+# ---------------------------------------------------------------------------
+# parse_swap_list
+# ---------------------------------------------------------------------------
+
+def test_parse_swap_list_basic(tmp_path):
+ from swap_sources import parse_swap_list
+
+ swap_txt = tmp_path / "_swap.txt"
+ swap_txt.write_text(
+ "# comment\n"
+ "\n"
+ "configuration/firewall/zone\n"
+ " quick-start \n" # leading/trailing whitespace
+ "# another comment\n"
+ "installation/virtual/vmware\n"
+ )
+ stems = parse_swap_list(swap_txt)
+ assert stems == [
+ "configuration/firewall/zone",
+ "quick-start",
+ "installation/virtual/vmware",
+ ]
+
+
+def test_parse_swap_list_empty(tmp_path):
+ from swap_sources import parse_swap_list
+
+ missing = tmp_path / "_swap.txt"
+ assert parse_swap_list(missing) == []
+
+
+# ---------------------------------------------------------------------------
+# do_swap
+# ---------------------------------------------------------------------------
+
+def test_swap_renames_files(tmp_path):
+ from swap_sources import do_swap
+
+ stems = ["quick-start"]
+ docs = _setup_docs(tmp_path, stems)
+ (tmp_path / "docs" / "_swap.txt").write_text("quick-start\n")
+
+ do_swap(docs)
+
+ assert (docs / "quick-start.md").exists(), "md- file should be renamed to plain .md"
+ assert not (docs / "md-quick-start.md").exists(), "md- prefix file should be gone"
+ assert (docs / "quick-start.rst").exists(), "RST file should still exist"
+
+
+def test_swap_creates_state_file(tmp_path):
+ from swap_sources import do_swap
+
+ stems = ["quick-start"]
+ docs = _setup_docs(tmp_path, stems)
+ (docs / "_swap.txt").write_text("quick-start\n")
+
+ do_swap(docs)
+
+ state_file = docs / "_build" / "_swap_state.json"
+ assert state_file.exists(), "state file should be created in _build/"
+
+ state = json.loads(state_file.read_text())
+ assert state["version"] == 1
+ assert len(state["swaps"]) == 1
+
+ entry = state["swaps"][0]
+ assert entry["stem"] == "quick-start"
+ assert "md_from" in entry
+ assert "md_to" in entry
+ assert "rst_excluded" in entry
+
+
+def test_swap_creates_exclude_file(tmp_path):
+ from swap_sources import do_swap
+
+ stems = ["quick-start"]
+ docs = _setup_docs(tmp_path, stems)
+ (docs / "_swap.txt").write_text("quick-start\n")
+
+ do_swap(docs)
+
+ exclude_file = docs / "_build" / "_swap_exclude.txt"
+ assert exclude_file.exists(), "exclude file should be created in _build/"
+
+ lines = [l.strip() for l in exclude_file.read_text().splitlines() if l.strip()]
+ assert "quick-start.rst" in lines
+
+
+# ---------------------------------------------------------------------------
+# do_restore
+# ---------------------------------------------------------------------------
+
+def test_restore_undoes_renames(tmp_path):
+ from swap_sources import do_restore, do_swap
+
+ stems = ["quick-start"]
+ docs = _setup_docs(tmp_path, stems)
+ (docs / "_swap.txt").write_text("quick-start\n")
+
+ do_swap(docs)
+ assert (docs / "quick-start.md").exists()
+
+ do_restore(docs)
+
+ assert (docs / "md-quick-start.md").exists(), "md- prefix file should be restored"
+ assert not (docs / "quick-start.md").exists(), "plain .md should be gone after restore"
+
+ state_file = docs / "_build" / "_swap_state.json"
+ exclude_file = docs / "_build" / "_swap_exclude.txt"
+ assert not state_file.exists(), "state file should be deleted after restore"
+ assert not exclude_file.exists(), "exclude file should be deleted after restore"
+
+
+def test_restore_noop_without_state(tmp_path):
+ from swap_sources import do_restore
+
+ docs = tmp_path / "docs"
+ docs.mkdir()
+ # Should not raise even with no state file
+ do_restore(docs) # no error = pass
+
+
+# ---------------------------------------------------------------------------
+# Collision / validation
+# ---------------------------------------------------------------------------
+
+def test_swap_fails_on_collision(tmp_path):
+ from swap_sources import do_swap
+
+ stems = ["quick-start", "cli"]
+ docs = _setup_docs(tmp_path, stems)
+ (docs / "_swap.txt").write_text("quick-start\ncli\n")
+
+ # Simulate a collision: unprefixed .md already exists for "cli"
+ (docs / "cli.md").write_text("# collision\n")
+
+ with pytest.raises(RuntimeError, match="collision"):
+ do_swap(docs)
+
+ # Rollback: first stem's rename should be undone
+ assert (docs / "md-quick-start.md").exists(), "rollback should undo completed renames"
+ assert not (docs / "quick-start.md").exists(), "renamed file should not exist after rollback"
+
+ # State file should NOT be written on failure
+ state_file = docs / "_build" / "_swap_state.json"
+ assert not state_file.exists(), "no state file should be written on failure"
+
+
+# ---------------------------------------------------------------------------
+# Rollback on rename failure (monkeypatched os.rename)
+# ---------------------------------------------------------------------------
+
+def test_swap_rollback_on_rename_failure(tmp_path, monkeypatch):
+ """If os.rename raises on the second call, the first rename must be undone."""
+ from swap_sources import do_swap
+
+ stems = ["quick-start", "cli"]
+ docs = _setup_docs(tmp_path, stems)
+ (docs / "_swap.txt").write_text("quick-start\ncli\n")
+
+ call_count = {"n": 0}
+ real_rename = os.rename
+
+ def failing_rename(src, dst):
+ call_count["n"] += 1
+ if call_count["n"] == 2:
+ raise OSError("simulated rename failure")
+ real_rename(src, dst)
+
+ monkeypatch.setattr(os, "rename", failing_rename)
+
+ with pytest.raises(RuntimeError):
+ do_swap(docs)
+
+ # First rename (quick-start) should have been rolled back
+ assert (docs / "md-quick-start.md").exists(), "rollback should undo the first rename"
+ assert not (docs / "quick-start.md").exists(), "renamed file should not exist after rollback"
+
+ # No state file should exist
+ assert not (docs / "_build" / "_swap_state.json").exists()
+
+
+# ---------------------------------------------------------------------------
+# Nested paths
+# ---------------------------------------------------------------------------
+
+def test_swap_nested_paths(tmp_path):
+ from swap_sources import do_swap, do_restore
+
+ stems = ["configuration/firewall/zone"]
+ docs = _setup_docs(tmp_path, stems)
+ (docs / "_swap.txt").write_text("configuration/firewall/zone\n")
+
+ do_swap(docs)
+
+ swapped = docs / "configuration" / "firewall" / "zone.md"
+ md_source = docs / "configuration" / "firewall" / "md-zone.md"
+ assert swapped.exists(), "nested md file should be renamed"
+ assert not md_source.exists()
+
+ exclude_file = docs / "_build" / "_swap_exclude.txt"
+ lines = [l.strip() for l in exclude_file.read_text().splitlines() if l.strip()]
+ assert "configuration/firewall/zone.rst" in lines
+
+ do_restore(docs)
+
+ assert md_source.exists(), "nested md- file should be restored"
+ assert not swapped.exists()