1
0
mirror of https://github.com/pgbackrest/pgbackrest.git synced 2026-10-28 10:39:37 +03:00
Files
pgbackrest/doc/lib/command/doc.py
David Steele b56b6ab4d2 List the most recent news items on the index automatically.
The three items the index lists were written on it by hand, so every news item was written twice: once as the news and again as a date, a title, and a link on the index. Nothing kept the two in step, so an item added to one and forgotten in the other would go unnoticed by anything that builds the documentation.

The index asks for the items now and the build takes them from the news. The title on the index is the title of the item as the news writes it, so a title that changes on the news changes on the index as well, and the project name in one is shown there the way it is shown everywhere else.

A news item says when it was posted as an attribute written the way a date sorts rather than as the paragraph the item begins with, so the date is written once and rendered both where the item shows it and where the index lists it. An item that is more recent than the item before it is an error, since the index lists the items the news begins with.

The release notes render their date the same way, so the months are named in one place rather than in three.
2026-08-02 19:50:22 +07:00

297 lines
13 KiB
Python

"""Build the Documentation.
Builds everything the documentation is made of and renders it. The documents that are generated from the declarations the binary is
built from -- the command and configuration references, the user guide with everything in it resolved, and the release notes -- are
built here and handed to the renderers in memory, so what is rendered is what was just built rather than a file that may be stale.
The user guide runs real commands on real hosts as it is built, so a run can take a long time. What those commands wrote is cached,
which is what makes it possible to render the documentation again without running any of it."""
####################################################################################################################################
import argparse
import os
import shutil
import time
from common.date import date_render
from common.error import ToolError
from common.log import *
from common.storage import file_read, file_write, path_create, path_list
from common.var_store import VarStore
from common.xml import xml_document_parse, xml_node_attribute, xml_node_child_list, xml_node_content, xml_node_normalize
from command.build.man import reference_man_render
from command.build.news import news_index_render, news_render
from command.build.pre import build_pre
from command.build.reference import reference_command_render, reference_configuration_render
from command.render.host import image_build_cached
from command.render.html import html_render
from command.render.manifest import RENDER_HTML, RENDER_MAN, RENDER_MARKDOWN, Manifest
from command.render.markdown import markdown_render
from command.render.release import release_current_stable, release_last, release_render
from config.parse import bld_cfg_parse
from config.project import PROJECT_EXE, project_version
from help.parse import bld_hlp_parse
# Where the documentation is read from and written to, relative to the documentation path
_PATH_XML = "xml"
_PATH_OUT = "output"
_FILE_MAN = "pgbackrest.1.txt"
####################################################################################################################################
def cfg_load(arg_list, path_repo):
"""Parse the command line and apply the rules that cannot be expressed in the parser."""
parser = argparse.ArgumentParser(prog="doc.py", description="pgBackRest Documentation Builder")
parser.add_argument("--version", action="version", version="pgBackRest %s Documentation Builder" % project_version(path_repo))
parser.add_argument("--repo-path", default=path_repo, metavar="PATH", help="code repository path")
parser.add_argument("--doc-path", metavar="PATH", help="documentation path to render (manifest.xml is located here)")
parser.add_argument("--out", action="append", default=[], metavar="TYPE", help="output type (html, markdown, man)")
parser.add_argument("--out-preserve", action="store_true", help="do not clean the output path")
parser.add_argument("--require", action="append", default=[], metavar="SECTION", help="render only certain sections")
parser.add_argument("--include", action="append", default=[], metavar="SOURCE", help="include a source in the build")
parser.add_argument("--exclude", action="append", default=[], metavar="SOURCE", help="exclude a source from the build")
parser.add_argument("--var", action="append", default=[], metavar="KEY=VALUE", help="override a variable")
parser.add_argument("--key-var", action="append", default=[], metavar="KEY=VALUE", help="override a variable and key the cache")
parser.add_argument("--deploy", action="store_true", help="write the execution cache into resource for persistence")
parser.add_argument("--no-exe", dest="exe", action="store_false", help="do not run the commands (for testing only)")
parser.add_argument("--no-cache", dest="cache", action="store_false", help="do not use the execution cache")
parser.add_argument("--cache-only", action="store_true", help="only use the execution cache, do not generate it")
parser.add_argument("--pre", action="store_true", help="pre-build the images for hosts with commands marked pre")
parser.add_argument("--dev", action="store_true", help="set the dev variable to y")
parser.add_argument("--debug", action="store_true", help="set the debug variable to y")
parser.add_argument("--quiet", action="store_true", help="set the log level to error")
parser.add_argument(
"--log-level", default="info", choices=sorted(LEVEL_NAME.values()), metavar="LEVEL", help="console log level"
)
parser.add_argument("--no-log-timestamp", dest="log_timestamp", action="store_false", help="suppress timestamps in the log")
config = parser.parse_args(arg_list)
# A relative path is made absolute so it does not depend on where the tool was run from
config.repo_path = os.path.abspath(config.repo_path)
config.doc_path = os.path.join(config.repo_path, "doc") if config.doc_path is None else os.path.abspath(config.doc_path)
# Nothing is cached when the commands are not run, since there would be nothing to cache
if not config.exe:
config.cache = False
if config.deploy:
if not config.exe:
raise ToolError("--no-exe cannot be specified for deploy")
if len(config.require) > 0:
raise ToolError("--require cannot be specified for deploy")
# A partial render is of one document, since the sections it names are sections of that document
if len(config.require) > 0 and len(config.include) != 1:
raise ToolError("one --include is required when --require is specified")
if len(config.include) > 0 and len(config.exclude) > 0:
raise ToolError("cannot specify both --include and --exclude")
# Variables that key the cache are variables as well, so a document refers to them the same way
config.key_var_map = _var_parse(config.key_var)
config.var_map = _var_parse(config.var)
for key in sorted(config.key_var_map):
if key in config.var_map:
raise ToolError("'%s' cannot be passed as --var and --key-var" % key)
config.var_map[key] = config.key_var_map[key]
if config.dev:
config.var_map["dev"] = "y"
# The debug variable always says what the flag says, since a document declaring it would otherwise decide for the caller
config.var_map["debug"] = "y" if config.debug else "n"
config.log_level = OFF if config.quiet else log_level_parse(config.log_level)
return config
####################################################################################################################################
def _var_parse(var_list):
"""Parse variables given as key=value, where a value may itself hold an equals sign."""
result = {}
for var in var_list:
key, _, value = var.partition("=")
if key == "" or "=" not in var:
raise ToolError("variable '%s' must be given as key=value" % var)
result[key] = value
return result
####################################################################################################################################
def _read(path):
"""Read a document and drop what it uses to lay itself out but does not mean."""
result = xml_document_parse(file_read(path), path)
xml_node_normalize(result)
return result
####################################################################################################################################
def _release_date(release_root, static):
"""The date the documentation says it was built, which a release build takes from the release so it can be built again."""
if static:
date = xml_node_attribute(release_last(release_root), "date", True)
if date == "XXXX-XX-XX":
raise ToolError("not possible to use static release dates on a dev build")
else:
now = time.localtime()
date = "%04d-%02d-%02d" % (now.tm_year, now.tm_mon, now.tm_mday)
return date_render(date), date[0:4]
####################################################################################################################################
def _build(config, var_store):
"""Build the documents that are generated rather than written, and the manual page."""
path_repo = config.repo_path
path_xml = os.path.join(config.doc_path, _PATH_XML)
bld_cfg = bld_cfg_parse(path_repo)
bld_hlp = bld_hlp_parse(os.path.join(path_xml, "reference.xml"), bld_cfg, True)
index = _read(os.path.join(path_xml, "index.xml"))
news = _read(os.path.join(path_xml, "news.xml"))
user_guide = _read(os.path.join(path_xml, "user-guide.xml"))
release = _read(os.path.join(path_xml, "release.xml"))
# What the documentation says about the project, which is read from the project rather than said again in the documentation
var_store.add("version", project_version(path_repo))
var_store.add("version-stable", xml_node_attribute(release_current_stable(release), "version", True))
date, year = _release_date(release, config.var_map.get("release-date-static", "n") == "y")
var_store.add("release-date", date)
var_store.add("release-year", year)
document_map = {
"command": reference_command_render(bld_cfg, bld_hlp),
"configuration": reference_configuration_render(bld_cfg, bld_hlp),
"index": news_index_render(index, news),
"news": news_render(news),
"user-guide": build_pre(user_guide, bld_hlp, var_store),
"release": release_render(release, config.doc_path, config.var_map.get("dev") == "y"),
}
return document_map, reference_man_render(index, bld_cfg, bld_hlp)
####################################################################################################################################
def _host_build(manifest, config):
"""Build the image for every host the documentation defines."""
# A document that was excluded is not in the source map at all, so only what was included has to be checked here
for key in sorted(manifest.source_map):
if len(config.include) > 0 and key not in config.include:
continue
log(INFO, "source %s" % key)
for host in xml_node_child_list(manifest.source_map[key].root, "host-define"):
if not manifest.evaluate_if(host):
continue
replace = manifest.var_store.replace_str
image = replace(xml_node_attribute(host, "image", True))
source = replace(xml_node_attribute(host, "from", True))
log(INFO, "Build vm '%s' from '%s'" % (image, source))
image_build_cached(
os.path.join(config.doc_path, _PATH_OUT, "doc-host.dockerfile"),
image,
"FROM %s\n\n%s\n" % (source, replace(xml_node_content(host)).strip()),
config.doc_path,
xml_node_attribute(host, "revision") or "0",
)
####################################################################################################################################
def _out_clean(path_out):
"""Empty the output path so what is left in it is what this build wrote."""
if not os.path.isdir(path_out):
path_create(path_out)
return
for name in path_list(path_out):
path = os.path.join(path_out, name)
shutil.rmtree(path) if os.path.isdir(path) else os.remove(path)
####################################################################################################################################
def cmd_doc(config):
"""Build the documentation and render it."""
var_store = VarStore()
# What the caller asked for is loaded first so a document declaring the same variable does not override it
for key in sorted(config.var_map):
var_store.add(key, config.var_map[key])
document_map, man = _build(config, var_store)
manifest = Manifest(config.doc_path, var_store, document_map, config)
if config.cache:
manifest.cache_read()
out_list = config.out
if len(out_list) == 0:
out_list = sorted(manifest.render_map)
# The manual page is of the project rather than of the documentation, so it is only built for the project
if var_store.test("project-exe", PROJECT_EXE):
out_list.append(RENDER_MAN)
if not config.cache_only and config.exe:
_host_build(manifest, config)
for out in out_list:
log(INFO, "render %s output" % out)
path_out = os.path.join(config.doc_path, _PATH_OUT, out)
if out == RENDER_MAN:
file_write(os.path.join(path_out, _FILE_MAN), man)
continue
if not config.out_preserve:
_out_clean(path_out)
else:
path_create(path_out)
manifest.render_get(out)
if out == RENDER_MARKDOWN:
markdown_render(manifest, path_out, config.exe)
else:
html_render(manifest, config.doc_path, path_out, config.exe)
# Links between pages can only be checked once every page is rendered and the sections of each are known
manifest.link_verify()
if config.cache and not config.cache_only:
manifest.cache_write()