EOS/tests/conftest.py
Bobby Noelte bd38b3c5ef
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
fix: logging, prediction update, multiple bugs (#584)
* Fix logging configuration issues that made logging stop operation. Switch to Loguru
  logging (from Python logging). Enable console and file logging with different log levels.
  Add logging documentation.

* Fix logging configuration and EOS configuration out of sync. Added tracking support
  for nested value updates of Pydantic models. This used to update the logging configuration
  when the EOS configurationm for logging is changed. Should keep logging config and EOS
  config in sync as long as all changes to the EOS logging configuration are done by
  set_nested_value(), which is the case for the REST API.

* Fix energy management task looping endlessly after the second update when trying to update
  the last_update datetime.

* Fix get_nested_value() to correctly take values from the dicts in a Pydantic model instance.

* Fix usage of model classes instead of model instances in nested value access when evaluation
  the value type that is associated to each key.

* Fix illegal json format in prediction documentation for PVForecastAkkudoktor provider.

* Fix documentation qirks and add EOS Connect to integrations.

* Support deprecated fields in configuration in documentation generation and EOSdash.

* Enhance EOSdash demo to show BrightSky humidity data (that is often missing)

* Update documentation reference to German EOS installation videos.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2025-06-10 22:00:28 +02:00

558 lines
18 KiB
Python

import json
import logging
import os
import signal
import subprocess
import sys
import tempfile
import time
from contextlib import contextmanager
from http import HTTPStatus
from pathlib import Path
from typing import Generator, Optional, Union
from unittest.mock import PropertyMock, patch
import pendulum
import psutil
import pytest
import requests
from _pytest.logging import LogCaptureFixture
from loguru import logger
from xprocess import ProcessStarter, XProcess
from akkudoktoreos.config.config import ConfigEOS, get_config
from akkudoktoreos.server.server import get_default_host
# -----------------------------------------------
# Adapt pytest logging handling to Loguru logging
# -----------------------------------------------
@pytest.fixture
def caplog(caplog: LogCaptureFixture):
"""Propagate Loguru logs to the pytest caplog handler."""
handler_id = logger.add(
caplog.handler,
format="{message}",
level=0,
filter=lambda record: record["level"].no >= caplog.handler.level,
enqueue=False, # Set to 'True' if your test is spawning child processes.
)
yield caplog
try:
logger.remove(handler_id)
except:
# May already be deleted
pass
@pytest.fixture
def reportlog(pytestconfig):
"""Propagate Loguru logs to the pytest terminal reporter."""
logging_plugin = pytestconfig.pluginmanager.getplugin("logging-plugin")
handler_id = logger.add(logging_plugin.report_handler, format="{message}")
yield
try:
logger.remove(handler_id)
except:
# May already be deleted
pass
@pytest.fixture(autouse=True)
def propagate_logs():
"""Deal with the pytest --log-cli-level command-line flag.
This option controls the standard logging logs, not loguru ones.
For this reason, we first install a PropagateHandler for compatibility.
"""
class PropagateHandler(logging.Handler):
def emit(self, record):
if logging.getLogger(record.name).isEnabledFor(record.levelno):
logging.getLogger(record.name).handle(record)
logger.remove()
logger.add(PropagateHandler(), format="{message}")
yield
@pytest.fixture()
def disable_debug_logging(scope="session", autouse=True):
"""Automatically disable debug logging for all tests."""
logger.remove() # Remove all loggers
logger.add(sys.stderr, level="INFO") # Only show INFO and above
# -----------------------------------------------
# Provide pytest options for specific test setups
# -----------------------------------------------
def pytest_addoption(parser):
parser.addoption(
"--full-run", action="store_true", default=False, help="Run with all optimization tests."
)
parser.addoption(
"--check-config-side-effect",
action="store_true",
default=False,
help="Verify that user config file is non-existent (will also fail if user config file exists before test run).",
)
parser.addoption(
"--system-test",
action="store_true",
default=False,
help="System test mode. Tests may access real resources, like prediction providers!",
)
@pytest.fixture
def is_full_run(request):
yield bool(request.config.getoption("--full-run"))
@pytest.fixture(autouse=True)
def config_mixin(config_eos):
with patch(
"akkudoktoreos.core.coreabc.ConfigMixin.config", new_callable=PropertyMock
) as config_mixin_patch:
config_mixin_patch.return_value = config_eos
yield config_mixin_patch
@pytest.fixture
def is_system_test(request):
yield bool(request.config.getoption("--system-test"))
@pytest.fixture
def prediction_eos():
from akkudoktoreos.prediction.prediction import get_prediction
return get_prediction()
@pytest.fixture
def devices_eos(config_mixin):
from akkudoktoreos.devices.devices import get_devices
devices = get_devices()
print("devices_eos reset!")
devices.reset()
return devices
@pytest.fixture
def devices_mixin(devices_eos):
with patch(
"akkudoktoreos.core.coreabc.DevicesMixin.devices", new_callable=PropertyMock
) as devices_mixin_patch:
devices_mixin_patch.return_value = devices_eos
yield devices_mixin_patch
# Test if test has side effect of writing to system (user) config file
# Before activating, make sure that no user config file exists (e.g. ~/.config/net.akkudoktoreos.eos/EOS.config.json)
@pytest.fixture(autouse=True)
def cfg_non_existent(request):
if not bool(request.config.getoption("--check-config-side-effect")):
yield
return
# Before test
from platformdirs import user_config_dir
user_dir = user_config_dir(ConfigEOS.APP_NAME)
user_config_file = Path(user_dir).joinpath(ConfigEOS.CONFIG_FILE_NAME)
cwd_config_file = Path.cwd().joinpath(ConfigEOS.CONFIG_FILE_NAME)
assert not user_config_file.exists(), (
f"Config file {user_config_file} exists, please delete before test!"
)
assert not cwd_config_file.exists(), (
f"Config file {cwd_config_file} exists, please delete before test!"
)
# Yield to test
yield
# After test
assert not user_config_file.exists(), (
f"Config file {user_config_file} created, please check test!"
)
assert not cwd_config_file.exists(), (
f"Config file {cwd_config_file} created, please check test!"
)
@pytest.fixture(autouse=True)
def user_cwd(config_default_dirs):
"""Patch cwd provided by module pathlib.Path.cwd."""
with patch(
"pathlib.Path.cwd",
return_value=config_default_dirs[1],
) as user_cwd_patch:
yield user_cwd_patch
@pytest.fixture(autouse=True)
def user_config_dir(config_default_dirs):
"""Patch user_config_dir provided by module platformdirs."""
with patch(
"akkudoktoreos.config.config.user_config_dir",
return_value=str(config_default_dirs[0]),
) as user_dir_patch:
yield user_dir_patch
@pytest.fixture(autouse=True)
def user_data_dir(config_default_dirs):
"""Patch user_data_dir provided by module platformdirs."""
with patch(
"akkudoktoreos.config.config.user_data_dir",
return_value=str(config_default_dirs[-1] / "data"),
) as user_dir_patch:
yield user_dir_patch
@pytest.fixture
def config_eos(
disable_debug_logging,
user_config_dir,
user_data_dir,
user_cwd,
config_default_dirs,
monkeypatch,
) -> ConfigEOS:
"""Fixture to reset EOS config to default values."""
monkeypatch.setenv(
"EOS_CONFIG__DATA_CACHE_SUBPATH", str(config_default_dirs[-1] / "data/cache")
)
monkeypatch.setenv(
"EOS_CONFIG__DATA_OUTPUT_SUBPATH", str(config_default_dirs[-1] / "data/output")
)
config_file = config_default_dirs[0] / ConfigEOS.CONFIG_FILE_NAME
config_file_cwd = config_default_dirs[1] / ConfigEOS.CONFIG_FILE_NAME
assert not config_file.exists()
assert not config_file_cwd.exists()
config_eos = get_config()
config_eos.reset_settings()
assert config_file == config_eos.general.config_file_path
assert config_file.exists()
assert not config_file_cwd.exists()
# Check user data directory pathes (config_default_dirs[-1] == data_default_dir_user)
assert config_default_dirs[-1] / "data" == config_eos.general.data_folder_path
assert config_default_dirs[-1] / "data/cache" == config_eos.cache.path()
assert config_default_dirs[-1] / "data/output" == config_eos.general.data_output_path
assert config_default_dirs[-1] / "data/output/eos.log" == config_eos.logging.file_path
return config_eos
@pytest.fixture
def config_default_dirs(tmpdir):
"""Fixture that provides a list of directories to be used as config dir."""
tmp_user_home_dir = Path(tmpdir)
# Default config directory from platform user config directory
config_default_dir_user = tmp_user_home_dir / "config"
# Default config directory from current working directory
config_default_dir_cwd = tmp_user_home_dir / "cwd"
config_default_dir_cwd.mkdir()
# Default config directory from default config file
config_default_dir_default = Path(__file__).parent.parent.joinpath("src/akkudoktoreos/data")
# Default data directory from platform user data directory
data_default_dir_user = tmp_user_home_dir
return (
config_default_dir_user,
config_default_dir_cwd,
config_default_dir_default,
data_default_dir_user,
)
@contextmanager
def server_base(xprocess: XProcess) -> Generator[dict[str, Union[str, int]], None, None]:
"""Fixture to start the server with temporary EOS_DIR and default config.
Args:
xprocess (XProcess): The pytest-xprocess fixture to manage the server process.
Yields:
dict[str, str]: A dictionary containing:
- "server" (str): URL of the server.
- "eos_dir" (str): Path to the temporary EOS_DIR.
"""
host = get_default_host()
port = 8503
eosdash_port = 8504
# Port of server may be still blocked by a server usage despite the other server already
# shut down. CLOSE_WAIT, TIME_WAIT may typically take up to 120 seconds.
server_timeout = 120
server = f"http://{host}:{port}"
eosdash_server = f"http://{host}:{eosdash_port}"
eos_tmp_dir = tempfile.TemporaryDirectory()
eos_dir = str(eos_tmp_dir.name)
class Starter(ProcessStarter):
# assure server to be installed
try:
project_dir = Path(__file__).parent.parent
subprocess.run(
[sys.executable, "-c", "import", "akkudoktoreos.server.eos"],
check=True,
env=os.environ,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=project_dir,
)
except subprocess.CalledProcessError:
subprocess.run(
[sys.executable, "-m", "pip", "install", "-e", str(project_dir)],
env=os.environ,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=project_dir,
)
# Set environment for server run
env = os.environ.copy()
env["EOS_DIR"] = eos_dir
env["EOS_CONFIG_DIR"] = eos_dir
# command to start server process
args = [
sys.executable,
"-m",
"akkudoktoreos.server.eos",
"--host",
host,
"--port",
str(port),
]
# Will wait for 'server_timeout' seconds before timing out
timeout = server_timeout
# xprocess will now attempt to clean up upon interruptions
terminate_on_interrupt = True
# checks if our server is ready
def startup_check(self):
try:
result = requests.get(f"{server}/v1/health", timeout=2)
if result.status_code == 200:
return True
except:
pass
return False
def cleanup_eos_eosdash():
# Cleanup any EOS process left.
if os.name == "nt":
# Windows does not provide SIGKILL
sigkill = signal.SIGTERM
else:
sigkill = signal.SIGKILL # type: ignore
# - Use pid on EOS health endpoint
try:
result = requests.get(f"{server}/v1/health", timeout=2)
if result.status_code == HTTPStatus.OK:
pid = result.json()["pid"]
os.kill(pid, sigkill)
time.sleep(1)
result = requests.get(f"{server}/v1/health", timeout=2)
assert result.status_code != HTTPStatus.OK
except:
pass
# - Use pids from processes on EOS port
for retries in range(int(server_timeout / 3)):
pids: list[int] = []
for conn in psutil.net_connections(kind="inet"):
if conn.laddr.port == port:
if conn.pid not in pids:
# Get fresh process info
try:
process = psutil.Process(conn.pid)
process_info = process.as_dict(attrs=["pid", "cmdline"])
if "akkudoktoreos.server.eos" in process_info["cmdline"]:
pids.append(conn.pid)
except:
# PID may already be dead
pass
for pid in pids:
os.kill(pid, sigkill)
if len(pids) == 0:
break
time.sleep(3)
assert len(pids) == 0
# Cleanup any EOSdash processes left.
# - Use pid on EOSdash health endpoint
try:
result = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
if result.status_code == HTTPStatus.OK:
pid = result.json()["pid"]
os.kill(pid, sigkill)
time.sleep(1)
result = requests.get(f"{eosdash_server}/eosdash/health", timeout=2)
assert result.status_code != HTTPStatus.OK
except:
pass
# - Use pids from processes on EOSdash port
for retries in range(int(server_timeout / 3)):
pids = []
for conn in psutil.net_connections(kind="inet"):
if conn.laddr.port == eosdash_port:
if conn.pid not in pids:
# Get fresh process info
try:
process = psutil.Process(conn.pid)
process_info = process.as_dict(attrs=["pid", "cmdline"])
if "akkudoktoreos.server.eosdash" in process_info["cmdline"]:
pids.append(conn.pid)
except:
# PID may already be dead
pass
for pid in pids:
os.kill(pid, sigkill)
if len(pids) == 0:
break
time.sleep(3)
assert len(pids) == 0
# Kill all running eos and eosdash process - just to be sure
cleanup_eos_eosdash()
# Ensure there is an empty config file in the temporary EOS directory
config_file_path = Path(eos_dir).joinpath(ConfigEOS.CONFIG_FILE_NAME)
with config_file_path.open(mode="w", encoding="utf-8", newline="\n") as fd:
json.dump({}, fd)
# ensure process is running and return its logfile
pid, logfile = xprocess.ensure("eos", Starter)
logger.info(f"Started EOS ({pid}). This may take very long (up to {server_timeout} seconds).")
logger.info(f"View xprocess logfile at: {logfile}")
yield {
"server": server,
"eosdash_server": eosdash_server,
"eos_dir": eos_dir,
"timeout": server_timeout,
}
# clean up whole process tree afterwards
xprocess.getinfo("eos").terminate()
# Cleanup any EOS process left.
cleanup_eos_eosdash()
# Remove temporary EOS_DIR
eos_tmp_dir.cleanup()
@pytest.fixture(scope="class")
def server_setup_for_class(xprocess) -> Generator[dict[str, Union[str, int]], None, None]:
"""A fixture to start the server for a test class."""
with server_base(xprocess) as result:
yield result
@pytest.fixture(scope="function")
def server_setup_for_function(xprocess) -> Generator[dict[str, Union[str, int]], None, None]:
"""A fixture to start the server for a test function."""
with server_base(xprocess) as result:
yield result
@pytest.fixture
def server(xprocess, config_eos, config_default_dirs) -> Generator[str, None, None]:
"""Fixture to start the server.
Provides URL of the server.
"""
# create url/port info to the server
url = "http://127.0.0.1:8503"
class Starter(ProcessStarter):
# Set environment before any subprocess run, to keep custom config dir
env = os.environ.copy()
env["EOS_DIR"] = str(config_default_dirs[-1])
project_dir = config_eos.package_root_path.parent.parent
# assure server to be installed
try:
subprocess.run(
[sys.executable, "-c", "import", "akkudoktoreos.server.eos"],
check=True,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=project_dir,
)
except subprocess.CalledProcessError:
subprocess.run(
[sys.executable, "-m", "pip", "install", "-e", str(project_dir)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# command to start server process
args = [sys.executable, "-m", "akkudoktoreos.server.eos"]
# will wait for xx seconds before timing out
timeout = 10
# xprocess will now attempt to clean up upon interruptions
terminate_on_interrupt = True
# checks if our server is ready
def startup_check(self):
try:
result = requests.get(f"{url}/v1/health")
if result.status_code == 200:
return True
except:
pass
return False
# ensure process is running and return its logfile
pid, logfile = xprocess.ensure("eos", Starter)
print(f"View xprocess logfile at: {logfile}")
yield url
# clean up whole process tree afterwards
xprocess.getinfo("eos").terminate()
@pytest.fixture
def set_other_timezone():
"""Temporarily sets a timezone for Pendulum during a test.
Resets to the original timezone after the test completes.
"""
original_timezone = pendulum.local_timezone()
default_other_timezone = "Atlantic/Canary"
if default_other_timezone == original_timezone:
default_other_timezone = "Asia/Singapore"
def _set_timezone(other_timezone: Optional[str] = None) -> str:
if other_timezone is None:
other_timezone = default_other_timezone
pendulum.set_local_timezone(other_timezone)
assert pendulum.local_timezone() == other_timezone
return other_timezone
yield _set_timezone
# Restore the original timezone
pendulum.set_local_timezone(original_timezone)
assert pendulum.local_timezone() == original_timezone