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
+39
View File
@@ -885,3 +885,42 @@ class TestConfigProviderValidation:
for section, value in providers.items():
assert getattr(config, section).provider == value["provider"]
class TestDocumentationMode:
"""Documentation mode is enabled explicitly, never by an imported Sphinx."""
def test_imported_sphinx_does_not_enable_it(self, monkeypatch):
import sphinx # noqa: F401 # the module is in sys.modules now
monkeypatch.delattr("sys._called_from_sphinx", raising=False)
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", False)
assert not ConfigEOS.documentation_mode()
def test_sphinx_configuration_enables_it(self, monkeypatch):
monkeypatch.setattr("sys._called_from_sphinx", True, raising=False)
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", False)
assert ConfigEOS.documentation_mode()
def test_documentation_generators_enable_it(self, monkeypatch):
monkeypatch.delattr("sys._called_from_sphinx", raising=False)
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", True)
assert ConfigEOS.documentation_mode()
def test_optimization_solution_keys_are_left_out(self, monkeypatch):
"""Keys of a solution of a previous run do not reach the documentation."""
from akkudoktoreos.optimization import optimization
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
solution = Mock()
solution.solution.to_dataframe.return_value.columns.tolist.return_value = ["b", "a"]
ems = Mock()
ems.optimization_solution.return_value = solution
monkeypatch.setattr(optimization, "get_ems", lambda: ems)
monkeypatch.delattr("sys._called_from_sphinx", raising=False)
settings = OptimizationCommonSettings()
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", False)
assert settings.keys == ["a", "b"]
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", True)
assert settings.keys == []
+4 -2
View File
@@ -9,6 +9,8 @@ from unittest.mock import patch
import pytest
from akkudoktoreos.config.config import ConfigEOS
DIR_PROJECT_ROOT = Path(__file__).parent.parent
DIR_TESTDATA = Path(__file__).parent / "testdata"
@@ -205,10 +207,10 @@ def test_config_md_current(config_eos, set_other_timezone):
# Create test files
try:
config_eos._force_documentation_mode = True
ConfigEOS._force_documentation_mode = True
config_md = generate_config_md.generate_config_md(tested[0], config_eos)
finally:
config_eos._force_documentation_mode = False
ConfigEOS._force_documentation_mode = False
# Check test files are the same as the expected files
for i, expected_path in enumerate(expected):
+12 -1
View File
@@ -142,7 +142,18 @@ sphinx_conf = importlib.util.module_from_spec(spec)
sys.modules["sphinx_conf"] = sphinx_conf
if spec.loader is None:
raise AssertionError(f"Can not import sphinx_conf from {conf_path}")
spec.loader.exec_module(sphinx_conf)
# conf.py marks the process as a Sphinx build, which enables documentation mode
# of akkudoktoreos. Only its settings are read here: keep the other tests of this
# process out of documentation mode.
_called_from_sphinx = getattr(sys, "_called_from_sphinx", None)
try:
spec.loader.exec_module(sphinx_conf)
finally:
if _called_from_sphinx is None:
if hasattr(sys, "_called_from_sphinx"):
del sys._called_from_sphinx
else:
sys._called_from_sphinx = _called_from_sphinx # type: ignore[attr-defined]
# Build NapoleonConfig with all options
napoleon_config = NapoleonConfig(
+111
View File
@@ -2,6 +2,9 @@ import asyncio
import json
import os
import re
import socket
import subprocess
import sys
import time
from http import HTTPStatus
from pathlib import Path
@@ -395,6 +398,114 @@ class TestServerWithEnv:
)
class TestServerReload:
"""EOS with uvicorn's auto-reload (``server.reload``, used by ``make run-dev``)."""
STARTUP_TIMEOUT_S = 90
@staticmethod
def free_port() -> int:
"""Return a free TCP port on the default host that EOS accepts (1024..49151).
Starts above the ports of the other server tests (8503, 8504, 8555).
"""
for port in range(8513, 8600):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind((get_default_host(), port))
except OSError:
continue
return port
pytest.fail("No free port for the EOS reload test in 8513..8599")
@staticmethod
def stop_process_tree(process: subprocess.Popen) -> None:
"""Stop a process and all of its descendants (reloader, worker)."""
try:
parent = psutil.Process(process.pid)
processes = [*parent.children(recursive=True), parent]
except psutil.NoSuchProcess:
return
for proc in processes:
try:
proc.terminate()
except psutil.NoSuchProcess:
pass
_, alive = psutil.wait_procs(processes, timeout=15)
for proc in alive:
try:
proc.kill()
except psutil.NoSuchProcess:
pass
process.wait(timeout=15)
def test_server_starts_with_reload(self, tmp_path: Path) -> None:
"""The reload worker initializes the singletons that run_eos() set up in the parent.
The worker process imports the app afresh. Without initialization in the
app's lifespan, the startup fails with 'access before init'.
"""
eos_dir = tmp_path / "eos"
eos_dir.mkdir()
# An empty config file, so EOS does not fall back to the user's config file.
(eos_dir / "EOS.config.json").write_text("{}", encoding="utf-8")
host = get_default_host()
port = self.free_port()
env = os.environ.copy()
env.update(
{
"EOS_DIR": str(eos_dir),
"EOS_CONFIG_DIR": str(eos_dir),
"PYTHONIOENCODING": "utf-8",
}
)
log_path = tmp_path / "eos.log"
with log_path.open("w", encoding="utf-8") as log_file:
# cwd: the reloader watches the working directory, not the repository.
process = subprocess.Popen(
[
sys.executable,
"-m",
"akkudoktoreos.server.eos",
"--host",
host,
"--port",
str(port),
"--reload",
"true",
"--startup_eosdash",
"false",
],
cwd=tmp_path,
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
)
try:
health: dict = {}
deadline = time.monotonic() + self.STARTUP_TIMEOUT_S
while time.monotonic() < deadline and process.poll() is None:
try:
response = requests.get(f"http://{host}:{port}/v1/health", timeout=2)
if response.status_code == HTTPStatus.OK:
health = response.json()
break
except requests.RequestException:
pass
time.sleep(1)
worker_pids = {
child.pid for child in psutil.Process(process.pid).children(recursive=True)
} if process.poll() is None else set()
finally:
self.stop_process_tree(process)
log = log_path.read_text(encoding="utf-8", errors="replace")
assert health.get("status") == "alive", f"EOS with --reload did not start:\n{log}"
assert "before init" not in log, log
# The app runs in the reload worker, a child of the started process.
assert health.get("pid") in worker_pids
class TestEosdashRedirect:
"""Redirects to EOSdash must target an address the client can reach.
+21
View File
@@ -117,6 +117,27 @@ def test_homeassistant_settings_preserve_entity_lookup_and_unavailability(
assert getattr(settings, property_name) == []
@pytest.mark.parametrize(
"property_name",
["homeassistant_entity_ids", "eos_solution_entity_ids", "eos_device_instruction_entity_ids"],
)
def test_homeassistant_settings_leave_runtime_entities_out_of_documentation(
monkeypatch: pytest.MonkeyPatch, property_name: str
) -> None:
"""Entity IDs of a running system do not reach the documentation."""
provider = HomeAssistantAdapter()
lookup = MagicMock(return_value=["sensor.eos_example"])
monkeypatch.setattr(HomeAssistantAdapter, f"get_{property_name}", lookup)
adapter = MagicMock()
adapter.provider_by_id.return_value = provider
monkeypatch.setattr(homeassistant, "get_adapter", lambda: adapter)
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", True)
settings = HomeAssistantAdapterCommonSettings()
assert getattr(settings, property_name) == []
lookup.assert_not_called()
@pytest.mark.parametrize("operation", ["revert_settings", "list_backups"])
def test_backup_operations_report_an_uninitialized_path_as_an_invariant_failure(
config_eos: ConfigEOS, monkeypatch: pytest.MonkeyPatch, operation: str