Source code for batools.apidocs
# Released under the MIT License. See LICENSE for details.
#
"""Finding the modules we flag as not-for-modders.
A documented module is flagged internal by carrying
:data:`INTERNAL_API_WARNING` in its module docstring. The docs build
shows such a module's page with its docstring only (so just the
warning and whatever explanation surrounds it), and the project checks
in ``batools.project._checks_apidocs`` keep the flag consistent. See
``docs/design/python-api-packages.md``.
"""
from __future__ import annotations # Docs-generation hack.
import ast
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
#: The exact block marking a module as internal api. Everything keys
#: off this text, so a reworded copy would silently stop counting.
INTERNAL_API_WARNING = (
'.. warning::\n'
'\n'
' This is an internal api and subject to change at any time. Do not'
' use\n'
' it in mod code.\n'
)
# Where the Sphinx docs pull Python from (see batools.docs); the
# dummy-module tree is generated from C++ and has no such docstrings.
_DOC_ROOTS = ('tools', 'src/assets/ba_data/python')
[docs]
class ApiModule:
"""What we need to know about one documented module."""
def __init__(self, name: str, path: Path, tree: ast.Module) -> None:
self.name = name
self.path = path
self.tree = tree
self.docstring = ast.get_docstring(tree, clean=False) or ''
self.flagged = INTERNAL_API_WARNING in self.docstring
self.is_package = path.name == '__init__.py'
[docs]
def gather_documented_modules(projroot: Path) -> dict[str, ApiModule]:
"""Parse every module that gets a docs page, keyed by dotted name."""
modules: dict[str, ApiModule] = {}
for rootname in _DOC_ROOTS:
root = projroot / rootname
for path in sorted(root.rglob('*.py')):
rel = path.relative_to(root)
if not _is_documented(rel):
continue
parts = list(rel.with_suffix('').parts)
if parts[-1] == '__init__':
parts = parts[:-1]
if not parts:
continue
name = '.'.join(parts)
tree = ast.parse(path.read_text(encoding='utf-8'))
modules[name] = ApiModule(name, path, tree)
return modules
[docs]
def flagged_packages(modules: dict[str, ApiModule]) -> set[str]:
"""Names of the flagged packages among some gathered modules."""
return {
mod.name for mod in modules.values() if mod.flagged and mod.is_package
}
[docs]
def flagged_ancestor(modname: str, flagged_pkgs: set[str]) -> str | None:
"""The nearest flagged package containing a module, if any."""
parts = modname.split('.')
for i in range(len(parts) - 1, 0, -1):
pkg = '.'.join(parts[:i])
if pkg in flagged_pkgs:
return pkg
return None
[docs]
def internal_api_modules(projroot: Path) -> set[str]:
"""Names of all documented modules that are internal api.
That is, flagged ones plus anything under a flagged package (which
the project checks require to be flagged as well).
"""
modules = gather_documented_modules(projroot)
pkgs = flagged_packages(modules)
return {
mod.name
for mod in modules.values()
if mod.flagged or flagged_ancestor(mod.name, pkgs) is not None
}
def _is_documented(rel: Path) -> bool:
"""Whether a module (path relative to a docs root) gets a page.
Mirrors batools.docs: _single-underscore files and dirs are private
implementation detail; tools/spinoff is a symlink excluded there.
"""
if '__pycache__' in rel.parts or rel.parts[0] == 'spinoff':
return False
return not any(
part.startswith('_') and not part.startswith('__') for part in rel.parts
)
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self, TypeIs)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum