Files
DocsGPT/docsgpt/core/settings/reference.py
T
arc53-machine 5578039c19 refactor(settings): treat unset spellings of every optional string as None
Review follow-up. The per-group secret validators normalised a hand-picked
list of API keys, which left other optional credentials and overrides
(OPEN_ROUTER_API_KEY, S3 and Daytona keys, ELASTIC_PASSWORD, the OIDC
trio, connector client ids, MICROSOFT_AUTHORITY, MCP_OAUTH_REDIRECT_URI)
holding the literal "None" or "" a .env file spells "unset" with, so
truthiness checks and fallbacks downstream saw a value. One rule on the
group base replaces those lists: every Optional[str] field maps "", "None"
and whitespace to None and strips real values. Plain str fields are left
alone. The OIDC required-settings check therefore also rejects those
spellings.

EMBEDDINGS_POOLING is Literal["cls", "mean"] with case-insensitive
parsing; its consumer silently ignored anything else.

Bounds added where the consumer rejects or misbehaves on the value:
SCHEDULE_RUN_OUTPUT_RETENTION_DAYS and MESSAGE_EVENTS_RETENTION_DAYS (the
cleanup repositories raise on <= 0), EMBEDDINGS_DELEGATE_TIMEOUT, the
remote-device idle/pairing/invocation TTLs and CELERY_VISIBILITY_TIMEOUT
(> 0), REMOTE_DEVICE_CMD_QUEUE_TTL_SECONDS (> 605, the documented drain
deadline), GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION (>= 0; negative would slice
the pending list from the end).

The generated reference now renders generic type arguments
(dict[str, int] rather than dict).
2026-09-17 11:37:57 +01:00

172 lines
6.0 KiB
Python

"""Render the settings reference page from the ``Settings`` definitions.
The page under ``docs/content/Deploying/Settings-Reference.mdx`` is generated
from the field types, defaults and descriptions in this package, so the model
is the single source of truth. Regenerate it after changing a setting::
python -m docsgpt.core.settings.reference --write
``--check`` exits non-zero when the checked-in page is stale; the test suite
runs the same comparison.
"""
from __future__ import annotations
import argparse
import inspect
import json
import sys
import types
import typing
from pathlib import Path
from typing import Any, Literal, Optional, Union
from pydantic import AliasChoices
from pydantic.fields import FieldInfo
from docsgpt.core.paths import home_dir
from docsgpt.core.settings import SETTINGS_GROUPS, Settings
REFERENCE_PATH = Path("docs") / "content" / "Deploying" / "Settings-Reference.mdx"
HOME_PLACEHOLDER = "<DOCSGPT_HOME>"
_HEADER = """\
---
title: Settings Reference
description: Every DocsGPT setting, grouped by domain, with its type, default and purpose.
---
{/* GENERATED FILE. Do not edit by hand: run `python -m docsgpt.core.settings.reference --write`. */}
# Settings Reference
Every setting DocsGPT reads, generated from `docsgpt/core/settings/`. Each one is
an environment variable of the same name, set in `.env` or the process
environment; see [App Configuration](/Deploying/DocsGPT-Settings) for how the
file is found and for worked examples. `<DOCSGPT_HOME>` below is the data home
described there.
"""
def _mdx(text: str) -> str:
"""Escape prose for MDX, where braces open expressions and ``<`` opens JSX."""
return text.replace("{", "\\{").replace("}", "\\}").replace("<", "&lt;")
def _type_name(annotation: Any) -> str:
origin = typing.get_origin(annotation)
if origin in (Union, types.UnionType):
args = [a for a in typing.get_args(annotation) if a is not type(None)]
return " | ".join(_type_name(a) for a in args)
if origin is Literal:
return " | ".join(json.dumps(v) for v in typing.get_args(annotation))
if origin is not None:
name = getattr(origin, "__name__", str(origin))
args = typing.get_args(annotation)
return f"{name}[{', '.join(_type_name(arg) for arg in args)}]" if args else name
return getattr(annotation, "__name__", str(annotation))
def _default_text(field: FieldInfo) -> str:
value = field.default_factory() if field.default_factory is not None else field.default
if value is None:
return "unset"
if isinstance(value, bool):
return "`true`" if value else "`false`"
if isinstance(value, str):
value = value.replace(str(home_dir()), HOME_PLACEHOLDER)
return "`\"\"`" if value == "" else f"`{value}`"
if isinstance(value, (list, dict)):
return f"`{json.dumps(value)}`"
return f"`{value}`"
def _constraints(field: FieldInfo) -> list[str]:
out = []
for item in field.metadata:
for attr, symbol in (("gt", ">"), ("ge", ">="), ("lt", "<"), ("le", "<=")):
if hasattr(item, attr):
out.append(f"{symbol} {getattr(item, attr)}")
return out
def _aliases(name: str, field: FieldInfo) -> list[str]:
alias = field.validation_alias
if isinstance(alias, AliasChoices):
return [str(c) for c in alias.choices if str(c) != name]
if isinstance(alias, str) and alias != name:
return [alias]
return []
def _render_field(name: str, field: FieldInfo) -> str:
facts = [f"Type `{_type_name(field.annotation)}`", f"default {_default_text(field)}"]
constraints = _constraints(field)
if constraints:
facts.append("must be " + " and ".join(constraints))
aliases = _aliases(name, field)
if aliases:
facts.append("also read from " + ", ".join(f"`{a}`" for a in aliases))
# The facts are code spans, which MDX leaves alone; only prose needs escaping.
lines = [f"### `{name}`", "", ", ".join(facts) + "."]
if field.deprecated:
note = field.deprecated if isinstance(field.deprecated, str) else "This setting is deprecated."
lines += ["", f"**Deprecated.** {_mdx(str(note))}"]
if field.description:
lines += ["", _mdx(field.description)]
return "\n".join(lines)
def _group_intro(group: type) -> str:
doc = inspect.getdoc(group) or ""
return doc.split("\n\n", 1)[0].replace("\n", " ").strip()
def render_reference() -> str:
"""The full reference page as MDX text."""
parts = [_HEADER]
for title, group in SETTINGS_GROUPS:
parts.append(f"\n## {title}\n")
intro = _group_intro(group)
if intro:
parts.append(_mdx(intro) + "\n")
for name in group.model_fields:
parts.append(_render_field(name, Settings.model_fields[name]) + "\n")
return "\n".join(parts).rstrip("\n") + "\n"
def reference_path(root: Optional[Path] = None) -> Path:
"""Where the generated page lives in a checkout; ``root`` defaults to the repository root."""
if root is None:
root = Path(__file__).resolve().parents[3]
return root / REFERENCE_PATH
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
action = parser.add_mutually_exclusive_group()
action.add_argument("--write", action="store_true", help="write the page into the docs tree")
action.add_argument("--check", action="store_true", help="exit 1 if the checked-in page is stale")
args = parser.parse_args(argv)
rendered = render_reference()
path = reference_path()
if args.write:
path.write_text(rendered, encoding="utf-8")
print(f"wrote {path}")
return 0
if args.check:
current = path.read_text(encoding="utf-8") if path.exists() else ""
if current != rendered:
print(f"{path} is stale; run: python -m docsgpt.core.settings.reference --write", file=sys.stderr)
return 1
print(f"{path} is up to date")
return 0
sys.stdout.write(rendered)
return 0
if __name__ == "__main__":
sys.exit(main())