From 2c5ae194ffd791c23c146929eb326b53cd21a8ed Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 6 Oct 2023 14:13:34 +1100 Subject: [PATCH] docs: setup mkdocs MkDocs is a popular tool in the Python ecosystem to generate documentation. It has support for parsing Python docstrings in order to generate API documentation quite easily. Signed-off-by: JP-Ellis --- .github/workflows/docs.yml | 55 ++++++++++++++ .pre-commit-config.yaml | 4 ++ docs/SUMMARY.md | 5 ++ docs/scripts/markdown.py | 62 ++++++++++++++++ docs/scripts/other.py | 117 ++++++++++++++++++++++++++++++ docs/scripts/python.py | 69 ++++++++++++++++++ docs/scripts/ruff.toml | 4 ++ mkdocs.yml | 143 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 13 +++- 9 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/SUMMARY.md create mode 100644 docs/scripts/markdown.py create mode 100644 docs/scripts/other.py create mode 100644 docs/scripts/python.py create mode 100644 docs/scripts/ruff.toml create mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..43ce2de74 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,55 @@ +name: docs + +on: + push: + +env: + STABLE_PYTHON_VERSION: "3.11" + PYTEST_ADDOPTS: --color=yes + +jobs: + build: + name: Build docs + + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.STABLE_PYTHON_VERSION }} + + - name: Install Hatch + run: pip install --upgrade hatch + + - name: Build docs + run: | + hatch run mkdocs build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v2 + with: + path: site + + publish: + name: Publish docs + + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1a4eb510f..fb0f09796 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,6 +23,10 @@ repos: - id: check-toml - id: check-xml - id: check-yaml + exclude: | + (?x)^( + mkdocs.yml + )$ - repo: https://gitlab.com/bmares/check-json5 rev: v1.0.0 diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 000000000..c76eb5d7d --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,5 @@ +- [Home](README.md) + - [Changelog](CHANGELOG.md) + - [Contributing](CONTRIBUTING.md) +- [Pact](pact/) +- [Examples](examples/) diff --git a/docs/scripts/markdown.py b/docs/scripts/markdown.py new file mode 100644 index 000000000..8333f437f --- /dev/null +++ b/docs/scripts/markdown.py @@ -0,0 +1,62 @@ +""" +Script to merge Markdown documentation from the main codebase into the docs. + +This script is run by mkdocs-gen-files when the documentation is built and +imports Markdown documentation from the main codebase so that it can be included +in the documentation site. For example, a Markdown file located at +`some/path/foo.md` will be treated as if it was located at +`docs/some/path/foo.md` without the need for symlinks or copying the file. + +If the destination file already exists (either because it is a real file, or was +otherwise already generated), the script will raise a RuntimeError. +""" + +import subprocess +import sys +from pathlib import Path + +import mkdocs_gen_files +from mkdocs_gen_files.editor import FilesEditor + +EDITOR = FilesEditor.current() + +# These paths are relative to the project root, *not* the current file. +SRC_ROOT = "." +DOCS_DEST = "." + +# List of all files version controlled files in the SRC_ROOT +ALL_FILES = sorted( + map( + Path, + subprocess.check_output(["git", "ls-files", SRC_ROOT]) # noqa: S603, S607 + .decode("utf-8") + .splitlines(), + ), +) + + +for source_path in filter(lambda p: p.suffix == ".md", ALL_FILES): + if source_path.parts[0] == "docs": + continue + dest_path = Path(DOCS_DEST, source_path) + + if str(dest_path) in EDITOR.files: + print( # noqa: T201 + f"Unable to copy {source_path} to {dest_path} because the file already" + " exists at the destination.", + file=sys.stderr, + ) + msg = f"File {dest_path} already exists." + raise RuntimeError(msg) + + with Path(source_path).open("r", encoding="utf-8") as fi, mkdocs_gen_files.open( + dest_path, + "w", + encoding="utf-8", + ) as fd: + fd.write(fi.read()) + + mkdocs_gen_files.set_edit_path( + dest_path, + f"https://github.com/pact-foundation/pact-python/edit/develop/{source_path}", + ) diff --git a/docs/scripts/other.py b/docs/scripts/other.py new file mode 100644 index 000000000..e6f16148e --- /dev/null +++ b/docs/scripts/other.py @@ -0,0 +1,117 @@ +""" +Create placeholder files for all other files in the codebase. + +This script is run by mkdocs-gen-files when the documentation is built and +creates placeholder files for all other files in the codebase. This is done so +that the documentation site can link to all files in the codebase, even if they +aren't part of the documentation proper. + +If the files are binary, they are copied as-is (e.g. for images), otherwise a +HTML redirect is created. + +If the destination file already exists (either because it is a real file, or was +otherwise already generated), the script will ignore the current file and +continue silently. +""" + +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING + +import mkdocs_gen_files +from mkdocs_gen_files.editor import FilesEditor + +if TYPE_CHECKING: + import io + +EDITOR = FilesEditor.current() + +# These paths are relative to the project root, *not* the current file. +SRC_ROOT = "." +DOCS_DEST = "." + +# List of all files version controlled files in the SRC_ROOT +ALL_FILES = sorted( + map( + Path, + subprocess.check_output(["git", "ls-files", SRC_ROOT]) # noqa: S603, S607 + .decode("utf-8") + .splitlines(), + ), +) + + +def is_binary(buffer: bytes) -> bool: + """ + Determine whether the given buffer is binary or not. + + The check is done by attempting to decode the buffer as UTF-8. If this + succeeds, the buffer is not binary. If it fails, the buffer is binary. + + The entire buffer will be checked, therefore if checking whether a file is + binary, only the start of the file should be passed. + + Args: + buffer: + The buffer to check. + + Returns: + True if the buffer is binary, False otherwise. + """ + try: + buffer.decode("utf-8") + except UnicodeDecodeError: + return True + else: + return False + + +for source_path in ALL_FILES: + if not source_path.is_file(): + continue + if source_path.parts[0] in ["docs"]: + continue + + dest_path = Path(DOCS_DEST, source_path) + + if str(dest_path) in EDITOR.files: + continue + + fi: "io.IOBase" + with Path(source_path).open("rb") as fi: + buf = fi.read(2048) + + if is_binary(buf): + if source_path.stat().st_size < 16 * 2**20: + # Copy the file only if it's less than 16MB. + with Path(source_path).open("rb") as fi, mkdocs_gen_files.open( + dest_path, + "wb", + ) as fd: + fd.write(fi.read()) + else: + # File is too big, create a redirect. + url = ( + "https://github.com" + "/pact-foundation/pact-python" + "/raw" + "/develop" + f"/{source_path}" + ) + with mkdocs_gen_files.open(dest_path, "w", encoding="utf-8") as fd: + fd.write(f'') + fd.write(f"# Redirecting to {url}...") + fd.write(f"[Click here if you are not redirected]({url})") + + mkdocs_gen_files.set_edit_path( + dest_path, + f"https://github.com/pact-foundation/pact-python/edit/develop/{source_path}", + ) + + else: + with Path(source_path).open("r", encoding="utf-8") as fi, mkdocs_gen_files.open( + dest_path, + "w", + encoding="utf-8", + ) as fd: + fd.write(fi.read()) diff --git a/docs/scripts/python.py b/docs/scripts/python.py new file mode 100644 index 000000000..abc0d96e2 --- /dev/null +++ b/docs/scripts/python.py @@ -0,0 +1,69 @@ +""" +Script used by mkdocs-gen-files to generate documentation for Pact Python. + +The script is run by mkdocs-gen-files when the documentation is built in order +to generate documentation from Python docstrings. +""" +import subprocess +from pathlib import Path +from typing import Union + +import mkdocs_gen_files + + +def process_python(src: str, dest: Union[str, None] = None) -> None: + """ + Process the Python files in the given directory. + + The source directory is relative to the root of the repository, and only + Python files which are version controlled are processed. The generated + documentation may optionally written to a different directory. + """ + dest = dest or src + + # List of all files version controlled files in the SRC_ROOT + files = sorted( + map( + Path, + subprocess.check_output(["git", "ls-files", src]) # noqa: S603, S607 + .decode("utf-8") + .splitlines(), + ), + ) + files = sorted(filter(lambda p: p.suffix == ".py", files)) + + for source_path in files: + module_path = source_path.relative_to(src).with_suffix("") + doc_path = source_path.relative_to(src).with_suffix(".md") + full_doc_path = Path(dest, doc_path) + + parts = [src, *module_path.parts] + + # Skip __main__ modules + if parts[-1] == "__main__": + continue + + # The __init__ modules are implicit in the directory structure. + if parts[-1] == "__init__": + parts = parts[:-1] + full_doc_path = full_doc_path.parent / "README.md" + + if full_doc_path.exists(): + with mkdocs_gen_files.open(full_doc_path, "a", encoding="utf-8") as fd: + python_identifier = ".".join(parts) + print("# " + parts[-1], file=fd) + print("::: " + python_identifier, file=fd) + else: + with mkdocs_gen_files.open(full_doc_path, "w", encoding="utf-8") as fd: + python_identifier = ".".join(parts) + print("# " + parts[-1], file=fd) + print("::: " + python_identifier, file=fd) + + mkdocs_gen_files.set_edit_path( + full_doc_path, + f"https://github.com/pact-foundation/pact-python/edit/master/pact/{module_path}.py", + ) + + +process_python("pact") +process_python("examples") diff --git a/docs/scripts/ruff.toml b/docs/scripts/ruff.toml new file mode 100644 index 000000000..fdd63b78d --- /dev/null +++ b/docs/scripts/ruff.toml @@ -0,0 +1,4 @@ +extend = "../../pyproject.toml" +ignore = [ + "INP001", # Forbid implicit namespaces +] diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000..942d73eaf --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,143 @@ +site_name: Pact Python +site_url: https://pact-foundation.github.io/pact-python/ + +repo_name: pact-foundation/pact-python +repo_url: https://github.com/pact-foundation/pact-python + +edit_uri: edit/develop/docs + +plugins: + - search + - literate-nav: + nav_file: SUMMARY.md + - section-index + # Library documentation + - gen-files: + scripts: + - docs/scripts/markdown.py + - docs/scripts/python.py + # - docs/scripts/other.py + - mkdocstrings: + default_handler: python + enable_inventory: true + handlers: + python: + import: + - https://docs.python.org/3/objects.inv + options: + # General + allow_inspection: true + show_source: true + show_bases: true + # Headings + heading_level: 2 + show_root_heading: false + show_root_toc_entry: true + show_root_full_path: true + show_root_members_full_path: false + show_object_full_path: false + show_category_heading: true + # Members + filters: + - "!^_" + - "!^__" + group_by_category: true + show_submodules: false + # Docstrings + docstring_style: google + docstring_options: + ignore_init_summary: true + docstring_section_style: spacy + merge_init_into_class: true + show_if_no_docstring: true + # Signature + annotations_path: brief + show_signature: true + show_signature_annotations: true + +markdown_extensions: + # Python Markdown + - abbr + - admonition + - attr_list + - def_list + - footnotes + - meta + - md_in_html + - tables + - toc: + permalink: true + + # Python Markdown Extensions + - pymdownx.arithmatex: + generic: true + - pymdownx.betterem: + smart_enable: all + - pymdownx.caret + - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.mark + - pymdownx.pathconverter: + absolute: false + - pymdownx.smartsymbols + - pymdownx.snippets + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tilde + +copyright: Copyright © 2023 Pact Foundation + +theme: + name: material + + icon: + repo: fontawesome/brands/github + + features: + - content.tooltips + - navigation.indexes + - navigation.instant + - navigation.sections + - navigation.tracking + - navigation.tabs + - navigation.top + - search.highlight + - search.share + - search.suggest + + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: teal + accent: purple + toggle: + icon: material/weather-sunny + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: lime + toggle: + icon: material/weather-night + name: Switch to system preference +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/pact-foundation/pact-python diff --git a/pyproject.toml b/pyproject.toml index 44bc2ed97..a563543d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,14 @@ types = [ "types-cffi ~= 1.0", "types-requests ~= 2.0", ] +docs = [ + "mkdocs ~= 1.5", + "mkdocs-material ~= 9.4", + "mkdocs_gen_files ~= 0.5", + "mkdocs-literate-nav ~= 0.6", + "mkdocs-section-index ~= 0.3", + "mkdocstrings[python] ~= 0.23", +] test = [ "aiohttp[speedups] ~= 3.0", "coverage[toml] ~= 7.0", @@ -79,8 +87,9 @@ test = [ dev = [ "pact-python[types]", "pact-python[test]", - "black ==23.11.0", - "ruff ==0.1.6", + "pact-python[docs]", + "black ==23.11.0", + "ruff ==0.1.6", ] ################################################################################