Documentation: Support nested config

* Add examples to pydantic models.
This commit is contained in:
Dominique Lasserre
2025-01-15 00:54:45 +01:00
parent be26457563
commit d74a56b75a
29 changed files with 8200 additions and 9424 deletions

View File

@@ -2,74 +2,237 @@
"""Utility functions for Configuration specification generation."""
import argparse
import json
import sys
import textwrap
from typing import Any, Union
from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel
logger = get_logger(__name__)
config_eos = get_config()
# Fixed set of prefixes to filter configuration values and their respective titles
CONFIG_PREFIXES = {
"battery": "Battery Device Simulation Configuration",
"bev": "Battery Electric Vehicle Device Simulation Configuration",
"dishwasher": "Dishwasher Device Simulation Configuration",
"inverter": "Inverter Device Simulation Configuration",
"measurement": "Measurement Configuration",
"optimization": "General Optimization Configuration",
"server": "Server Configuration",
"elecprice": "Electricity Price Prediction Configuration",
"load": "Load Prediction Configuration",
"logging": "Logging Configuration",
"prediction": "General Prediction Configuration",
"pvforecast": "PV Forecast Configuration",
"weather": "Weather Forecast Configuration",
}
# Static set of configuration names to include in a separate table
GENERAL_CONFIGS = [
"config_default_file_path",
"config_file_path",
"config_folder_path",
"config_keys",
"config_keys_read_only",
"data_cache_path",
"data_cache_subpath",
"data_folder_path",
"data_output_path",
"data_output_subpath",
"latitude",
"longitude",
"package_root_path",
"timezone",
]
documented_types: set[PydanticBaseModel] = set()
undocumented_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
def generate_config_table_md(configs, title):
def get_title(config: PydanticBaseModel) -> str:
if config.__doc__ is None:
raise NameError(f"Missing docstring: {config}")
return config.__doc__.strip().splitlines()[0].strip(".")
def get_body(config: PydanticBaseModel) -> str:
if config.__doc__ is None:
raise NameError(f"Missing docstring: {config}")
return textwrap.dedent("\n".join(config.__doc__.strip().splitlines()[1:])).strip()
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
resolved_types: list[tuple[type, list[str]]] = []
origin = getattr(field_type, "__origin__", field_type)
if origin is Union:
for arg in getattr(field_type, "__args__", []):
resolved_types.extend(resolve_nested_types(arg, parent_types))
elif origin is list:
for arg in getattr(field_type, "__args__", []):
resolved_types.extend(resolve_nested_types(arg, parent_types + ["list"]))
else:
resolved_types.append((field_type, parent_types))
return resolved_types
def get_example_or_default(field_name: str, field_info: FieldInfo) -> dict[str, Any]:
"""Generate a default value for a field, considering constraints."""
if field_info.examples is not None:
return field_info.examples[0]
if field_info.default is not None:
return field_info.default
raise NotImplementedError(f"No default or example provided '{field_name}': {field_info}")
def create_model_from_examples(model_class: PydanticBaseModel) -> PydanticBaseModel:
"""Create a model instance with default or example values, respecting constraints."""
example_data = {}
for field_name, field_info in model_class.model_fields.items():
example_data[field_name] = get_example_or_default(field_name, field_info)
return model_class(**example_data)
def build_nested_structure(keys: list[str], value: Any) -> Any:
if not keys:
return value
current_key = keys[0]
if current_key == "list":
return [build_nested_structure(keys[1:], value)]
else:
return {current_key: build_nested_structure(keys[1:], value)}
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
default_value = ""
if regular_field:
if (val := field_info.default) is not PydanticUndefined:
default_value = val
else:
default_value = "required"
else:
default_value = "N/A"
return default_value
def get_type_name(field_type: type) -> str:
type_name = str(field_type).replace("typing.", "")
if type_name.startswith("<class"):
type_name = field_type.__name__
return type_name
def generate_config_table_md(
config: PydanticBaseModel,
toplevel_keys: list[str],
prefix: str,
toplevel: bool = False,
extra_config: bool = False,
) -> str:
"""Generate a markdown table for given configurations.
Args:
configs (dict): Configuration values with keys and their descriptions.
title (str): Title for the table.
config (PydanticBaseModel): PydanticBaseModel configuration definition.
prefix (str): Prefix for table entries.
Returns:
str: The markdown table as a string.
"""
if not configs:
return ""
table = ""
if toplevel:
title = get_title(config)
heading_level = "###" if extra_config else "##"
env_header = ""
env_header_underline = ""
env_width = ""
if not extra_config:
env_header = "| Environment Variable "
env_header_underline = "| -------------------- "
env_width = "20 "
table += f"{heading_level} {title}\n\n"
body = get_body(config)
if body:
table += body
table += "\n\n"
table += (
":::{table} "
+ f"{'::'.join(toplevel_keys)}\n:widths: 10 {env_width}10 5 5 30\n:align: left\n\n"
)
table += f"| Name {env_header}| Type | Read-Only | Default | Description |\n"
table += f"| ---- {env_header_underline}| ---- | --------- | ------- | ----------- |\n"
for field_name, field_info in list(config.model_fields.items()) + list(
config.model_computed_fields.items()
):
regular_field = isinstance(field_info, FieldInfo)
config_name = field_name if extra_config else field_name.upper()
field_type = field_info.annotation if regular_field else field_info.return_type
default_value = get_default_value(field_info, regular_field)
description = field_info.description if field_info.description else "-"
read_only = "rw" if regular_field else "ro"
type_name = get_type_name(field_type)
env_entry = ""
if not extra_config:
if regular_field:
env_entry = f"| `{prefix}{config_name}` "
else:
env_entry = "| "
table += f"| {field_name} {env_entry}| `{type_name}` | `{read_only}` | `{default_value}` | {description} |\n"
inner_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
def extract_nested_models(subtype: Any, subprefix: str, parent_types: list[str]):
if subtype in inner_types.keys():
return
nested_types = resolve_nested_types(subtype, [])
for nested_type, nested_parent_types in nested_types:
if issubclass(nested_type, PydanticBaseModel):
new_parent_types = parent_types + nested_parent_types
if "list" in parent_types:
new_prefix = ""
else:
new_prefix = f"{subprefix}"
inner_types.setdefault(nested_type, (new_prefix, new_parent_types))
for nested_field_name, nested_field_info in list(
nested_type.model_fields.items()
) + list(nested_type.model_computed_fields.items()):
nested_field_type = nested_field_info.annotation
if new_prefix:
new_prefix += f"{nested_field_name.upper()}__"
extract_nested_models(
nested_field_type,
new_prefix,
new_parent_types + [nested_field_name],
)
extract_nested_models(field_type, f"{prefix}{config_name}__", toplevel_keys + [field_name])
for new_type, info in inner_types.items():
if new_type not in documented_types:
undocumented_types.setdefault(new_type, (info[0], info[1]))
if toplevel:
table += ":::\n\n" # Add an empty line after the table
ins = create_model_from_examples(config)
if ins is not None:
# Transform to JSON (and manually to dict) to use custom serializers and then merge with parent keys
ins_json = ins.model_dump_json(include_computed_fields=False)
ins_dict = json.loads(ins_json)
ins_out_json = ins.model_dump_json(include_computed_fields=True)
ins_out_dict = json.loads(ins_out_json)
same_output = ins_out_dict == ins_dict
same_output_str = "/Output" if same_output else ""
table += f"#{heading_level} Example Input{same_output_str}\n\n"
table += "```{eval-rst}\n"
table += ".. code-block:: json\n\n"
input_dict = build_nested_structure(toplevel_keys, ins_dict)
table += textwrap.indent(json.dumps(input_dict, indent=4), " ")
table += "\n"
table += "```\n\n"
if not same_output:
table += f"#{heading_level} Example Output\n\n"
table += "```{eval-rst}\n"
table += ".. code-block:: json\n\n"
output_dict = build_nested_structure(toplevel_keys, ins_out_dict)
table += textwrap.indent(json.dumps(output_dict, indent=4), " ")
table += "\n"
table += "```\n\n"
while undocumented_types:
extra_config_type, extra_info = undocumented_types.popitem()
documented_types.add(extra_config_type)
table += generate_config_table_md(
extra_config_type, extra_info[1], extra_info[0], True, True
)
table = f"## {title}\n\n"
table += ":::{table} " + f"{title}\n:widths: 10 10 5 5 30\n:align: left\n\n"
table += "| Name | Type | Read-Only | Default | Description |\n"
table += "| ---- | ---- | --------- | ------- | ----------- |\n"
for name, config in sorted(configs.items()):
type_name = config["type"]
if type_name.startswith("typing."):
type_name = type_name[len("typing.") :]
table += f"| `{config['name']}` | `{type_name}` | `{config['read-only']}` | `{config['default']}` | {config['description']} |\n"
table += ":::\n\n" # Add an empty line after the table
return table
@@ -79,57 +242,16 @@ def generate_config_md() -> str:
Returns:
str: The Markdown representation of the configuration spec.
"""
# FIXME: Support for nested
configs = {}
config_keys = config_eos.model_fields_set
# config_keys_read_only = config_eos.config_keys_read_only
config_keys_read_only: list[str] = []
for config_key in config_keys:
config = {}
config["name"] = config_key
config["value"] = getattr(config_eos, config_key)
if config_key in config_keys_read_only:
config["read-only"] = "ro"
computed_field_info = config_eos.__pydantic_decorators__.computed_fields[
config_key
].info
config["default"] = "N/A"
config["description"] = computed_field_info.description
config["type"] = str(computed_field_info.return_type)
else:
config["read-only"] = "rw"
field_info = config_eos.model_fields[config_key]
config["default"] = field_info.default
config["description"] = field_info.description
config["type"] = str(field_info.annotation)
configs[config_key] = config
# Generate markdown for the main table
markdown = "# Configuration Table\n\n"
# Generate table for general configuration names
general_configs = {k: v for k, v in configs.items() if k in GENERAL_CONFIGS}
for k in general_configs.keys():
del configs[k] # Remove general configs from the main configs dictionary
markdown += generate_config_table_md(general_configs, "General Configuration Values")
# Generate tables for each top level config
for field_name, field_info in config_eos.model_fields.items():
field_type = field_info.annotation
markdown += generate_config_table_md(
field_type, [field_name], f"EOS_{field_name.upper()}__", True
)
non_prefixed_configs = {k: v for k, v in configs.items()}
# Generate tables for each prefix (sorted by value) and remove prefixed configs from the main dictionary
sorted_prefixes = sorted(CONFIG_PREFIXES.items(), key=lambda item: item[1])
for prefix, title in sorted_prefixes:
prefixed_configs = {k: v for k, v in configs.items() if k.startswith(prefix)}
for k in prefixed_configs.keys():
del non_prefixed_configs[k]
markdown += generate_config_table_md(prefixed_configs, title)
# Generate markdown for the remaining non-prefixed configs if any
if non_prefixed_configs:
markdown += generate_config_table_md(non_prefixed_configs, "Other Configuration Values")
# Assure the is no double \n at end of file
# Assure there is no double \n at end of file
markdown = markdown.rstrip("\n")
markdown += "\n"