Commit fadbc46f authored by Dom Sekotill's avatar Dom Sekotill
Browse files

Add Sphinx documentation generator scripts & configs

parent 61534555
Loading
Loading
Loading
Loading

doc/.gitignore

0 → 100644
+4 −0
Original line number Diff line number Diff line
# This project uses Markdown, any RST file is generated
*.rst

build/

doc/Makefile

0 → 100644
+56 −0
Original line number Diff line number Diff line
SPHINXBUILD = sphinx-build
SPHINXOPTS = -j auto -E
SPHINXAPIDOC = sphinx-apidoc
SPHINXAPIDOCOPTS = --separate
BUILDDIR = build

# Alternatives: html dirhtml singlehtml
HTML_BUILDER = html

# Values above this line may be modified from cmdline
# ======

BUILD_SRC = Makefile $(wildcard *.py)
SPHINX_SRC = index.md kilter.service.rst
SPHINX_SRC += ../README.md
APIDOC_SRC := $(wildcard ../kilter/service/*.py)
APIDOC_SRC += $(wildcard templates/apidoc/*.rst_t)

BUILDOUT = $(@D)


.PHONY: default all
default:

.PHONY all default: html
html: BUILDER=$(HTML_BUILDER)
html: doctest $(BUILDDIR)/html/index.html

.PHONY all: man
man: BUILDER=man
man: doctest $(BUILDDIR)/man/kilterkilterservice.1

.PHONY all: text
text: BUILDER=text
text: doctest $(BUILDDIR)/text/index.txt

.PHONY: doctest
doctest: BUILDER=doctest
doctest: $(BUILDDIR)/doctest/output.txt $(APIDOC_SRC)

.PHONY: clean
clean:
	-rm -rf $(BUILDDIR)
	-rm kilter.*


kilter.service.rst: $(BUILD_SRC) $(APIDOC_SRC)
	$(SPHINXAPIDOC) $(SPHINXAPIDOCOPTS) --implicit-namespaces -MTft templates/apidoc -o . ../kilter
	@rm kilter.rst
	@touch $@

$(BUILDDIR)/%: $(BUILD_SRC) $(SPHINX_SRC) | $(BUILDDIR)
	$(SPHINXBUILD) -ab $(BUILDER) $(SPHINXOPTS) . $(BUILDOUT)

$(BUILDDIR):
	mkdir -p $@

doc/conf.py

0 → 100644
+58 −0
Original line number Diff line number Diff line
from __future__ import annotations

import sys
from pathlib import Path

project = "Kilter (kilter.service)"

highlight_language = "python3"

add_module_names = False

html_theme = "sphinx_rtd_theme"

extensions = [
	"sphinx.ext.autodoc",
	"sphinx.ext.doctest",
	"sphinx.ext.viewcode",
	"sphinx.ext.intersphinx",
	"myst_parser",
	"docstring",
]
myst_enable_extensions = [
	"substitution",
]


doc_dir = Path(__file__).parent.absolute()
sys.path.insert(0, str(doc_dir))
sys.path.insert(0, str(doc_dir.parent))

autoclass_content = "class"
autodoc_class_signature = "mixed"
autodoc_member_order = "bysource"
autodoc_typehints = "both"
autodoc_typehints_description_target = "documented"
autodoc_typehints_format = "short"
autodoc_inherit_docstring = True
doctest_test_doctest_blocks = "default"
myst_heading_anchors = 3

myst_substitutions = {
	"libmilter": "*__libmilter__*",
}
myst_enable_extensions += [
	"smartquotes",
]

intersphinx_mapping = {
	"python": ("https://docs.python.org/3", None),
	"kilter.protocol": ("http://dom.doc.kodo.org.uk/kilter.protocol", None),
	"anyio": ("https://anyio.readthedocs.io/en/stable", None),
}

autodoc_type_aliases = {
	"EventMessage": "kilter.service.session.EventMessage",
	"ResponseMessage": "kilter.service.session.ResponseMessage",
	"EditMessage": "kilter.service.session.EditMessage",
}

doc/docstring.py

0 → 100644
+210 −0
Original line number Diff line number Diff line
"""
A Sphinx extension to make cross-linking work without polluting docstrings with RST markup

Docstring text surrounded by single backticks with optional calling parentheses is examined
to see if it can be resolved into a Python object, which is then injected as
a ":py:xxx:`<object>`" style link.
"""

from __future__ import annotations

import builtins
import re
from collections.abc import Callable
from importlib import import_module
from inspect import get_annotations
from types import FunctionType
from types import ModuleType
from typing import Literal as L
from typing import Union

from sphinx.application import Sphinx
from sphinx.util import logging

ObjType = Union[
	L["module"], L["class"], L["exception"], L["function"],
	L["method"], L["attribute"], L["property"],
]


class UnknownObject(ValueError):
	"""
	An error for unknown values of "what" or unusable "obj" passed to `add_roles`
	"""


def setup(app: Sphinx) -> None:
	app.connect("autodoc-process-docstring", add_roles)


def add_roles(
	app: Sphinx, what: ObjType, name: str, obj: object, options: object, lines: list[str],
) -> None:
	"""
	Add Sphinx roles to strings delimited with "`" in docstrings

	Polluting docstrings with RestructuredText markup is forbidden, so this plugin marks-up
	python objects in backticks for cross linking.
	"""
	replacer = get_replacer(what, obj, name)
	regex = re.compile(r"(?<![:])`(?P<name>[a-z0-9_.]+)(\(\))?`", re.I)
	lines[:] = (regex.sub(replacer, line) for line in lines)


def get_replacer(
	what: ObjType, doc_obj: object, doc_obj_name: str,
) -> Callable[[re.Match[str]], str]:
	try:
		module, cls = get_context(what, doc_obj)
	except UnknownObject:
		return lambda m: m.group(0)

	def get_type(match: re.Match[str]) -> str:
		"""
		Given a match for a dot-name, return the RST type
		"""
		name = match.group("name")
		try:
			obj, parent, name = dot_import(module, cls, name)
		except AttributeError:
			location = None
			if isinstance(doc_obj, FunctionType):
				co = doc_obj.__code__
				location = (co.co_filename, co.co_firstlineno)
			logging.getLogger(__name__).warning(
				f"ignoring {match.group(0)} in docstring of {doc_obj_name}",
				type="ref.ref",
				location=location,
			)
			return match.group(0)
		if isinstance(obj, ModuleType):
			role = ":py:mod:"
		elif isinstance(obj, type):
			role = ":py:exc:" if issubclass(obj, BaseException) else ":py:class:"
		elif callable(obj):
			role = ":py:meth:" if isinstance(parent, type) else ":py:func:"
		elif isinstance(parent, ModuleType):
			role = ":py:const:" if name.isupper() else ":py:data:"
		elif isinstance(parent, type):
			role = ":py:attr:"
		else:
			role = ":py:obj:"
		if isinstance(parent, ModuleType):
			name = f"{name.removeprefix(parent.__name__+'.')} <{name}>"
		elif isinstance(parent, type):
			name = f"{name.removeprefix(parent.__module__+'.')} <{name}>"
		return f"{role}`{name}`"

	return get_type


def get_context(what: ObjType, obj: object) -> tuple[ModuleType, type|None]:
	"""
	Given an object and its type, return the module it's in and a class if appropriate

	These values form the starting points for searching for names.
	"""
	match what:
		case "module":
			assert isinstance(obj, ModuleType)
			return obj, None
		case "method":
			assert isinstance(obj, FunctionType)
			return get_method_context(obj)
		case "property":
			assert isinstance(obj, property)
			func = \
				obj.fget if isinstance(obj.fget, FunctionType) else \
				obj.fset if isinstance(obj.fset, FunctionType) else \
				None
			if func is None:
				raise UnknownObject(
					"could not get function from property; cannot determine a module",
				)
			return get_method_context(func)
		case "class" | "exception":
			assert isinstance(obj, type), f"{what} {obj!r} is not a type?!"
			return import_module(obj.__module__), obj
		case "function":
			assert hasattr(obj, "__module__"), f"{what} {obj!r} has no attribute '__module__'"
			return import_module(obj.__module__), None
	raise UnknownObject(f"unknown value for 'what': {what}")


def get_method_context(method: FunctionType) -> tuple[ModuleType, type|None]:
	"""
	Given a method, return it's module and best attempt at a class
	"""
	mod = import_module(method.__module__)
	clsname, has_clsname, _ = method.__qualname__.rpartition(".")
	if not has_clsname:
		return mod, None
	return mod, getattr(mod, clsname, None)


def dot_import(module: ModuleType, cls: type|None, name: str) -> tuple[object, object, str]:
	"""
	Given a dot-separated name, return an object, its parent, and an absolute name for it

	The search is started from the context returned by `get_context()`.
	"""
	labels = list(name.split("."))
	obj, parent, name = dot_import_first(module, cls, labels.pop(0))
	for label in labels:
		parent = obj
		match obj:
			case ModuleType():
				obj = dot_import_from(obj, label)
			case type():
				try:
					obj = getattr(obj, label)
				except AttributeError:
					assert isinstance(obj, type)  # come on mypy…
					annotations = get_annotations(obj)
					if label not in annotations:
						raise
					obj = annotations[label]
			case _:
				obj = getattr(obj, label)
	return obj, parent, ".".join([name] + labels)


def dot_import_first(module: ModuleType, cls: type|None, name: str) -> tuple[object, object, str]:
	"""
	Given a name, return an object, its parent, and its absolute dot-separated name

	The name is search first from builtins; then top-level packages and modules; then
	submodules of the context module; then attributes of the context modules; then
	attributes of the context class, or as a special case the context class itself if the
	name is "self".
	"""
	try:
		return getattr(builtins, name), None, name
	except AttributeError:
		pass
	try:
		return import_module(name), None, name
	except ModuleNotFoundError:
		pass
	try:
		obj = dot_import_from(module, name)
		if hasattr(obj, "__module__"):
			module = import_module(obj.__module__)
		return obj, module, f"{module.__name__}.{name}"
	except AttributeError:
		if cls is None:
			raise
		return (
			(cls, module, f"{module.__name__}.{cls.__name__}") if name == "self" else \
			(getattr(cls, name), cls, f"{module.__name__}.{cls.__name__}.{name}")
		)


def dot_import_from(module: ModuleType, name: str) -> object:
	"""
	Given a module and name, return a submodule or module attribute of that name
	"""
	try:
		return import_module("." + name, module.__name__)
	except ModuleNotFoundError:
		return getattr(module, name)

doc/index.md

0 → 100644
+14 −0
Original line number Diff line number Diff line
Kilter Service (kilter.service)
===============================

```{include} ../README.md
:relative-docs: doc/
:start-line: 3
```

```{toctree}
:maxdepth: 4
:caption: Sections

kilter.service
```
Loading