222 lines
6.1 KiB
Python
222 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
|
|
"""A documentation generator.
|
|
|
|
This is a shim tool which wraps up a whole bunch of Sphinx internals in a single "convenient"
|
|
entrypoint. Former tweeps may recognize some parallels to the `docbird` tool developed by Twitter's
|
|
techdocs team.
|
|
|
|
"""
|
|
|
|
import builtins
|
|
from functools import wraps
|
|
import io
|
|
import os
|
|
import sys
|
|
|
|
import click
|
|
import livereload
|
|
from sphinx.application import Sphinx
|
|
from sphinx.cmd.quickstart import main as new
|
|
from sphinx.ext.apidoc import main as apidoc
|
|
from sphinx.ext.autosummary.generate import (
|
|
main as autosummary,
|
|
)
|
|
from sphinx.util.docutils import (
|
|
docutils_namespace,
|
|
patch_docutils,
|
|
)
|
|
|
|
|
|
@click.group()
|
|
def cli():
|
|
"""A documentation generator.
|
|
|
|
Just a shim to a variety of upstream Sphinx commands typically distributed as separate binaries
|
|
for some dang reason.
|
|
|
|
Note that due to subcommand argument parsing '-- --help' is likely required.
|
|
|
|
Subcommands have not been renamed (or customized, yet) from their Sphinx equivalents.
|
|
|
|
"""
|
|
|
|
|
|
@cli.group()
|
|
def generate():
|
|
"""Subcommands for doing RST header generation."""
|
|
|
|
|
|
@generate.command(
|
|
"apidoc",
|
|
context_settings=dict(
|
|
ignore_unknown_options=True,
|
|
),
|
|
)
|
|
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
|
|
def do_apidoc(argv):
|
|
"""Use sphinx.ext.apidoc to generate API documentation."""
|
|
return apidoc(argv)
|
|
|
|
|
|
@generate.command(
|
|
"summary",
|
|
context_settings=dict(
|
|
ignore_unknown_options=True,
|
|
),
|
|
)
|
|
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
|
|
def do_summary(argv):
|
|
"""Use sphinx.ext.autosummary to generate module summaries."""
|
|
return autosummary(argv)
|
|
|
|
|
|
@cli.command(
|
|
"new",
|
|
context_settings=dict(
|
|
ignore_unknown_options=True,
|
|
),
|
|
)
|
|
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
|
|
def do_new(argv):
|
|
"""Create a new Sphinx in the current directory."""
|
|
return new(argv)
|
|
|
|
|
|
@cli.command(
|
|
"build",
|
|
context_settings=dict(
|
|
ignore_unknown_options=True,
|
|
),
|
|
)
|
|
@click.argument("sourcedir")
|
|
@click.argument("outputdir")
|
|
@click.option("-c", "--confdir")
|
|
@click.option("-d", "--doctreedir")
|
|
@click.option("-b", "--builder", default="html")
|
|
@click.option("--freshenv/--no-freshenv", default=False)
|
|
@click.option("-W", "--warning-is-error", "werror", is_flag=True, flag_value=True)
|
|
@click.option("-t", "--tag", "tags", multiple=True)
|
|
def do_build(
|
|
sourcedir, outputdir, confdir, doctreedir, builder, freshenv, werror, tags
|
|
):
|
|
"""Build a single Sphinx project."""
|
|
|
|
if not confdir:
|
|
confdir = sourcedir
|
|
|
|
if not doctreedir:
|
|
doctreedir = os.path.join(outputdir, ".doctrees")
|
|
|
|
status = sys.stdout
|
|
warning = sys.stderr
|
|
# error = sys.stderr
|
|
|
|
confdir = confdir or sourcedir
|
|
confoverrides = {} # FIXME: support these
|
|
with patch_docutils(confdir), docutils_namespace():
|
|
app = Sphinx(
|
|
sourcedir,
|
|
confdir,
|
|
outputdir,
|
|
doctreedir,
|
|
builder,
|
|
confoverrides,
|
|
status,
|
|
warning,
|
|
freshenv,
|
|
werror,
|
|
tags,
|
|
1,
|
|
4,
|
|
False,
|
|
)
|
|
app.build(True, [])
|
|
return app.statuscode
|
|
|
|
|
|
@cli.command(
|
|
"serve",
|
|
context_settings=dict(
|
|
ignore_unknown_options=True,
|
|
),
|
|
)
|
|
@click.option("-h", "--host", default="localhost")
|
|
@click.option("-p", "--port", type=int, default=8080)
|
|
@click.argument("sourcedir")
|
|
@click.argument("outputdir")
|
|
def do_serve(host, port, sourcedir, outputdir):
|
|
"""Build and then serve a Sphinx tree."""
|
|
|
|
sourcedir = os.path.realpath(sourcedir)
|
|
outputdir = os.path.realpath(outputdir)
|
|
|
|
server = livereload.Server()
|
|
|
|
# HACK (arrdem 2020-10-31):
|
|
# Okay. This is an elder hack, and I'm proud of it.
|
|
#
|
|
# The naive implementation of the watching server is to watch the input files, which is
|
|
# obviously correct. However, Sphinx has a BUNCH of operators like include and mdinclude and
|
|
# soforth which can cause a Sphinx doctree to have file dependencies OUTSIDE of the "trivial"
|
|
# source path dependency set.
|
|
#
|
|
# In order to make sure that rebuilding does what the user intends, we trace calls to the
|
|
# open() function and attempt to dynamically discover the dependency set of the site. This
|
|
# allows us to trigger strictly correct rebuilds unlike other Sphinx implementations which
|
|
# need to be restarted under some circumstances.
|
|
def opener(old_open):
|
|
@wraps(old_open)
|
|
def tracking_open(path, mode="r", *args, **kw):
|
|
file = old_open(path, mode, *args, **kw)
|
|
if isinstance(path, int):
|
|
# If you're doing something weird with file pointers, ignore it.
|
|
pass
|
|
else:
|
|
path = os.path.realpath(path)
|
|
|
|
if "w" in mode:
|
|
# If we're writing a file, it's an output for sure. Ignore it.
|
|
ignorelist.add(path)
|
|
elif (
|
|
not path.startswith(outputdir)
|
|
and path not in ignorelist
|
|
and path not in watchlist
|
|
):
|
|
# Watch any source file (file we open for reading)
|
|
server.watch(path, build)
|
|
watchlist.add(path)
|
|
return file
|
|
|
|
return tracking_open
|
|
|
|
ignorelist = set()
|
|
watchlist = set()
|
|
|
|
def build():
|
|
try:
|
|
old_open = open
|
|
builtins.open = opener(old_open)
|
|
io.open = opener(old_open)
|
|
do_build([sourcedir, outputdir])
|
|
except SystemExit:
|
|
pass
|
|
finally:
|
|
builtins.open = old_open
|
|
io.open = old_open
|
|
|
|
build()
|
|
server.watch(
|
|
"conf.py", build
|
|
) # Not sure why this isn't picked up, but it doesn't seem to be.
|
|
|
|
server.serve(port=port, host=host, root=outputdir)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Hack in a -- delimeter to bypass click arg parsing
|
|
if not (sys.argv + [""])[1].startswith("-"):
|
|
sys.argv = [sys.argv[0], "--"] + sys.argv[1:]
|
|
|
|
# Use click subcommands for everything else
|
|
exit(cli())
|