gunicorn/scripts/build_settings_doc.py
Benoit Chesneau 73adc7cb29 docs: Add collapsible TOC for settings reference
- Change settings headers to h2 sections / h3 settings for TOC visibility
- Enable toc.integrate to show TOC in left sidebar
- Add JavaScript for collapsible section toggles on settings page
2026-01-23 01:20:03 +01:00

255 lines
8.3 KiB
Python

"""Generate the Markdown settings reference for MkDocs."""
from __future__ import annotations
import inspect
import textwrap
from pathlib import Path
from typing import List
import re
import gunicorn.config as guncfg
HEAD = """\
> **Generated file** — update `gunicorn/config.py` instead.
# Settings
This reference is built directly from `gunicorn.config.KNOWN_SETTINGS` and is
regenerated during every documentation build.
!!! note
Settings can be provided through the `GUNICORN_CMD_ARGS` environment
variable. For example:
```console
$ GUNICORN_CMD_ARGS="--bind=127.0.0.1 --workers=3" gunicorn app:app
```
_Added in 19.7._
"""
def _format_default(setting: guncfg.Setting) -> tuple[str, bool]:
if hasattr(setting, "default_doc"):
text = textwrap.dedent(setting.default_doc).strip("\n")
return text, True
default = setting.default
if callable(default):
source = textwrap.dedent(inspect.getsource(default)).strip("\n")
return f"```python\n{source}\n```", True
if default == "":
return "`''`", False
return f"`{default!r}`", False
def _format_cli(setting: guncfg.Setting) -> str | None:
if not setting.cli:
return None
if setting.meta:
variants = [f"`{opt} {setting.meta}`" for opt in setting.cli]
else:
variants = [f"`{opt}`" for opt in setting.cli]
return ", ".join(variants)
REF_MAP = {
"forwarded-allow-ips": ("reference/settings.md", "forwarded_allow_ips"),
"forwarder-headers": ("reference/settings.md", "forwarder_headers"),
"proxy-allow-ips": ("reference/settings.md", "proxy_allow_ips"),
"worker-class": ("reference/settings.md", "worker_class"),
"reload": ("reference/settings.md", "reload"),
"raw-env": ("reference/settings.md", "raw_env"),
"check-config": ("reference/settings.md", "check_config"),
"errorlog": ("reference/settings.md", "errorlog"),
"logconfig": ("reference/settings.md", "logconfig"),
"logconfig-json": ("reference/settings.md", "logconfig_json"),
"ssl-context": ("reference/settings.md", "ssl_context"),
"ssl-version": ("reference/settings.md", "ssl_version"),
"blocking-os-fchmod": ("reference/settings.md", "blocking_os_fchmod"),
"configuration_file": ("../configure.md", "configuration-file"),
}
REF_PATTERN = re.compile(r":ref:`([^`]+)`")
def _convert_refs(text: str) -> str:
def repl(match: re.Match[str]) -> str:
raw = match.group(1)
if "<" in raw and raw.endswith(">"):
label, target = raw.split("<", 1)
target = target[:-1]
label = label.replace("\n", " ").strip()
else:
label, target = None, raw.strip()
info = REF_MAP.get(target)
if not info:
return (label or target).replace("\n", " ").strip()
path, anchor = info
if path.endswith(".md"):
if path == "reference/settings.md" and anchor:
href = f"#{anchor}"
else:
href = path + (f"#{anchor}" if anchor else "")
else:
href = path + (f"#{anchor}" if anchor else "")
text = (label or target).replace("\n", " ").strip()
return f"[{text}]({href})"
return REF_PATTERN.sub(repl, text)
def _consume_indented(lines: List[str], start: int) -> tuple[str, int]:
body: List[str] = []
i = start
while i < len(lines):
line = lines[i]
if line.startswith(" ") or not line.strip():
body.append(line)
i += 1
else:
break
text = textwrap.dedent("\n".join(body)).strip("\n")
return text, i
def _convert_desc(desc: str) -> str:
raw_lines = textwrap.dedent(desc).splitlines()
output: List[str] = []
i = 0
while i < len(raw_lines):
line = raw_lines[i]
stripped = line.strip()
if stripped.startswith(".. note::"):
body, i = _consume_indented(raw_lines, i + 1)
output.append("!!! note")
if body:
for body_line in body.splitlines():
output.append(f" {body_line}" if body_line else "")
output.append("")
continue
if stripped.startswith(".. warning::"):
body, i = _consume_indented(raw_lines, i + 1)
output.append("!!! warning")
if body:
for body_line in body.splitlines():
output.append(f" {body_line}" if body_line else "")
output.append("")
continue
if stripped.startswith(".. deprecated::"):
version = stripped.split("::", 1)[1].strip()
body, i = _consume_indented(raw_lines, i + 1)
title = f"Deprecated in {version}" if version else "Deprecated"
output.append(f"!!! danger \"{title}\"")
if body:
for body_line in body.splitlines():
output.append(f" {body_line}" if body_line else "")
output.append("")
continue
if stripped.startswith(".. versionadded::"):
version = stripped.split("::", 1)[1].strip()
body, i = _consume_indented(raw_lines, i + 1)
title = f"Added in {version}" if version else "Added"
output.append(f"!!! info \"{title}\"")
if body:
for body_line in body.splitlines():
output.append(f" {body_line}" if body_line else "")
output.append("")
continue
if stripped.startswith(".. versionchanged::"):
version = stripped.split("::", 1)[1].strip()
body, i = _consume_indented(raw_lines, i + 1)
title = f"Changed in {version}" if version else "Changed"
output.append(f"!!! info \"{title}\"")
if body:
for body_line in body.splitlines():
output.append(f" {body_line}" if body_line else "")
output.append("")
continue
if stripped.startswith(".. code::") or stripped.startswith(".. code-block::"):
language = stripped.split("::", 1)[1].strip()
body, i = _consume_indented(raw_lines, i + 1)
fence = language or "text"
output.append(f"```{fence}")
if body:
output.append(body)
output.append("```")
output.append("")
continue
output.append(line)
i += 1
text = "\n".join(output)
text = _convert_refs(text)
# Collapse excessive blank lines
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip("\n")
def _format_setting(setting: guncfg.Setting) -> str:
lines: list[str] = [f"### `{setting.name}`", ""]
cli = _format_cli(setting)
if cli:
lines.extend((f"**Command line:** {cli}", ""))
default_text, is_block = _format_default(setting)
if is_block:
lines.append("**Default:**")
lines.append("")
lines.append(default_text)
else:
lines.append(f"**Default:** {default_text}")
lines.append("")
desc = _convert_desc(setting.desc)
if desc:
lines.append(desc)
lines.append("")
return "\n".join(lines)
def render_settings() -> str:
sections: list[str] = [HEAD, '<span id="blocking_os_fchmod"></span>', ""]
known_settings = sorted(guncfg.KNOWN_SETTINGS, key=lambda s: s.section)
current_section: str | None = None
for setting in known_settings:
if setting.section != current_section:
current_section = setting.section
sections.append(f"## {current_section}\n")
sections.append(_format_setting(setting))
return "\n".join(sections).strip() + "\n"
def _write_output(markdown: str) -> None:
try:
import mkdocs_gen_files # type: ignore
except ImportError:
mkdocs_gen_files = None
if mkdocs_gen_files is not None:
try:
with mkdocs_gen_files.open("reference/settings.md", "w") as fh:
fh.write(markdown)
return
except Exception:
pass
output = Path(__file__).resolve().parents[1] / "docs" / "content" / "reference" / "settings.md"
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(markdown, encoding="utf-8")
def main() -> None:
markdown = render_settings()
_write_output(markdown)
if __name__ == "__main__":
main()