diff --git a/README.md b/README.md index f54b896..749228b 100644 --- a/README.md +++ b/README.md @@ -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--`, где `hash` — SHA256 от текущей рабочей директории | | `--recreate-venv` | Удалить и пересоздать venv, переустановить пакеты | | `--strict` | Завершить с кодом 1, если хотя бы один пакет или модуль не удалось экспортировать | +| `--keep-old-versions` | Не удалять предыдущие `api-.md` и legacy `api.md` в каталогах модулей | ## Результат @@ -40,12 +42,34 @@ python3 py-export-api-docs.py requirements.txt ``` docs/ -├── README.md # индекс: таблица модулей и ссылок +├── README.md # индекс текущего экспорта ├── / -│ └── api.md # документация модуля +│ ├── api-.md # документация модуля +│ └── ... # другие api-.md — только с --keep-old-versions └── ... ``` +### Версия в файлах + +В каждом `api-.md` в начале указаны имя пакета, **фактически установленная** версия (из метаданных venv, не из спецификатора в `requirements.txt`) и import-модуль: + +```markdown + + +> **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-.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) | +``` diff --git a/py-export-api-docs.py b/py-export-api-docs.py index 77329fb..45361c6 100755 --- a/py-export-api-docs.py +++ b/py-export-api-docs.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Export Markdown API docs for packages listed in requirements.txt. -Creates docs//api.md under the current working directory using a +Creates docs//api-.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-.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"\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)