Skip to content

Documentation#

This project uses MkDocs with the Material for MkDocs theme.

Configuration#

mkdocs.yaml Configuration File
site_name: Project Template for Python

repo_url: https://github.com/jannismain/python-project-template
repo_name: python-project-template
site_url: https://jannismain.github.io/python-project-template/
edit_uri: -/edit/main/docs/
site_dir: build/docs

theme: # https://squidfunk.github.io/mkdocs-material/setup/
  name: material
  icon:
    logo: material/satellite-variant
    tag:
      doc: material/book-open-page-variant
      test: material/test-tube
      vcs: octicons/git-branch-16
      default: material/tag
  favicon: assets/material-satellite-variant.svg
  features:
    - navigation.instant
    - navigation.tracking
    - navigation.indexes
    - navigation.top
    - navigation.tabs
    - navigation.sections
    - navigation.prune
    - content.code.annotate
    - toc.follow
    - navigation.footer
  palette:
    - media: "(prefers-color-scheme: light)"
      scheme: default
      primary: teal
      accent: deep orange
      toggle:
        icon: material/weather-sunny
        name: Switch to dark mode
    - media: "(prefers-color-scheme: dark)"
      scheme: slate
      primary: teal
      accent: amber
      toggle:
        icon: material/weather-night
        name: Switch to light mode

plugins:
  - search
  - git-revision-date-localized:
      type: timeago
      fallback_to_build_date: true
  - exclude:
      glob:
        - util/*
  - literate-nav:
      nav_file: _nav.md
      implicit_index: true
  - macros: # see https://mkdocs-macros-plugin.readthedocs.io/
      include_dir: .
      module_name: docs/util/macros
      modules: [includex]
  - autorefs
  - tags:
      enabled: !ENV [CI, true]
      tags_file: reference/tooling/index.md

markdown_extensions:
  - abbr
  - meta
  - pymdownx.inlinehilite
  - pymdownx.snippets:
      check_paths: true
      url_download: true
      auto_append:
        - docs/util/abbreviations.md
  - pymdownx.highlight:
      anchor_linenums: true
  - pymdownx.superfences:
      custom_fences:
        - name: mermaid
          class: mermaid
          format: !!python/name:pymdownx.superfences.fence_code_format
  - pymdownx.magiclink:
      hide_protocol: True
      provider: gitlab
      user: mkj
      repo: project-template
      repo_url_shortener: True
      repo_url_shorthand: True
  - footnotes
  - pymdownx.tabbed:
      alternate_style: true
  - admonition
  - pymdownx.details
  - md_in_html
  - toc:
      marker: ""
      permalink: "#"
      permalink_title: "Link to this section"
      toc_depth: 3
  # emoji support: https://squidfunk.github.io/mkdocs-material/reference/icons-emojis/
  - attr_list
  - pymdownx.emoji:
      emoji_index: !!python/name:material.extensions.emoji.twemoji
      emoji_generator: !!python/name:material.extensions.emoji.to_svg
  - pymdownx.keys
  - def_list
  - pymdownx.critic

extra_javascript:
  - assets/magiclink.js

extra_css:
  - assets/magiclink.css
  - assets/custom.css

extra:
  generator: false
  URL_EXAMPLE_FILE:
    !ENV [
      URL_EXAMPLE_FILE,
      https://github.com/jannismain/python-project-template-example/blob/main,
    ]
  tags:
    Documentation: doc
    Testing: test
    Continuous Integration: ci
    Version Control: vcs

The navigation is setup using mkdocs-literate-nav and managed in the _nav.md file:

- [Home](./index.md)
- User Guide
    - [Getting Started](./user-guide/getting-started.md)
    - [Creating your first project](./user-guide/first-project.md)
    - [Next steps](./user-guide/next-steps.md)
    - [About the project structure](./user-guide/project-structure.md)
    - user-guide/*
    - Topics
        - user-guide/topics/*
- [Reference](./reference/)
- [Template Developer Guide](./developer-guide/)

Macros#

Jinja macros are provided by mkdocs-macros and can be configured via the macros.py file:

docs/util/macros.py
"""Documentation macros.

[`mkdocs-macros-plugin` Documentation](https://mkdocs-macros-plugin.readthedocs.io/)
"""

import hashlib
import json
import logging
import os
import pathlib
import re
import shlex
import subprocess
import tomllib
import unicodedata

import pymdownx.magiclink
import yaml
from mkdocs_macros.plugin import MacrosPlugin

# patch for private gitlab instance
base_url = "https://git01.iis.fhg.de"
pymdownx.magiclink.PROVIDER_INFO["gitlab"].update(
    {
        "url": base_url,
        "issue": "%s/{}/{}/issues/{}" % base_url,
        "pull": "%s/{}/{}/merge_requests/{}" % base_url,
        "commit": "%s/{}/{}/commit/{}" % base_url,
        "compare": "%s/{}/{}/compare/{}...{}" % base_url,
    }
)

root = pathlib.Path(__file__).parent.parent.parent

log = logging.getLogger("mkdocs.mkdocs_macros")


def define_env(env: MacrosPlugin):
    """Define variables, macros and filters for mkdocs-macros."""

    @env.filter
    def pretty_json(s, indent=2, **kwargs):
        return json.dumps(s, indent=indent, **kwargs)

    @env.filter
    def pretty_json_obj(s, indent=2, indent_char=" "):
        r = ""
        indentation = ""
        prev = ""
        for c in s:
            if c == "{":
                indentation += indent * indent_char
                r += c + "\n" + indentation
            elif c == "}":
                indentation = indentation[:-indent]
                r += "\n" + indentation + c
            elif c == ",":
                r += c + "\n" + indentation
            elif c == " " and prev != ":":
                pass
            else:
                r += c
            prev = c
        return r

    env.macro(read_toml)
    env.macro(read_yaml)
    env.macro(get_files)
    env.macro(run)

    env.variables["questions"] = {
        k: v
        for k, v in read_yaml(root / "copier.yaml").items()
        if not k.startswith("_") and "explanation" in v
    }


def read_toml(filepath: pathlib.Path):
    filepath = pathlib.Path(filepath)
    with filepath.open("rb") as f:
        return tomllib.load(f)


def read_yaml(filepath: pathlib.Path):
    filepath = pathlib.Path(filepath)
    with filepath.open("r") as f:
        return yaml.safe_load(f)


def get_files(directory: str | pathlib.Path, match: str = "", ignore: str = "") -> list[str]:
    """Return list of files in *directory* that match the provided substring.

    Args:
        directory: path to directory
        match: only files that contain this string will be included

    Returns:
        List of files in *directory*
    """
    rv = []
    try:
        directory = pathlib.Path(directory)
        assert directory.is_dir()
        for file in sorted(os.listdir(directory)):
            if match and match not in file:
                continue
            if ignore and ignore in file:
                continue
            rv.append(file)
    except Exception as e:
        rv.append(f"Error: {e}")
    return rv


fp_cli_command_output_cache = root / "build" / ".docs_cache"
fp_cli_command_output_cache.mkdir(parents=True, exist_ok=True)


def run(
    command,
    *args,
    setup: list = None,
    skip_lines=0,
    show_command=False,
    skip_cache=False,
    should_exit_with_error=False,
    cwd="",
):
    if setup is None:
        setup = []

    # ensure arguments are all strings
    setup = [str(x) for x in setup]
    if command.startswith("$ "):
        command = command[1:]
        show_command = True
    if " " in command:
        command = shlex.split(command)
    else:
        command = [command, *[str(x) for x in args]]

    filename = _get_filename(command, setup, cwd)
    fp_cached_command = fp_cli_command_output_cache / filename

    if skip_cache or not fp_cached_command.is_file():
        log.info("Generating output for: %s", " ".join(setup) + " " + " ".join(command))
        kwargs = {}
        if cwd:
            cwd = root / pathlib.Path(cwd)
            kwargs["cwd"] = cwd
        result = subprocess.run(
            [*setup, *command],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            check=not should_exit_with_error,  # use to catch issues with any cli command
            **kwargs,
        )
        rv = result.stdout
        if (
            result.returncode != 0 and not should_exit_with_error
        ):  # We check ourselves, in order to log the output
            log.error(
                f"{' '.join(setup)} {' '.join(command)} failed with return code {result.returncode}"
            )
            log.error(rv.decode())
        fp_cached_command.open("wb").write(rv)

    output = fp_cached_command.open().read()

    if skip_lines:
        output = "\n".join(output.split("\n")[skip_lines:])

    if show_command:
        command_str = f"$ {' '.join(command)}"
        output = command_str + "\n" + output

    return output


def _get_filename(args: list[str], setup: list[str] = None, cwd: str = "") -> str:
    """Create filename with human-readable prefix and unique suffix for given args and setup.

    Filenames include ascii-characters only and strip other characters problematic for certain filesystems.

    Human-readable prefix will be generated from args alone.
    Unique suffix (i.e. hash) will be generated from both args and setup.

    Args:
        args: list of cli arguments
        setup: list of cli setup commands. Defaults to [].

    Returns:
        filename of the format: {args}_{hash}
    """
    sha_hash = hashlib.sha1(("".join([*setup, *args, cwd])).encode()).hexdigest()
    prog = pathlib.Path(args[0]).name
    filename = f"{prog}_{'_'.join(args[1:])}_{sha_hash}"
    filename = unicodedata.normalize("NFKD", filename).encode("ascii", "ignore").decode("ascii")
    filename = re.sub(r"[^\w\s-]", "", filename)
    return filename