fix(config): enable documentation mode only explicitly

ConfigEOS.documentation_mode() was true as soon as Sphinx was imported
anywhere in the process. A test importing Sphinx therefore switched all
later tests into documentation mode, and test_config_md_current failed
depending on test order. Documentation mode is now enabled only by the
documentation generators (_force_documentation_mode) or by the Sphinx
configuration, which sets sys._called_from_sphinx in docs/conf.py.

Declare _force_documentation_mode as a ClassVar. Without the annotation
pydantic turned it into a private attribute, and the class attribute was
a truthy descriptor object.

Behavior change: because of that descriptor, every running EOS was in
documentation mode, so the get_adapter/config/measurement/prediction/ems/
database/resource_registry() guards never raised "access before init" and
created the singletons lazily on first access. They now raise for every
access before initialization. The worker process of uvicorn's auto-reload
(server.reload, make run-dev) relied on that: the app's lifespan now sets up
the configuration and initializes the singletons that do not exist yet.

Leave runtime state out of the documentation: the Home Assistant entity ID
properties and OptimizationCommonSettings.keys return an empty list in
documentation mode, so entity IDs and solution keys of a running system
no longer reach the generated configuration docs.

Keep the tests isolated: test_doc sets documentation mode on the class
and test_docstringrst restores sys._called_from_sphinx after reading the
Sphinx configuration. Add tests for how documentation mode is enabled and
for the runtime values it leaves out.

Additional changes
- Generate openapi.json in documentation mode, as the config docs.
- Share the documentation mode check as coreabc.documentation_mode().
- Move the logging and server address setup of run_eos() to setup_eos_config().
- Test that EOS starts with server.reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bobby Noelte
2026-11-09 12:40:15 +01:00
co-authored by Claude Opus 5.5
parent a0e57a18b7
commit 57132df44e
12 changed files with 293 additions and 29 deletions
+10 -1
View File
@@ -10,7 +10,7 @@ from pydantic import Field, PrivateAttr, computed_field, field_validator
from akkudoktoreos.adapter.adapterabc import AdapterProvider
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_adapter
from akkudoktoreos.core.coreabc import documentation_mode, get_adapter
from akkudoktoreos.core.emplan import (
DDBCInstruction,
FRBCInstruction,
@@ -147,6 +147,9 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
@property
def homeassistant_entity_ids(self) -> list[str]:
"""Entity IDs available at Home Assistant."""
if documentation_mode():
# Documentation shows no runtime state.
return []
try:
adapter_eos = get_adapter()
provider = adapter_eos.provider_by_id("HomeAssistant")
@@ -164,6 +167,9 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
@property
def eos_solution_entity_ids(self) -> list[str]:
"""Entity IDs for optimization solution available at EOS."""
if documentation_mode():
# Documentation shows no runtime state.
return []
try:
adapter_eos = get_adapter()
provider = adapter_eos.provider_by_id("HomeAssistant")
@@ -181,6 +187,9 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
@property
def eos_device_instruction_entity_ids(self) -> list[str]:
"""Entity IDs for energy management instructions available at EOS."""
if documentation_mode():
# Documentation shows no runtime state.
return []
try:
adapter_eos = get_adapter()
provider = adapter_eos.provider_by_id("HomeAssistant")
+12 -5
View File
@@ -466,7 +466,9 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
_config_autosave: ClassVar[str] = ""
# Settings provided at runtime, e.g. by the REST interface. Highest priority after CLI.
_runtime_settings: ClassVar[dict[str, Any]] = {}
_force_documentation_mode = False
# A ClassVar: without the annotation pydantic makes it a private attribute,
# and the class attribute would be a (truthy) descriptor object.
_force_documentation_mode: ClassVar[bool] = False
def __hash__(self) -> int:
# ConfigEOS is a singleton
@@ -767,11 +769,16 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
def documentation_mode(cls) -> bool:
"""Are we running in documentation mode.
Some checks may be relaxed to allow for proper documentation execution.
Some checks may be relaxed to allow for proper documentation execution,
and runtime state is left out of the documentation.
Documentation mode is set explicitly: by the documentation generators
(``_force_documentation_mode``) or by the Sphinx configuration
(``sys._called_from_sphinx``). Merely having Sphinx imported, e.g. by a
test in the same process, does not enable it.
"""
# Detect if Sphinx is importing this module
is_sphinx = "sphinx" in sys.modules or getattr(sys, "_called_from_sphinx", False)
return cls._force_documentation_mode or is_sphinx
is_sphinx = getattr(sys, "_called_from_sphinx", False)
return bool(cls._force_documentation_mode or is_sphinx)
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initializes the singleton ConfigEOS instance.
+25
View File
@@ -106,6 +106,31 @@ class AdapterMixin:
return get_adapter()
def documentation_mode() -> bool:
"""Return whether EOS runs in documentation mode.
Shortcut for ``ConfigEOS.documentation_mode()`` for modules that cannot
import ``ConfigEOS`` at module level. Documentation shows no runtime state,
e.g. entity IDs of a Home Assistant instance or keys of an optimization
solution.
Returns:
True if the documentation is being generated.
"""
from akkudoktoreos.config.config import ConfigEOS
return ConfigEOS.documentation_mode()
def config_initialized() -> bool:
"""Return whether the configuration singleton exists, without creating it.
Returns:
True if ``get_config(init=...)`` already created the configuration.
"""
return _config_eos is not None
def get_config(init: Union[bool, dict[str, bool]] = False) -> ConfigEOS:
"""Retrieve the singleton EOS configuration instance.
@@ -4,7 +4,7 @@ from typing import Any, Optional
from pydantic import Field, computed_field, model_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.core.coreabc import documentation_mode, get_ems
from akkudoktoreos.core.pydantic import (
PydanticBaseModel,
PydanticDateTimeDataFrame,
@@ -92,6 +92,9 @@ class OptimizationCommonSettings(SettingsBaseModel):
@property
def keys(self) -> list[str]:
"""The keys of the solution."""
if documentation_mode():
# Documentation shows no runtime state, e.g. of a previous run.
return []
try:
ems_eos = get_ems()
except Exception:
+34 -10
View File
@@ -29,6 +29,7 @@ from loguru import logger
from akkudoktoreos.config.config import ConfigEOS, SettingsEOS
from akkudoktoreos.core.cache import CacheFileStore, cache_clear, cache_load, cache_save
from akkudoktoreos.core.coreabc import (
config_initialized,
get_config,
get_ems,
get_measurement,
@@ -189,6 +190,13 @@ def config_eos_ready() -> bool:
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Lifespan manager for the app."""
# On startup
if not config_initialized():
# The worker process of uvicorn's auto-reload (server.reload) imports the
# app afresh; run_eos() did not run in this process.
setup_eos_config(get_config(init=True))
# Create the singletons that do not exist yet; a no-op after run_eos().
singletons_init()
await load_eos_state()
# Prepare the Manager and all task that are handled by the manager
@@ -2498,19 +2506,18 @@ def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectRespons
return RedirectResponse(url="/docs", status_code=303)
def run_eos() -> None:
"""Run the EOS server with the specified configurations.
def setup_eos_config(config_eos: ConfigEOS) -> None:
"""Set up logging and the server address defaults from the configuration.
Starts the EOS server using the Uvicorn ASGI server. Logs an error and exits if
binding to the host and port fails.
Called once per process that serves the app: by ``run_eos()``, and by the
app's lifespan in the worker process of uvicorn's auto-reload.
Returns:
None
Args:
config_eos: The EOS configuration of this process.
Raises:
RuntimeError: If no server port is set after applying the defaults.
"""
# get_config(init=True) creates the configuration, includes also ARGV args.
# This should not be done before nor later
config_eos = get_config(init=True)
# Set logging to what is in config
logger.remove()
logging_track_config(config_eos, "logging", None, None)
@@ -2532,6 +2539,23 @@ def run_eos() -> None:
if config_eos.server.eosdash_port is None:
config_eos.set_nested_value("server/eosdash_port", config_eos.server.port + 1)
def run_eos() -> None:
"""Run the EOS server with the specified configurations.
Starts the EOS server using the Uvicorn ASGI server. Logs an error and exits if
binding to the host and port fails.
Returns:
None
"""
# get_config(init=True) creates the configuration, includes also ARGV args.
# This should not be done before nor later
config_eos = get_config(init=True)
setup_eos_config(config_eos)
if config_eos.server.port is None: # make mypy happy
raise RuntimeError("server.port is None despite default setup")
# Switch data directories ownership to user
fix_data_directories_permissions(run_as_user=config_eos.server.run_as_user)