Add installed package version to exported API docs.
Write api-<version>.md with version metadata in each file and index, sanitize output for safe paths/markdown, atomically replace exports, and add --keep-old-versions to retain prior exports as an archive.
This commit is contained in:
+121
-25
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export Markdown API docs for packages listed in requirements.txt.
|
||||
|
||||
Creates docs/<import-module>/api.md under the current working directory using a
|
||||
Creates docs/<import-module>/api-<version>.md under the current working directory using a
|
||||
temporary virtualenv in /tmp (does not install into the system Python).
|
||||
|
||||
Supported requirements lines: PEP 508 name specs and ``-r`` includes (must stay
|
||||
@@ -66,6 +66,11 @@ def parse_args() -> argparse.Namespace:
|
||||
action="store_true",
|
||||
help="Exit with failure if any package or module export fails",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-old-versions",
|
||||
action="store_true",
|
||||
help="Keep previous api-<version>.md and legacy api.md files in each module directory",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -274,6 +279,59 @@ def is_safe_module_name(module: str) -> bool:
|
||||
return MODULE_RE.fullmatch(module) is not None
|
||||
|
||||
|
||||
def distribution_version(dist: im.Distribution) -> str:
|
||||
try:
|
||||
version = dist.version
|
||||
if version:
|
||||
return version
|
||||
except (im.PackageNotFoundError, AttributeError):
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
def safe_metadata_value(value: str) -> str:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
return "unknown"
|
||||
cleaned = re.sub(r"[\x00-\x1f\x7f]", "", cleaned)
|
||||
return cleaned.replace("-->", "-- >")
|
||||
|
||||
|
||||
def safe_markdown_inline(value: str) -> str:
|
||||
return (
|
||||
safe_metadata_value(value)
|
||||
.replace("\\", "\\\\")
|
||||
.replace("`", "\\`")
|
||||
.replace("|", "\\|")
|
||||
)
|
||||
|
||||
|
||||
def safe_version_for_filename(version: str) -> str:
|
||||
cleaned = re.sub(r"[^\w.+-]", "_", safe_metadata_value(version))
|
||||
return cleaned or "unknown"
|
||||
|
||||
|
||||
def export_doc_header(*, package_name: str, package_version: str, module: str) -> str:
|
||||
name = safe_markdown_inline(package_name)
|
||||
version = safe_markdown_inline(package_version)
|
||||
module_name = safe_markdown_inline(module)
|
||||
return (
|
||||
f"<!-- py-export-api-docs: package={safe_metadata_value(package_name)} "
|
||||
f"version={safe_metadata_value(package_version)} "
|
||||
f"module={safe_metadata_value(module)} -->\n\n"
|
||||
f"> **Source:** `{name}` **{version}** "
|
||||
f"(import: `{module_name}`)\n\n"
|
||||
"---\n\n"
|
||||
)
|
||||
|
||||
|
||||
def remove_stale_api_exports(out_dir: Path, keep: Path) -> None:
|
||||
for pattern in ("api-*.md", "api.md"):
|
||||
for old in out_dir.glob(pattern):
|
||||
if old != keep:
|
||||
old.unlink()
|
||||
|
||||
|
||||
def safe_output_dir(docs_root: Path, module: str) -> Path | None:
|
||||
if not is_safe_module_name(module):
|
||||
return None
|
||||
@@ -321,38 +379,63 @@ def export_module(
|
||||
site_pkgs: Path,
|
||||
module: str,
|
||||
out_dir: Path,
|
||||
*,
|
||||
package_name: str,
|
||||
package_version: str,
|
||||
keep_old_versions: bool = False,
|
||||
) -> Path:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
api_md = out_dir / "api.md"
|
||||
with api_md.open("w", encoding="utf-8") as handle:
|
||||
run(
|
||||
[
|
||||
str(pydoc_markdown),
|
||||
"-p",
|
||||
module,
|
||||
"-I",
|
||||
str(site_pkgs),
|
||||
"-q",
|
||||
],
|
||||
stdout=handle,
|
||||
)
|
||||
if not api_md.is_file() or api_md.stat().st_size == 0:
|
||||
raise FileNotFoundError(f"Expected non-empty output file: {api_md}")
|
||||
version_tag = safe_version_for_filename(package_version)
|
||||
api_md = out_dir / f"api-{version_tag}.md"
|
||||
header = export_doc_header(
|
||||
package_name=package_name,
|
||||
package_version=package_version,
|
||||
module=module,
|
||||
)
|
||||
body_tmp = out_dir / f".{module}.pydoc.tmp"
|
||||
export_tmp = out_dir / f".{module}.export.tmp"
|
||||
try:
|
||||
with body_tmp.open("w", encoding="utf-8") as handle:
|
||||
subprocess.run(
|
||||
[
|
||||
str(pydoc_markdown),
|
||||
"-p",
|
||||
module,
|
||||
"-I",
|
||||
str(site_pkgs),
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
stdout=handle,
|
||||
)
|
||||
if body_tmp.stat().st_size == 0:
|
||||
raise RuntimeError(f"pydoc-markdown produced no output for {module!r}")
|
||||
with export_tmp.open("w", encoding="utf-8") as handle:
|
||||
handle.write(header)
|
||||
with body_tmp.open("r", encoding="utf-8") as body:
|
||||
shutil.copyfileobj(body, handle)
|
||||
os.replace(export_tmp, api_md)
|
||||
if not keep_old_versions:
|
||||
remove_stale_api_exports(out_dir, api_md)
|
||||
finally:
|
||||
body_tmp.unlink(missing_ok=True)
|
||||
export_tmp.unlink(missing_ok=True)
|
||||
return api_md
|
||||
|
||||
|
||||
def write_docs_index(docs_root: Path, exports: list[tuple[str, Path]]) -> None:
|
||||
def write_docs_index(docs_root: Path, exports: list[tuple[str, str, Path]]) -> None:
|
||||
lines = [
|
||||
"# API documentation (exported)",
|
||||
"",
|
||||
"Generated from `requirements.txt` via `scripts/export-api-docs.py`.",
|
||||
"Generated from `requirements.txt` via `py-export-api-docs.py`.",
|
||||
"",
|
||||
"| Module | File |",
|
||||
"|--------|------|",
|
||||
"| Module | Version | File |",
|
||||
"|--------|---------|------|",
|
||||
]
|
||||
for module, api_md in sorted(exports):
|
||||
for module, version, api_md in sorted(exports):
|
||||
rel = api_md.relative_to(docs_root)
|
||||
lines.append(f"| `{module}` | [{rel}]({rel}) |")
|
||||
safe_version = safe_markdown_inline(version)
|
||||
lines.append(f"| `{module}` | `{safe_version}` | [{rel}]({rel}) |")
|
||||
(docs_root / "README.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
@@ -395,7 +478,7 @@ def main() -> int:
|
||||
)
|
||||
return 1
|
||||
|
||||
exports: list[tuple[str, Path]] = []
|
||||
exports: list[tuple[str, str, Path]] = []
|
||||
seen_modules: set[str] = set()
|
||||
failures = 0
|
||||
|
||||
@@ -407,6 +490,7 @@ def main() -> int:
|
||||
continue
|
||||
|
||||
display_name = dist.metadata.get("Name", req_name)
|
||||
package_version = distribution_version(dist)
|
||||
modules = import_modules_for_distribution(dist)
|
||||
if not modules:
|
||||
print(
|
||||
@@ -430,11 +514,23 @@ def main() -> int:
|
||||
continue
|
||||
|
||||
try:
|
||||
api_md = export_module(pydoc_markdown, site_pkgs, module, out_dir)
|
||||
api_md = export_module(
|
||||
pydoc_markdown,
|
||||
site_pkgs,
|
||||
module,
|
||||
out_dir,
|
||||
package_name=display_name,
|
||||
package_version=package_version,
|
||||
keep_old_versions=args.keep_old_versions,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"warning: failed to export {module}: {exc}", file=sys.stderr)
|
||||
failures += 1
|
||||
continue
|
||||
except RuntimeError as exc:
|
||||
print(f"warning: failed to export {module}: {exc}", file=sys.stderr)
|
||||
failures += 1
|
||||
continue
|
||||
except OSError as exc:
|
||||
print(f"warning: failed to export {module}: {exc}", file=sys.stderr)
|
||||
failures += 1
|
||||
@@ -442,7 +538,7 @@ def main() -> int:
|
||||
|
||||
size_kb = api_md.stat().st_size // 1024
|
||||
print(f" wrote {api_md} ({size_kb} KiB)", file=sys.stderr)
|
||||
exports.append((module, api_md))
|
||||
exports.append((module, package_version, api_md))
|
||||
|
||||
if exports:
|
||||
write_docs_index(docs_root, exports)
|
||||
|
||||
Reference in New Issue
Block a user