Files
wehub-resource-sync 7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

380 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Generate CONFIGURATION.md from default settings JSON files and env_definitions.
Usage:
python scripts/generate_config_docs.py # Write to docs/CONFIGURATION.md
python scripts/generate_config_docs.py --output /tmp/out # Write to custom location
python scripts/generate_config_docs.py --check # Exit 1 if docs are stale
"""
import argparse
import ast
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
def get_project_root() -> Path:
"""Return the project root directory."""
return Path(__file__).resolve().parent.parent
def get_env_var_name(key: str) -> str:
"""Convert setting key to environment variable name."""
return f"LDR_{key.replace('.', '_').upper()}"
def format_value(value: Any) -> str:
"""Format default value for markdown."""
if value is None:
return "null"
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, (dict, list)):
return f"`{json.dumps(value)}`"
return str(value)
def _discover_env_definition_files(env_defs_dir: Path) -> List[Path]:
"""Auto-discover env_definitions modules, excluding __init__.py and env_settings.py."""
if not env_defs_dir.is_dir():
return []
return sorted(
p
for p in env_defs_dir.glob("*.py")
if p.name not in ("__init__.py", "env_settings.py")
)
def _category_from_filename(filename: str) -> str:
"""Derive a human-readable category name from a filename.
Example: 'db_config.py' -> 'Db Config'
"""
stem = filename.removesuffix(".py")
return stem.replace("_", " ").title()
def _extract_setting_from_call(node: ast.Call) -> Optional[Dict[str, Any]]:
"""Extract a setting dict from a *Setting() AST call node."""
if not (
isinstance(node.func, ast.Name) and node.func.id.endswith("Setting")
):
return None
keywords = {k.arg: k.value for k in node.keywords if k.arg}
if "key" not in keywords:
return None
key_node = keywords["key"]
if not isinstance(key_node, ast.Constant):
return None
key = key_node.value # gitleaks:allow
# Description — may be a simple string or a parenthesised concatenation
description = ""
if "description" in keywords:
desc_node = keywords["description"]
if isinstance(desc_node, ast.Constant):
description = desc_node.value
else:
description = ast.unparse(desc_node)
# Default
default_val = "None"
if "default" in keywords:
default_val = ast.unparse(keywords["default"])
# Env var (auto-generated unless explicitly overridden)
if "env_var" in keywords and isinstance(keywords["env_var"], ast.Constant):
env_var = keywords["env_var"].value
else:
env_var = get_env_var_name(key)
# Type from the class name (e.g. BooleanSetting -> Boolean)
setting_type = node.func.id.replace("Setting", "")
# Required
required = False
if "required" in keywords and isinstance(
keywords["required"], ast.Constant
):
required = bool(keywords["required"].value)
# Min/max value
min_value = None
if "min_value" in keywords and isinstance(
keywords["min_value"], ast.Constant
):
min_value = keywords["min_value"].value
max_value = None
if "max_value" in keywords and isinstance(
keywords["max_value"], ast.Constant
):
max_value = keywords["max_value"].value
# Allowed values (ast.Set of ast.Constant)
allowed_values = None
if "allowed_values" in keywords and isinstance(
keywords["allowed_values"], ast.Set
):
allowed_values = sorted(
elt.value
for elt in keywords["allowed_values"].elts
if isinstance(elt, ast.Constant)
)
# Deprecated env var
deprecated_env_var = None
if "deprecated_env_var" in keywords and isinstance(
keywords["deprecated_env_var"], ast.Constant
):
deprecated_env_var = keywords["deprecated_env_var"].value
return {
"key": key,
"env_var": env_var,
"description": description,
"default": default_val,
"type": setting_type,
"required": required,
"min_value": min_value,
"max_value": max_value,
"allowed_values": allowed_values,
"deprecated_env_var": deprecated_env_var,
}
def get_env_only_settings(
root_dir: Optional[Path] = None,
) -> List[Dict[str, Any]]:
"""
Extract env-only settings from env_definitions/ by auto-discovering modules.
These are settings required before database initialization.
"""
root_dir = root_dir or get_project_root()
env_defs_dir = (
root_dir
/ "src"
/ "local_deep_research"
/ "settings"
/ "env_definitions"
)
env_only: List[Dict[str, Any]] = []
for filepath in _discover_env_definition_files(env_defs_dir):
category = _category_from_filename(filepath.name)
try:
content = filepath.read_text(encoding="utf-8")
tree = ast.parse(content)
except Exception as e:
print(f"Warning: Could not parse {filepath}: {e}")
continue
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
setting = _extract_setting_from_call(node)
if setting is None:
continue
setting["category"] = category
env_only.append(setting)
return env_only
def _format_constraints(setting: Dict[str, Any]) -> str:
"""Build a human-readable constraints string."""
parts = []
if (
setting.get("min_value") is not None
and setting.get("max_value") is not None
):
parts.append(f"{setting['min_value']}..{setting['max_value']}")
elif setting.get("min_value") is not None:
parts.append(f">={setting['min_value']}")
elif setting.get("max_value") is not None:
parts.append(f"<={setting['max_value']}")
if setting.get("allowed_values"):
parts.append(", ".join(setting["allowed_values"]))
return " | ".join(parts) if parts else ""
def generate_docs_content(root_dir: Optional[Path] = None) -> str:
"""Generate the full CONFIGURATION.md content as a string."""
root_dir = root_dir or get_project_root()
defaults_dir = root_dir / "src" / "local_deep_research" / "defaults"
settings: Dict[str, Any] = {}
# Recursively find all JSON files
for json_file in sorted(defaults_dir.rglob("*.json")):
try:
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
settings.update(data)
except Exception as e:
print(f"Warning: Could not load {json_file}: {e}")
sorted_keys = sorted(settings.keys())
# Get env-only settings
env_only_settings = get_env_only_settings(root_dir)
# Build markdown
content = [
"# Configuration Reference",
"",
"This document is automatically generated from the application's default settings.",
"All settings can be configured via the Web UI (Settings page), or overridden via Environment Variables.",
"",
"## Environment Variables",
"",
"To override a setting using an environment variable, convert the key to uppercase, replace dots with underscores, and prefix with `LDR_`.",
"For example, `app.debug` becomes `LDR_APP_DEBUG`.",
"",
"Configuration Priority: Web UI Config > Environment Variables > Default Values",
"> Environmental Variables are used to override default values, easing installation, while allowing for adjustments to configuration via Web UI.",
"",
"### System Locking",
"There is a special environment variable `LDR_LOCKED_SETTINGS` that allows administrators to strictly enforce specific settings.",
"",
"* **Variable**: `LDR_LOCKED_SETTINGS`",
"* **Format**: Comma-separated list of setting keys (e.g., `llm.model,app.port`)",
"* **Behavior**:",
" 1. Any setting listed here **MUST** have a corresponding value defined in the environment variables (e.g., `LDR_LLM_MODEL`). If not, the application will fail to start.",
" 2. The setting becomes **read-only** in the Web UI.",
" 3. The **Environment Variable** value takes absolute precedence, ignoring any value in the database.",
"",
"**Priority for Locked Settings**: Environment Variable > Database (Ignored) > Default (Ignored)",
"",
"",
]
# Env-only section with expanded columns
if env_only_settings:
content.extend(
[
"## Pre-Database (Env-Only) Settings",
"",
"These settings are **required before database initialization** and can only be set via environment variables.",
"They are not available in the Web UI because they are needed to start the application.",
"",
"| Environment Variable | Type | Default | Required | Constraints | Description | Category | Deprecated Alias |",
"|----------------------|------|---------|----------|-------------|-------------|----------|------------------|",
]
)
for setting in sorted(env_only_settings, key=lambda x: x["env_var"]):
env_var = setting["env_var"]
stype = setting["type"]
default = setting["default"]
required = "Yes" if setting.get("required") else "No"
constraints = _format_constraints(setting).replace("|", "\\|")
desc = setting["description"].replace("|", "\\|").replace("\n", " ")
category = setting["category"]
deprecated = setting.get("deprecated_env_var") or ""
row = (
f"| `{env_var}` | {stype} | `{default}` | {required} "
f"| {constraints} | {desc} | {category} | {deprecated} |"
)
content.append(row)
content.extend(["", ""])
# Main settings list
content.extend(
[
"## Settings List",
"",
"| Key | Environment Variable | Default Value | Description | Type |",
"|-----|----------------------|---------------|-------------|------|",
]
)
for key in sorted_keys:
setting = settings[key]
env_var = setting.get("env_var") or get_env_var_name(key)
default_val = format_value(setting.get("value"))
description = (
setting.get("description", "")
.replace("\n", " ")
.replace("|", "\\|")
)
setting_type = setting.get("type", "UNKNOWN")
row = f"| `{key}` | `{env_var}` | `{default_val}` | {description} | {setting_type} |"
content.append(row)
content.append("")
content.append("*Generated by scripts/generate_config_docs.py*")
return "\n".join(content) + "\n"
def generate_docs(
output_path: Optional[Path] = None,
check: bool = False,
) -> int:
"""Generate (or check) CONFIGURATION.md.
Returns 0 on success, 1 if check finds stale docs.
"""
root_dir = get_project_root()
output_file = output_path or (root_dir / "docs" / "CONFIGURATION.md")
new_content = generate_docs_content(root_dir)
if check:
if not output_file.exists():
print(
f"FAIL: {output_file} does not exist. "
"Run 'python scripts/generate_config_docs.py' to generate it."
)
return 1
existing = output_file.read_text(encoding="utf-8")
if existing == new_content:
print("OK: Configuration docs are up to date.")
return 0
print(
f"FAIL: {output_file} is out of date. "
"Run 'python scripts/generate_config_docs.py' to regenerate it."
)
return 1
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(new_content, encoding="utf-8")
print(f"Wrote {output_file}")
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Generate CONFIGURATION.md from defaults"
)
parser.add_argument(
"--output",
"-o",
type=Path,
help="Output file path (default: docs/CONFIGURATION.md)",
)
parser.add_argument(
"--check",
action="store_true",
help="Check if docs are up to date (exit 1 if stale)",
)
args = parser.parse_args()
sys.exit(generate_docs(output_path=args.output, check=args.check))