2025-01-05 14:41:07 +01:00
|
|
|
#!.venv/bin/python
|
|
|
|
|
"""Utility functions for Configuration specification generation."""
|
|
|
|
|
|
|
|
|
|
import argparse
|
2025-01-15 00:54:45 +01:00
|
|
|
import json
|
2025-02-12 21:35:51 +01:00
|
|
|
import os
|
2025-06-10 22:00:28 +02:00
|
|
|
import re
|
2025-01-05 14:41:07 +01:00
|
|
|
import sys
|
2025-01-15 00:54:45 +01:00
|
|
|
import textwrap
|
2025-01-19 21:47:21 +01:00
|
|
|
from pathlib import Path
|
2025-11-13 22:53:46 +01:00
|
|
|
from typing import Any, Optional, Type, Union, get_args
|
2025-01-15 00:54:45 +01:00
|
|
|
|
2025-06-10 22:00:28 +02:00
|
|
|
from loguru import logger
|
2025-01-15 00:54:45 +01:00
|
|
|
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
|
|
|
|
from pydantic_core import PydanticUndefined
|
2025-01-05 14:41:07 +01:00
|
|
|
|
2026-02-23 20:59:03 +01:00
|
|
|
# Add the src directory to sys.path so import akkudoktoreos works in all cases
|
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent
|
|
|
|
|
SRC_DIR = PROJECT_ROOT / "src"
|
|
|
|
|
sys.path.insert(0, str(SRC_DIR))
|
|
|
|
|
|
2026-02-22 14:12:42 +01:00
|
|
|
from akkudoktoreos.config.config import ConfigEOS, default_data_folder_path
|
|
|
|
|
from akkudoktoreos.core.coreabc import get_config, singletons_init
|
2025-01-15 00:54:45 +01:00
|
|
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
2025-10-28 02:50:31 +01:00
|
|
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
2025-01-05 14:41:07 +01:00
|
|
|
|
2025-01-15 00:54:45 +01:00
|
|
|
documented_types: set[PydanticBaseModel] = set()
|
|
|
|
|
undocumented_types: dict[PydanticBaseModel, tuple[str, list[str]]] = dict()
|
2025-01-05 14:41:07 +01:00
|
|
|
|
2025-01-20 23:39:43 +01:00
|
|
|
global_config_dict: dict[str, Any] = dict()
|
|
|
|
|
|
2025-01-05 14:41:07 +01:00
|
|
|
|
2025-11-13 22:53:46 +01:00
|
|
|
def get_model_class_from_annotation(field_type: Any) -> type[PydanticBaseModel] | None:
|
|
|
|
|
"""Given a type annotation (possibly Optional or Union), return the first Pydantic model class."""
|
|
|
|
|
origin = getattr(field_type, "__origin__", None)
|
|
|
|
|
if origin is Union:
|
|
|
|
|
# unwrap Union/Optional
|
|
|
|
|
for arg in get_args(field_type):
|
|
|
|
|
cls = get_model_class_from_annotation(arg)
|
|
|
|
|
if cls is not None:
|
|
|
|
|
return cls
|
|
|
|
|
return None
|
|
|
|
|
elif isinstance(field_type, type) and issubclass(field_type, PydanticBaseModel):
|
|
|
|
|
return field_type
|
|
|
|
|
else:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_title(config: type[PydanticBaseModel]) -> str:
|
2025-01-15 00:54:45 +01:00
|
|
|
if config.__doc__ is None:
|
|
|
|
|
raise NameError(f"Missing docstring: {config}")
|
|
|
|
|
return config.__doc__.strip().splitlines()[0].strip(".")
|
|
|
|
|
|
|
|
|
|
|
2025-11-13 22:53:46 +01:00
|
|
|
def get_body(config: type[PydanticBaseModel]) -> str:
|
2025-01-15 00:54:45 +01:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
def get_example_or_default(field_name: str, field_info: FieldInfo, example_ix: int) -> Any:
|
2025-11-10 16:57:44 +01:00
|
|
|
"""Generate a default value for a field, considering constraints.
|
2025-10-28 02:50:31 +01:00
|
|
|
|
2025-11-10 16:57:44 +01:00
|
|
|
Priority:
|
|
|
|
|
1. field_info.examples
|
|
|
|
|
2. field_info.example
|
|
|
|
|
3. json_schema_extra['examples']
|
|
|
|
|
4. json_schema_extra['example']
|
|
|
|
|
5. field_info.default
|
|
|
|
|
"""
|
|
|
|
|
# 1. Old-style examples attribute
|
|
|
|
|
examples = getattr(field_info, "examples", None)
|
|
|
|
|
if examples is not None:
|
|
|
|
|
try:
|
|
|
|
|
return examples[example_ix]
|
|
|
|
|
except IndexError:
|
|
|
|
|
return examples[-1]
|
|
|
|
|
|
|
|
|
|
# 2. Old-style single example
|
|
|
|
|
example = getattr(field_info, "example", None)
|
|
|
|
|
if example is not None:
|
|
|
|
|
return example
|
|
|
|
|
|
|
|
|
|
# 3. Look into json_schema_extra (new style)
|
|
|
|
|
extra = getattr(field_info, "json_schema_extra", {}) or {}
|
|
|
|
|
|
|
|
|
|
examples = extra.get("examples")
|
|
|
|
|
if examples is not None:
|
|
|
|
|
try:
|
|
|
|
|
return examples[example_ix]
|
|
|
|
|
except IndexError:
|
|
|
|
|
return examples[-1]
|
|
|
|
|
|
|
|
|
|
example = extra.get("example")
|
|
|
|
|
if example is not None:
|
|
|
|
|
return example
|
|
|
|
|
|
|
|
|
|
# 5. Default
|
|
|
|
|
if getattr(field_info, "default", None) not in (None, ...):
|
2025-10-28 02:50:31 +01:00
|
|
|
return field_info.default
|
|
|
|
|
|
2025-11-10 16:57:44 +01:00
|
|
|
raise NotImplementedError(
|
|
|
|
|
f"No default or example provided for field '{field_name}': {field_info}"
|
|
|
|
|
)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_model_structure_from_examples(
|
|
|
|
|
model_class: type[PydanticBaseModel], multiple: bool
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
"""Create a model instance with default or example values, respecting constraints."""
|
|
|
|
|
example_max_length = 1
|
|
|
|
|
|
|
|
|
|
# Get first field with examples (non-default) to get example_max_length
|
|
|
|
|
if multiple:
|
|
|
|
|
for _, field_info in model_class.model_fields.items():
|
|
|
|
|
if field_info.examples is not None:
|
|
|
|
|
example_max_length = len(field_info.examples)
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
example_data: list[dict[str, Any]] = [{} for _ in range(example_max_length)]
|
|
|
|
|
|
|
|
|
|
for field_name, field_info in model_class.model_fields.items():
|
|
|
|
|
if field_info.deprecated:
|
|
|
|
|
continue
|
|
|
|
|
for example_ix in range(example_max_length):
|
|
|
|
|
example_data[example_ix][field_name] = get_example_or_default(
|
|
|
|
|
field_name, field_info, example_ix
|
|
|
|
|
)
|
|
|
|
|
return example_data
|
|
|
|
|
|
|
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
def create_model_from_examples(
|
2025-11-13 22:53:46 +01:00
|
|
|
model_class: type[PydanticBaseModel], multiple: bool
|
2025-01-19 18:12:50 +01:00
|
|
|
) -> list[PydanticBaseModel]:
|
2025-01-15 00:54:45 +01:00
|
|
|
"""Create a model instance with default or example values, respecting constraints."""
|
2025-01-19 18:12:50 +01:00
|
|
|
return [
|
|
|
|
|
model_class(**data) for data in get_model_structure_from_examples(model_class, multiple)
|
|
|
|
|
]
|
2025-01-15 00:54:45 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2025-02-11 21:01:45 +01:00
|
|
|
type_name = str(field_type).replace("typing.", "").replace("pathlib._local", "pathlib")
|
2025-01-15 00:54:45 +01:00
|
|
|
if type_name.startswith("<class"):
|
|
|
|
|
type_name = field_type.__name__
|
|
|
|
|
return type_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_config_table_md(
|
2025-11-13 22:53:46 +01:00
|
|
|
config: type[PydanticBaseModel],
|
2025-01-15 00:54:45 +01:00
|
|
|
toplevel_keys: list[str],
|
|
|
|
|
prefix: str,
|
|
|
|
|
toplevel: bool = False,
|
|
|
|
|
extra_config: bool = False,
|
|
|
|
|
) -> str:
|
2025-01-05 14:41:07 +01:00
|
|
|
"""Generate a markdown table for given configurations.
|
|
|
|
|
|
|
|
|
|
Args:
|
2025-01-15 00:54:45 +01:00
|
|
|
config (PydanticBaseModel): PydanticBaseModel configuration definition.
|
|
|
|
|
prefix (str): Prefix for table entries.
|
2025-01-05 14:41:07 +01:00
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: The markdown table as a string.
|
|
|
|
|
"""
|
2025-01-15 00:54:45 +01:00
|
|
|
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 += (
|
2025-11-13 22:53:46 +01:00
|
|
|
"<!-- pyml disable line-length -->\n"
|
2025-01-15 00:54:45 +01:00
|
|
|
":::{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"
|
|
|
|
|
|
2025-11-13 22:53:46 +01:00
|
|
|
|
|
|
|
|
fields = {}
|
|
|
|
|
for field_name, field_info in config.model_fields.items():
|
|
|
|
|
fields[field_name] = field_info
|
|
|
|
|
for field_name, field_info in config.model_computed_fields.items():
|
|
|
|
|
fields[field_name] = field_info
|
|
|
|
|
for field_name in sorted(fields.keys()):
|
|
|
|
|
field_info = fields[field_name]
|
2025-01-15 00:54:45 +01:00
|
|
|
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)
|
2025-11-13 22:53:46 +01:00
|
|
|
description = config.field_description(field_name)
|
|
|
|
|
deprecated = config.field_deprecated(field_name)
|
2025-01-15 00:54:45 +01:00
|
|
|
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 = "| "
|
2025-06-10 22:00:28 +02:00
|
|
|
if deprecated:
|
|
|
|
|
if isinstance(deprecated, bool):
|
|
|
|
|
description = "Deprecated!"
|
|
|
|
|
else:
|
|
|
|
|
description = deprecated
|
2025-01-15 00:54:45 +01:00
|
|
|
table += f"| {field_name} {env_entry}| `{type_name}` | `{read_only}` | `{default_value}` | {description} |\n"
|
|
|
|
|
|
2025-10-30 10:54:36 +01:00
|
|
|
# inner_types: dict[type[PydanticBaseModel], tuple[str, list[str]]] = dict()
|
|
|
|
|
inner_types: dict[Any, tuple[str, list[str]]] = dict()
|
2025-01-15 00:54:45 +01:00
|
|
|
|
|
|
|
|
def extract_nested_models(subtype: Any, subprefix: str, parent_types: list[str]):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Extract nested models."""
|
2025-01-15 00:54:45 +01:00
|
|
|
if subtype in inner_types.keys():
|
|
|
|
|
return
|
|
|
|
|
nested_types = resolve_nested_types(subtype, [])
|
|
|
|
|
for nested_type, nested_parent_types in nested_types:
|
2025-10-30 10:54:36 +01:00
|
|
|
# Nested type may be of type class, enum, typing.Any
|
|
|
|
|
if isinstance(nested_type, type) and issubclass(nested_type, PydanticBaseModel):
|
|
|
|
|
# Nested type is a subclass of PydanticBaseModel
|
2025-01-15 00:54:45 +01:00
|
|
|
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))
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
# Handle normal fields
|
|
|
|
|
for nested_field_name, nested_field_info in nested_type.model_fields.items():
|
2025-01-15 00:54:45 +01:00
|
|
|
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],
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
# Do not extract computed fields
|
|
|
|
|
|
2025-01-15 00:54:45 +01:00
|
|
|
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:
|
2025-11-13 22:53:46 +01:00
|
|
|
table += ":::\n<!-- pyml enable line-length -->\n\n" # Add an empty line after the table
|
2025-01-15 00:54:45 +01:00
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
has_examples_list = toplevel_keys[-1] == "list"
|
|
|
|
|
instance_list = create_model_from_examples(config, has_examples_list)
|
|
|
|
|
if instance_list:
|
|
|
|
|
ins_dict_list = []
|
|
|
|
|
ins_out_dict_list = []
|
|
|
|
|
for ins in instance_list:
|
|
|
|
|
# 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_list.append(json.loads(ins_json))
|
2025-01-15 00:54:45 +01:00
|
|
|
|
2025-01-19 18:12:50 +01:00
|
|
|
ins_out_json = ins.model_dump_json(include_computed_fields=True)
|
|
|
|
|
ins_out_dict_list.append(json.loads(ins_out_json))
|
|
|
|
|
|
|
|
|
|
same_output = ins_out_dict_list == ins_dict_list
|
2025-01-15 00:54:45 +01:00
|
|
|
same_output_str = "/Output" if same_output else ""
|
|
|
|
|
|
2025-11-13 22:53:46 +01:00
|
|
|
# -- code block heading
|
|
|
|
|
table += "<!-- pyml disable no-emphasis-as-heading -->\n"
|
|
|
|
|
table += f"**Example Input{same_output_str}**\n"
|
|
|
|
|
table += "<!-- pyml enable no-emphasis-as-heading -->\n\n"
|
|
|
|
|
# -- code block
|
|
|
|
|
table += "<!-- pyml disable line-length -->\n"
|
|
|
|
|
table += "```json\n"
|
2025-01-19 18:12:50 +01:00
|
|
|
if has_examples_list:
|
|
|
|
|
input_dict = build_nested_structure(toplevel_keys[:-1], ins_dict_list)
|
2025-01-20 23:39:43 +01:00
|
|
|
if not extra_config:
|
|
|
|
|
global_config_dict[toplevel_keys[0]] = ins_dict_list
|
2025-01-19 18:12:50 +01:00
|
|
|
else:
|
|
|
|
|
input_dict = build_nested_structure(toplevel_keys, ins_dict_list[0])
|
2025-01-20 23:39:43 +01:00
|
|
|
if not extra_config:
|
|
|
|
|
global_config_dict[toplevel_keys[0]] = ins_dict_list[0]
|
2025-01-15 00:54:45 +01:00
|
|
|
table += textwrap.indent(json.dumps(input_dict, indent=4), " ")
|
2025-11-13 22:53:46 +01:00
|
|
|
table += "\n```\n<!-- pyml enable line-length -->\n\n"
|
|
|
|
|
# -- end code block
|
2025-01-15 00:54:45 +01:00
|
|
|
|
|
|
|
|
if not same_output:
|
2025-11-13 22:53:46 +01:00
|
|
|
# -- code block heading
|
|
|
|
|
table += "<!-- pyml disable no-emphasis-as-heading -->\n"
|
|
|
|
|
table += f"**Example Output**\n"
|
|
|
|
|
table += "<!-- pyml enable no-emphasis-as-heading -->\n\n"
|
|
|
|
|
# -- code block
|
|
|
|
|
table += "<!-- pyml disable line-length -->\n"
|
|
|
|
|
table += "```json\n"
|
2025-01-19 18:12:50 +01:00
|
|
|
if has_examples_list:
|
|
|
|
|
output_dict = build_nested_structure(toplevel_keys[:-1], ins_out_dict_list)
|
|
|
|
|
else:
|
|
|
|
|
output_dict = build_nested_structure(toplevel_keys, ins_out_dict_list[0])
|
2025-01-15 00:54:45 +01:00
|
|
|
table += textwrap.indent(json.dumps(output_dict, indent=4), " ")
|
2025-11-13 22:53:46 +01:00
|
|
|
table += "\n```\n<!-- pyml enable line-length -->\n\n"
|
|
|
|
|
# -- end code block
|
2025-01-15 00:54:45 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
)
|
2025-01-05 14:41:07 +01:00
|
|
|
|
|
|
|
|
return table
|
|
|
|
|
|
|
|
|
|
|
2025-11-13 22:53:46 +01:00
|
|
|
def generate_config_md(file_path: Optional[Union[str, Path]], config_eos: ConfigEOS) -> str:
|
2025-01-05 14:41:07 +01:00
|
|
|
"""Generate configuration specification in Markdown with extra tables for prefixed values.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: The Markdown representation of the configuration spec.
|
|
|
|
|
"""
|
2025-11-13 22:53:46 +01:00
|
|
|
markdown = ""
|
2025-01-05 14:41:07 +01:00
|
|
|
|
2025-11-13 22:53:46 +01:00
|
|
|
if file_path:
|
|
|
|
|
file_path = Path(file_path)
|
|
|
|
|
# -- table of content
|
|
|
|
|
markdown += "```{toctree}\n"
|
|
|
|
|
markdown += ":maxdepth: 1\n"
|
|
|
|
|
markdown += ":caption: Configuration Table\n\n"
|
|
|
|
|
else:
|
|
|
|
|
markdown += "# Configuration Table\n\n"
|
|
|
|
|
markdown += (
|
|
|
|
|
"The configuration table describes all the configuration options of Akkudoktor-EOS\n\n"
|
2025-01-15 00:54:45 +01:00
|
|
|
)
|
2025-01-05 14:41:07 +01:00
|
|
|
|
2025-11-13 22:53:46 +01:00
|
|
|
# Generate tables for each top level config
|
|
|
|
|
for field_name in sorted(config_eos.__class__.model_fields.keys()):
|
|
|
|
|
field_info = config_eos.__class__.model_fields[field_name]
|
|
|
|
|
field_type = field_info.annotation
|
|
|
|
|
model_class = get_model_class_from_annotation(field_type)
|
|
|
|
|
if model_class is None:
|
|
|
|
|
raise ValueError(f"Can not find class of top level field {field_name}.")
|
|
|
|
|
table = generate_config_table_md(
|
|
|
|
|
model_class, [field_name], f"EOS_{field_name.upper()}__", True
|
|
|
|
|
)
|
|
|
|
|
if file_path:
|
|
|
|
|
# Write table to extra document
|
|
|
|
|
table_path = file_path.with_name(file_path.stem + f"{field_name.lower()}.md")
|
|
|
|
|
write_to_file(table_path, table)
|
|
|
|
|
markdown += f"../_generated/{table_path.name}\n"
|
|
|
|
|
else:
|
|
|
|
|
# We will write to stdout
|
|
|
|
|
markdown += "---\n\n"
|
|
|
|
|
markdown += table
|
|
|
|
|
|
|
|
|
|
# Generate full example
|
|
|
|
|
example = ""
|
2025-01-20 23:39:43 +01:00
|
|
|
# Full config
|
2025-11-13 22:53:46 +01:00
|
|
|
example += "## Full example Config\n\n"
|
|
|
|
|
# -- code block
|
|
|
|
|
example += "<!-- pyml disable line-length -->\n"
|
|
|
|
|
example += "```json\n"
|
2025-01-20 23:39:43 +01:00
|
|
|
# Test for valid config first
|
|
|
|
|
config_eos.merge_settings_from_dict(global_config_dict)
|
2025-11-13 22:53:46 +01:00
|
|
|
example += textwrap.indent(json.dumps(global_config_dict, indent=4), " ")
|
|
|
|
|
example += "\n"
|
|
|
|
|
example += "```\n<!-- pyml enable line-length -->\n\n"
|
|
|
|
|
# -- end code block end
|
|
|
|
|
if file_path:
|
|
|
|
|
example_path = file_path.with_name(file_path.stem + f"example.md")
|
|
|
|
|
write_to_file(example_path, example)
|
|
|
|
|
markdown += f"../_generated/{example_path.name}\n"
|
|
|
|
|
markdown += "```\n\n"
|
|
|
|
|
# -- end table of content
|
|
|
|
|
else:
|
|
|
|
|
markdown += "---\n\n"
|
|
|
|
|
markdown += example
|
2025-01-20 23:39:43 +01:00
|
|
|
|
2025-01-15 00:54:45 +01:00
|
|
|
# Assure there is no double \n at end of file
|
2025-01-06 16:27:57 +01:00
|
|
|
markdown = markdown.rstrip("\n")
|
|
|
|
|
markdown += "\n"
|
|
|
|
|
|
2025-11-13 22:53:46 +01:00
|
|
|
markdown += "\nAuto generated from source code.\n"
|
|
|
|
|
|
|
|
|
|
# Write markdown to file or stdout
|
|
|
|
|
write_to_file(file_path, markdown)
|
|
|
|
|
|
|
|
|
|
return markdown
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_to_file(file_path: Optional[Union[str, Path]], config_md: str):
|
|
|
|
|
if os.name == "nt":
|
|
|
|
|
config_md = config_md.replace("\\\\", "/")
|
|
|
|
|
|
2025-06-10 22:00:28 +02:00
|
|
|
# Assure log path does not leak to documentation
|
2025-11-13 22:53:46 +01:00
|
|
|
config_md = re.sub(
|
2025-06-10 22:00:28 +02:00
|
|
|
r'(?<=["\'])/[^"\']*/output/eos\.log(?=["\'])',
|
2025-11-13 22:53:46 +01:00
|
|
|
'/home/user/.local/share/net.akkudoktor.eos/output/eos.log',
|
|
|
|
|
config_md
|
2025-06-10 22:00:28 +02:00
|
|
|
)
|
2026-02-22 14:12:42 +01:00
|
|
|
# Assure pathes are set to default for documentation
|
|
|
|
|
replacements = [
|
|
|
|
|
("data_folder_path", "/home/user/.local/share/net.akkudoktoreos.net"),
|
|
|
|
|
("data_output_path", "/home/user/.local/share/net.akkudoktoreos.net/output"),
|
|
|
|
|
("config_folder_path", "/home/user/.config/net.akkudoktoreos.net"),
|
|
|
|
|
("config_file_path", "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"),
|
|
|
|
|
]
|
|
|
|
|
for key, value in replacements:
|
|
|
|
|
config_md = re.sub(
|
|
|
|
|
rf'("{key}":\s*)"[^"]*"',
|
|
|
|
|
rf'\1"{value}"',
|
|
|
|
|
config_md
|
|
|
|
|
)
|
2025-06-10 22:00:28 +02:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
# Assure timezone name does not leak to documentation
|
|
|
|
|
tz_name = to_datetime().timezone_name
|
2025-11-13 22:53:46 +01:00
|
|
|
config_md = re.sub(re.escape(tz_name), "Europe/Berlin", config_md, flags=re.IGNORECASE)
|
2025-10-28 02:50:31 +01:00
|
|
|
# Also replace UTC, as GitHub CI always is on UTC
|
2025-11-13 22:53:46 +01:00
|
|
|
config_md = re.sub(re.escape("UTC"), "Europe/Berlin", config_md, flags=re.IGNORECASE)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
2025-11-13 22:53:46 +01:00
|
|
|
# Assure no extra lines at end of file
|
|
|
|
|
config_md = config_md.rstrip("\n")
|
|
|
|
|
config_md += "\n"
|
2025-10-28 02:50:31 +01:00
|
|
|
|
2025-11-13 22:53:46 +01:00
|
|
|
if file_path:
|
|
|
|
|
# Write to file
|
|
|
|
|
with open(Path(file_path), "w", encoding="utf-8", newline="\n") as f:
|
|
|
|
|
f.write(config_md)
|
|
|
|
|
else:
|
|
|
|
|
# Write to std output
|
|
|
|
|
print(config_md)
|
2025-01-05 14:41:07 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
"""Main function to run the generation of the Configuration specification as Markdown."""
|
|
|
|
|
parser = argparse.ArgumentParser(description="Generate Configuration Specification as Markdown")
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--output-file",
|
|
|
|
|
type=str,
|
|
|
|
|
default=None,
|
2025-11-13 22:53:46 +01:00
|
|
|
help="File to write the top level configuration specification to.",
|
2025-01-05 14:41:07 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
2026-02-22 14:12:42 +01:00
|
|
|
|
|
|
|
|
# Ensure we are in documentation mode
|
|
|
|
|
ConfigEOS._force_documentation_mode = True
|
|
|
|
|
|
|
|
|
|
# Make minimal config to make the generation reproducable
|
|
|
|
|
config_eos = get_config(init={
|
|
|
|
|
"with_init_settings": True,
|
|
|
|
|
"with_env_settings": False,
|
|
|
|
|
"with_dotenv_settings": False,
|
|
|
|
|
"with_file_settings": False,
|
|
|
|
|
"with_file_secret_settings": False,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Also init other singletons to get same list of e.g. providers
|
|
|
|
|
singletons_init()
|
2025-01-05 14:41:07 +01:00
|
|
|
|
|
|
|
|
try:
|
2025-11-13 22:53:46 +01:00
|
|
|
config_md = generate_config_md(args.output_file, config_eos)
|
2025-01-05 14:41:07 +01:00
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error during Configuration Specification generation: {e}", file=sys.stderr)
|
2025-01-20 23:39:43 +01:00
|
|
|
# keep throwing error to debug potential problems (e.g. invalid examples)
|
|
|
|
|
raise e
|
2026-02-22 14:12:42 +01:00
|
|
|
finally:
|
|
|
|
|
# Ensure we are out of documentation mode
|
|
|
|
|
ConfigEOS._force_documentation_mode = False
|
2025-01-05 14:41:07 +01:00
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|