#!/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))