Skip to content

api_reference_docs

create_api_reference_docs task.

Create Python API reference in the documentation. This is specifically to be used with the MkDocs and mkdocstrings framework.

create_api_reference_docs(context, package_dir, pre_clean=False, pre_commit=False, root_repo_path='.', docs_folder='docs', unwanted_folder=None, unwanted_file=None, full_docs_folder=None, full_docs_file=None, special_option=None, relative=False, debug=False)

Create the Python API Reference in the documentation.

Source code in ci_cd/tasks/api_reference_docs.py
@task(
    help={
        "package-dir": (
            "Relative path to a package dir from the repository root, "
            "e.g., 'src/my_package'. This input option can be supplied multiple times."
        ),
        "pre-clean": "Remove the 'api_reference' sub directory prior to (re)creation.",
        "pre-commit": (
            "Whether or not this task is run as a pre-commit hook. Will return a "
            "non-zero error code if changes were made."
        ),
        "root-repo-path": (
            "A resolvable path to the root directory of the repository folder."
        ),
        "docs-folder": (
            "The folder name for the documentation root folder. "
            "This defaults to 'docs'."
        ),
        "unwanted-folder": (
            "A folder to avoid including into the Python API reference documentation. "
            "Note, only folder names, not paths, may be included. Note, all folders "
            "and their contents with this name will be excluded. Defaults to "
            "'__pycache__'. This input option can be supplied multiple times."
        ),
        "unwanted-file": (
            "A file to avoid including into the Python API reference documentation. "
            "Note, only full file names, not paths, may be included, i.e., filename + "
            "file extension. Note, all files with this names will be excluded. "
            "Defaults to '__init__.py'. This input option can be supplied multiple "
            "times."
        ),
        "full-docs-folder": (
            "A folder in which to include everything - even those without "
            "documentation strings. This may be useful for a module full of data "
            "models or to ensure all class attributes are listed. This input option "
            "can be supplied multiple times."
        ),
        "full-docs-file": (
            "A full relative path to a file in which to include everything - even "
            "those without documentation strings. This may be useful for a file full "
            "of data models or to ensure all class attributes are listed. This input "
            "option can be supplied multiple times."
        ),
        "special-option": (
            "A combination of a relative path to a file and a fully formed "
            "mkdocstrings option that should be added to the generated MarkDown file. "
            "The combination should be comma-separated. Example: "
            "'my_module/py_file.py,show_bases:false'. Encapsulate the value in double "
            'quotation marks (") if including spaces ( ). Important: If multiple '
            "package-dir options are supplied, the relative path MUST include/start "
            "with the package-dir value, e.g., "
            "'\"my_package/my_module/py_file.py,show_bases: false\"'. This input "
            "option can be supplied multiple times. The options will be accumulated "
            "for the same file, if given several times."
        ),
        "relative": (
            "Whether or not to use relative Python import links in the API reference "
            "markdown files."
        ),
        "debug": "Whether or not to print debug statements.",
    },
    iterable=[
        "package_dir",
        "unwanted_folder",
        "unwanted_file",
        "full_docs_folder",
        "full_docs_file",
        "special_option",
    ],
)
def create_api_reference_docs(
    context,
    package_dir,
    pre_clean=False,
    pre_commit=False,
    root_repo_path=".",
    docs_folder="docs",
    unwanted_folder=None,
    unwanted_file=None,
    full_docs_folder=None,
    full_docs_file=None,
    special_option=None,
    relative=False,
    debug=False,
):
    """Create the Python API Reference in the documentation."""
    if TYPE_CHECKING:  # pragma: no cover
        context: Context = context  # type: ignore[no-redef]
        pre_clean: bool = pre_clean  # type: ignore[no-redef]
        pre_commit: bool = pre_commit  # type: ignore[no-redef]
        root_repo_path: str = root_repo_path  # type: ignore[no-redef]
        docs_folder: str = docs_folder  # type: ignore[no-redef]
        relative: bool = relative  # type: ignore[no-redef]
        debug: bool = debug  # type: ignore[no-redef]

    if not unwanted_folder:
        unwanted_folder: list[str] = ["__pycache__"]  # type: ignore[no-redef]
    if not unwanted_file:
        unwanted_file: list[str] = ["__init__.py"]  # type: ignore[no-redef]
    if not full_docs_folder:
        full_docs_folder: list[str] = []  # type: ignore[no-redef]
    if not full_docs_file:
        full_docs_file: list[str] = []  # type: ignore[no-redef]
    if not special_option:
        special_option: list[str] = []  # type: ignore[no-redef]

    # Initialize user-given paths as pure POSIX paths
    package_dir: list[PurePosixPath] = [PurePosixPath(_) for _ in package_dir]
    root_repo_path = str(PurePosixPath(root_repo_path))
    docs_folder: PurePosixPath = PurePosixPath(docs_folder)  # type: ignore[no-redef]
    full_docs_folder = [Path(PurePosixPath(_)) for _ in full_docs_folder]

    def write_file(full_path: Path, content: str) -> None:
        """Write file with `content` to `full_path`"""
        if full_path.exists():
            cached_content = full_path.read_text(encoding="utf8")
            if content == cached_content:
                del cached_content
                return
            del cached_content
        full_path.write_text(content, encoding="utf8")

    if pre_commit:
        # Ensure git is installed
        result: Result = context.run("git --version", hide=True)
        if result.exited != 0:
            sys.exit(
                "Git is not installed. Please install it before running this task."
            )

    if pre_commit and root_repo_path == ".":
        # Use git to determine repo root
        result = context.run("git rev-parse --show-toplevel", hide=True)
        root_repo_path = result.stdout.strip("\n")  # type: ignore[no-redef]

    root_repo_path: Path = Path(root_repo_path).resolve()  # type: ignore[no-redef]
    package_dirs: list[Path] = [Path(root_repo_path / _) for _ in package_dir]
    docs_api_ref_dir = Path(root_repo_path / docs_folder / "api_reference")

    LOGGER.debug(
        """package_dirs: %s
docs_api_ref_dir: %s
unwanted_folder: %s
unwanted_file: %s
full_docs_folder: %s
full_docs_file: %s
special_option: %s""",
        package_dirs,
        docs_api_ref_dir,
        unwanted_folder,
        unwanted_file,
        full_docs_folder,
        full_docs_file,
        special_option,
    )
    if debug:
        print("package_dirs:", package_dirs, flush=True)
        print("docs_api_ref_dir:", docs_api_ref_dir, flush=True)
        print("unwanted_folder:", unwanted_folder, flush=True)
        print("unwanted_file:", unwanted_file, flush=True)
        print("full_docs_folder:", full_docs_folder, flush=True)
        print("full_docs_file:", full_docs_file, flush=True)
        print("special_option:", special_option, flush=True)

    special_options_files = defaultdict(list)
    for special_file, option in [_.split(",", maxsplit=1) for _ in special_option]:
        if any("," in _ for _ in (special_file, option)):
            LOGGER.error(
                "Failing for special-option: %s", ",".join([special_file, option])
            )
            if debug:
                print(
                    "Failing for special-option:",
                    ",".join([special_file, option]),
                    flush=True,
                )
            sys.exit(
                "special-option values may only include a single comma (,) to "
                "separate the relative file path and the mkdocstsrings option."
            )
        special_options_files[special_file].append(option)

    LOGGER.debug("special_options_files: %s", special_options_files)
    if debug:
        print("special_options_files:", special_options_files, flush=True)

    if any(os.sep in _ or "/" in _ for _ in unwanted_folder + unwanted_file):
        sys.exit("Unwanted folders and files may NOT be paths.")

    pages_template = 'title: "{name}"\n'
    md_template = "# {name}\n\n::: {py_path}\n"
    no_docstring_template_addition = (
        f"{' ' * 4}options:\n{' ' * 6}show_if_no_docstring: true\n"
    )

    if docs_api_ref_dir.exists() and pre_clean:
        LOGGER.debug("Removing %s", docs_api_ref_dir)
        if debug:
            print(f"Removing {docs_api_ref_dir}", flush=True)
        shutil.rmtree(docs_api_ref_dir, ignore_errors=True)
        if docs_api_ref_dir.exists():
            sys.exit(f"{docs_api_ref_dir} should have been removed!")
    docs_api_ref_dir.mkdir(exist_ok=True)

    LOGGER.debug("Writing file: %s", docs_api_ref_dir / ".pages")
    if debug:
        print(f"Writing file: {docs_api_ref_dir / '.pages'}", flush=True)
    write_file(
        full_path=docs_api_ref_dir / ".pages",
        content=pages_template.format(name="API Reference"),
    )

    single_package = len(package_dirs) == 1
    for package in package_dirs:
        for dirpath, dirnames, filenames in os.walk(package):
            for unwanted in unwanted_folder:
                LOGGER.debug("unwanted: %s\ndirnames: %s", unwanted, dirnames)
                if debug:
                    print("unwanted:", unwanted, flush=True)
                    print("dirnames:", dirnames, flush=True)
                if unwanted in dirnames:
                    # Avoid walking into or through unwanted directories
                    dirnames.remove(unwanted)

            relpath = Path(dirpath).relative_to(
                package if single_package else package.parent
            )
            abspath = (
                package / relpath if single_package else package.parent / relpath
            ).resolve()
            LOGGER.debug("relpath: %s\nabspath: %s", relpath, abspath)
            if debug:
                print("relpath:", relpath, flush=True)
                print("abspath:", abspath, flush=True)

            if not (abspath / "__init__.py").exists():
                # Avoid paths that are not included in the public Python API
                LOGGER.debug("does not exist: %s", abspath / "__init__.py")
                print("does not exist:", abspath / "__init__.py", flush=True)
                continue

            # Create `.pages`
            docs_sub_dir = docs_api_ref_dir / relpath
            docs_sub_dir.mkdir(exist_ok=True)
            LOGGER.debug("docs_sub_dir: %s", docs_sub_dir)
            if debug:
                print("docs_sub_dir:", docs_sub_dir, flush=True)
            if str(relpath) != ".":
                LOGGER.debug("Writing file: %s", docs_sub_dir / ".pages")
                if debug:
                    print(f"Writing file: {docs_sub_dir / '.pages'}", flush=True)
                write_file(
                    full_path=docs_sub_dir / ".pages",
                    content=pages_template.format(name=relpath.name),
                )

            # Create markdown files
            for filename in (Path(_) for _ in filenames):
                if (
                    re.match(r".*\.py$", str(filename)) is None
                    or str(filename) in unwanted_file
                ):
                    # Not a Python file: We don't care about it!
                    # Or filename is in the list of unwanted files:
                    # We don't want it!
                    LOGGER.debug(
                        "%s is not a Python file or is an unwanted file (through user "
                        "input). Skipping it.",
                        filename,
                    )
                    if debug:
                        print(
                            f"{filename} is not a Python file or is an unwanted file "
                            "(through user input). Skipping it.",
                            flush=True,
                        )
                    continue

                py_path_root = (
                    package.relative_to(root_repo_path) if relative else package.name
                )
                py_path = (
                    f"{py_path_root}/{filename.stem}"
                    if str(relpath) == "."
                    or (str(relpath) == package.name and not single_package)
                    else (
                        f"{py_path_root}/"
                        f"{relpath if single_package else relpath.relative_to(package.name)}/"  # noqa: E501
                        f"{filename.stem}"
                    )
                )

                # Replace OS specific path separators with forward slashes before
                # replacing that with dots (for Python import paths).
                py_path = py_path.replace(os.sep, "/").replace("/", ".")

                LOGGER.debug("filename: %s\npy_path: %s", filename, py_path)
                if debug:
                    print("filename:", filename, flush=True)
                    print("py_path:", py_path, flush=True)

                relative_file_path = Path(
                    str(filename) if str(relpath) == "." else str(relpath / filename)
                ).as_posix()

                # For special files we want to include EVERYTHING, even if it doesn't
                # have a doc-string
                template = md_template + (
                    no_docstring_template_addition
                    if relative_file_path in full_docs_file
                    or relpath in full_docs_folder
                    else ""
                )

                # Include special options, if any, for certain files.
                if relative_file_path in special_options_files:
                    template += (
                        f"{' ' * 4}options:\n" if "options:\n" not in template else ""
                    )
                    template += "\n".join(
                        f"{' ' * 6}{option}"
                        for option in special_options_files[relative_file_path]
                    )
                    template += "\n"

                LOGGER.debug(
                    "template: %s\nWriting file: %s",
                    template,
                    docs_sub_dir / filename.with_suffix(".md"),
                )
                if debug:
                    print("template:", template, flush=True)
                    print(
                        f"Writing file: {docs_sub_dir / filename.with_suffix('.md')}",
                        flush=True,
                    )

                write_file(
                    full_path=docs_sub_dir / filename.with_suffix(".md"),
                    content=template.format(name=filename.stem, py_path=py_path),
                )

    if pre_commit:
        # Check if there have been any changes.
        # List changes if yes.

        # NOTE: Concerning the weird regular expression, see:
        # http://manpages.ubuntu.com/manpages/precise/en/man1/git-status.1.html
        result = context.run(
            f'git -C "{root_repo_path}" status --porcelain '
            f"{docs_api_ref_dir.relative_to(root_repo_path)}",
            hide=True,
        )
        if result.stdout:
            for line in result.stdout.splitlines():
                if re.match(r"^[? MARC][?MD]", line):
                    sys.exit(
                        f"{Emoji.CURLY_LOOP.value} The following files have been "
                        f"changed/added/removed:\n\n{result.stdout}\n"
                        "Please stage them:\n\n"
                        f"  git add {docs_api_ref_dir.relative_to(root_repo_path)}"
                    )
        print(
            f"{Emoji.CHECK_MARK.value} No changes - your API reference documentation "
            "is up-to-date !"
        )