forked from microsoft/PyRIT
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathvalidate_docs.py
More file actions
166 lines (136 loc) · 5.21 KB
/
Copy pathvalidate_docs.py
File metadata and controls
166 lines (136 loc) · 5.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
"""
Fast validation script for Jupyter Book 2 documentation.
Validates that all file references in doc/myst.yml exist and detects
orphaned documentation files. Designed to run quickly in pre-commit
instead of a full `jupyter-book build`.
Exit codes:
0: All validations passed
1: Validation errors found
"""
import sys
from pathlib import Path
import yaml
def parse_toc_files(toc_entries: list, files: set | None = None) -> set[str]:
"""Recursively extract all file references from myst.yml toc."""
if files is None:
files = set()
for entry in toc_entries:
if isinstance(entry, dict):
if "file" in entry:
files.add(entry["file"].replace("\\", "/"))
if "children" in entry:
parse_toc_files(entry["children"], files)
return files
def validate_toc_files(toc_files: set[str], doc_root: Path) -> list[str]:
"""Check that all files referenced in the TOC exist.
Auto-generated ``api/*.md`` pages are produced by
``build_scripts/gen_api_md.py`` and are gitignored, so they are skipped
while the ``doc/api/`` directory has not been generated yet (e.g. during
pre-commit). Once that directory exists (i.e. after a docs build), the
api/ entries are validated like any other file so the TOC stays in sync
with the generator output.
"""
skip_generated_api = not (doc_root / "api").exists()
api_prefixes = ("api/", "api\\")
errors = []
for file_ref in toc_files:
if skip_generated_api and file_ref.startswith(api_prefixes):
continue
file_path = doc_root / file_ref
if not file_path.exists():
errors.append(f"File referenced in myst.yml TOC not found: '{file_ref}'")
return errors
def find_orphaned_files(toc_files: set[str], doc_root: Path) -> list[str]:
"""Find documentation files not referenced in the TOC.
``doc/api/`` holds auto-generated reference pages. They are skipped while
the directory does not yet exist (pre-commit, before any docs build), but
once present they are checked for orphans so the TOC reflects exactly the
set of files produced by ``build_scripts/gen_api_md.py``.
"""
skip_dirs = {
"_build",
"_api",
"css",
".ipynb_checkpoints",
"__pycache__",
"playwright_demo",
"generate_docs",
}
if not (doc_root / "api").exists():
skip_dirs.add("api")
skip_files = {
"myst.yml",
"roakey.png",
"banner.png",
".gitignore",
"references.bib",
"requirements.txt",
}
# Normalize TOC references (strip extensions for comparison)
toc_stems = set()
for f in toc_files:
p = Path(f)
toc_stems.add(p.with_suffix("").as_posix())
toc_stems.add(p.as_posix())
orphaned = []
for file_path in doc_root.rglob("*"):
if file_path.is_dir():
continue
if any(skip_dir in file_path.parts for skip_dir in skip_dirs):
continue
if file_path.name in skip_files:
continue
if file_path.suffix not in [".md", ".ipynb", ".py", ".rst"]:
continue
# .py companion files for .ipynb are not orphaned
if file_path.suffix == ".py":
notebook_version = file_path.with_suffix(".ipynb")
if notebook_version.exists():
continue
rel = file_path.relative_to(doc_root)
rel_posix = rel.as_posix()
rel_stem = rel.with_suffix("").as_posix()
if rel_posix not in toc_stems and rel_stem not in toc_stems:
orphaned.append(str(rel))
return orphaned
def main() -> int:
script_dir = Path(__file__).parent
repo_root = script_dir.parent
doc_root = repo_root / "doc"
myst_yml = doc_root / "myst.yml"
if not myst_yml.exists():
print(f"ERROR: myst.yml not found at {myst_yml}", file=sys.stderr)
return 1
with open(myst_yml, encoding="utf-8") as f:
config = yaml.safe_load(f)
all_errors = []
# Validate TOC file references
toc = config.get("project", {}).get("toc", [])
print("Validating myst.yml TOC file references...")
toc_files = parse_toc_files(toc)
toc_errors = validate_toc_files(toc_files, doc_root)
if toc_errors:
all_errors.extend(f"[myst.yml] {err}" for err in toc_errors)
else:
print(f"[OK] Validated {len(toc_files)} file references in myst.yml")
# Check for orphaned files
print("Checking for orphaned documentation files...")
orphaned = find_orphaned_files(toc_files, doc_root)
if orphaned:
all_errors.extend(f"[orphaned] File exists but not in myst.yml: {f}" for f in orphaned)
else:
print("[OK] No orphaned documentation files found")
if all_errors:
print("\n" + "=" * 80, file=sys.stderr)
print("VALIDATION ERRORS FOUND:", file=sys.stderr)
print("=" * 80, file=sys.stderr)
for error in all_errors:
print(f" • {error}", file=sys.stderr)
print("=" * 80, file=sys.stderr)
return 1
print("\n[OK] All documentation validations passed!")
return 0
if __name__ == "__main__":
sys.exit(main())