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:
2026-05-30 18:14:33 +07:00
parent 71a0306c51
commit 0230587040
2 changed files with 157 additions and 29 deletions
+36 -4
View File
@@ -17,6 +17,7 @@
./py-export-api-docs.py requirements.txt
./py-export-api-docs.py /path/to/requirements.txt --recreate-venv
./py-export-api-docs.py requirements.txt --strict
./py-export-api-docs.py requirements.txt --keep-old-versions
```
Или через интерпретатор:
@@ -33,6 +34,7 @@ python3 py-export-api-docs.py requirements.txt
| `--venv-dir PATH` | Каталог виртуального окружения. По умолчанию: `/tmp/export-api-docs-<uid>-<hash>`, где `hash` — SHA256 от текущей рабочей директории |
| `--recreate-venv` | Удалить и пересоздать venv, переустановить пакеты |
| `--strict` | Завершить с кодом 1, если хотя бы один пакет или модуль не удалось экспортировать |
| `--keep-old-versions` | Не удалять предыдущие `api-<version>.md` и legacy `api.md` в каталогах модулей |
## Результат
@@ -40,12 +42,34 @@ python3 py-export-api-docs.py requirements.txt
```
docs/
├── README.md # индекс: таблица модулей и ссылок
├── README.md # индекс текущего экспорта
├── <import-module>/
── api.md # документация модуля
── api-<version>.md # документация модуля
│ └── ... # другие api-<version>.md — только с --keep-old-versions
└── ...
```
### Версия в файлах
В каждом `api-<version>.md` в начале указаны имя пакета, **фактически установленная** версия (из метаданных venv, не из спецификатора в `requirements.txt`) и import-модуль:
```markdown
<!-- py-export-api-docs: package=requests version=2.32.3 module=requests -->
> **Source:** `requests` **2.32.3** (import: `requests`)
```
Имя файла — filesystem-safe slug той же версии: небезопасные символы заменяются на `_` (например, `1.0+local``api-1.0+local.md`, `1.0 rc1``api-1.0_rc1.md`). В редких случаях разные строки версии могут дать один slug — тогда файл перезаписывается.
### Очистка и архив
| Режим | Поведение |
|-------|-----------|
| По умолчанию | После успешного экспорта удаляются другие `api-<version>.md` и legacy `api.md` в каталоге модуля |
| `--keep-old-versions` | Старые файлы остаются на диске как архив |
`docs/README.md` перезаписывается при каждом запуске и содержит только модули **текущего** экспорта — архивные файлы в индекс не попадают.
Для каждого пакета определяются корневые import-модули (из `top_level.txt` в метаданных дистрибутива, из встроенных fallback-таблиц или по эвристике). Каждый модуль экспортируется один раз, даже если несколько пакетов его предоставляют.
## Поддерживаемые строки requirements.txt
@@ -85,7 +109,7 @@ docs/
- небезопасное имя модуля;
- ошибка `pydoc-markdown` при экспорте.
Для пакетов без `top_level.txt` используется guess по имени дистрибутива (`my-package ``my_lib`) или встроенные соответствия (`python-gitlab``gitlab`, `pyyaml``yaml`, `pillow``PIL` и др.).
Для пакетов без `top_level.txt` используется guess по имени дистрибутива (`my-package``my_package`) или встроенные соответствия (`python-gitlab``gitlab`, `pyyaml``yaml`, `pillow``PIL` и др.).
## Пример вывода
@@ -93,6 +117,14 @@ docs/
Creating virtualenv: /tmp/export-api-docs-1000-a1b2c3d4e5f6
Installing packages into temporary virtualenv…
requests -> requests
wrote docs/requests/api.md (42 KiB)
wrote docs/requests/api-2.32.3.md (42 KiB)
Done. Documentation in /home/user/project/docs
```
Сгенерированный `docs/README.md`:
```markdown
| Module | Version | File |
|--------|---------|------|
| `requests` | `2.32.3` | [requests/api-2.32.3.md](requests/api-2.32.3.md) |
```
+121 -25
View File
@@ -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)