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:
@@ -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
@@ -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