Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c2fd74c

Browse files
authoredDec 30, 2024
Reasonable defaults, isolate tests, EOS_LOGGING_LEVEL, EOS_CONFIG_DIR
* Add EOS_CONFIG_DIR to set config dir (relative path to EOS_DIR or absolute path). - config_folder_path read-only - config_file_path read-only * Default values to support app start with empty config: - latitude/longitude (Berlin) - optimization_ev_available_charge_rates_percent (null, so model default value is used) - Enable Akkudoktor electricity price forecast (docker-compose). * Fix some endpoints (empty data, remove unused params, fix types). * cacheutil: Use cache dir. Closes #240 * Support EOS_LOGGING_LEVEL environment variable to set log level. * tests: All tests use separate temporary config - Add pytest switch --check-config-side-effect to check user config file existence after each test. Will also fail if user config existed before test execution (but will only check after the test has run). Enable flag in github workflow. - Globally mock platformdirs in config module. Now no longer required to patch individually. Function calls to config instance (e.g. merge_settings_from_dict) were unaffected previously. * Set Berlin as default location (default config/docker-compose).
1 parent e5af742 commit c2fd74c

29 files changed

+373
-375
lines changed
 

‎.github/workflows/pytest.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
- name: Run Pytest
2727
run: |
2828
pip install -e .
29-
python -m pytest --full-run -vs --cov src --cov-report term-missing
29+
python -m pytest --full-run --check-config-side-effect -vs --cov src --cov-report term-missing
3030
3131
- name: Upload test artifacts
3232
uses: actions/upload-artifact@v4

‎Dockerfile

+5-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ ENV MPLCONFIGDIR="/tmp/mplconfigdir"
99
ENV EOS_DIR="/opt/eos"
1010
ENV EOS_CACHE_DIR="${EOS_DIR}/cache"
1111
ENV EOS_OUTPUT_DIR="${EOS_DIR}/output"
12+
ENV EOS_CONFIG_DIR="${EOS_DIR}/config"
1213

1314
WORKDIR ${EOS_DIR}
1415

@@ -18,7 +19,9 @@ RUN adduser --system --group --no-create-home eos \
1819
&& mkdir -p "${EOS_CACHE_DIR}" \
1920
&& chown eos "${EOS_CACHE_DIR}" \
2021
&& mkdir -p "${EOS_OUTPUT_DIR}" \
21-
&& chown eos "${EOS_OUTPUT_DIR}"
22+
&& chown eos "${EOS_OUTPUT_DIR}" \
23+
&& mkdir -p "${EOS_CONFIG_DIR}" \
24+
&& chown eos "${EOS_CONFIG_DIR}"
2225

2326
COPY requirements.txt .
2427

@@ -34,4 +37,4 @@ EXPOSE 8503
3437

3538
CMD ["python", "-m", "akkudoktoreos.server.fastapi_server"]
3639

37-
VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}"]
40+
VOLUME ["${MPLCONFIGDIR}", "${EOS_CACHE_DIR}", "${EOS_OUTPUT_DIR}", "${EOS_CONFIG_DIR}"]

‎docker-compose.yaml

+6-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ services:
1111
dockerfile: "Dockerfile"
1212
args:
1313
PYTHON_VERSION: "${PYTHON_VERSION}"
14-
volumes:
15-
- ./src/akkudoktoreos/default.config.json:/opt/eos/EOS.config.json:ro
14+
environment:
15+
- EOS_CONFIG_DIR=config
16+
- latitude=52.2
17+
- longitude=13.4
18+
- elecprice_provider=ElecPriceAkkudoktor
19+
- elecprice_charges=0.21
1620
ports:
1721
- "${EOS_PORT}:${EOS_PORT}"

‎docs/akkudoktoreos/openapi.json

+33-57
Original file line numberDiff line numberDiff line change
@@ -778,26 +778,8 @@
778778
},
779779
"/pvforecast": {
780780
"get": {
781-
"summary": "Fastapi Pvprognose",
782-
"operationId": "fastapi_pvprognose_pvforecast_get",
783-
"parameters": [
784-
{
785-
"name": "ac_power_measurement",
786-
"in": "query",
787-
"required": false,
788-
"schema": {
789-
"anyOf": [
790-
{
791-
"type": "number"
792-
},
793-
{
794-
"type": "null"
795-
}
796-
],
797-
"title": "Ac Power Measurement"
798-
}
799-
}
800-
],
781+
"summary": "Fastapi Pvforecast",
782+
"operationId": "fastapi_pvforecast_pvforecast_get",
801783
"responses": {
802784
"200": {
803785
"description": "Successful Response",
@@ -808,16 +790,6 @@
808790
}
809791
}
810792
}
811-
},
812-
"422": {
813-
"description": "Validation Error",
814-
"content": {
815-
"application/json": {
816-
"schema": {
817-
"$ref": "#/components/schemas/HTTPValidationError"
818-
}
819-
}
820-
}
821793
}
822794
}
823795
}
@@ -2987,32 +2959,6 @@
29872959
"description": "Sub-path for the EOS cache data directory.",
29882960
"default": "cache"
29892961
},
2990-
"config_folder_path": {
2991-
"anyOf": [
2992-
{
2993-
"type": "string",
2994-
"format": "path"
2995-
},
2996-
{
2997-
"type": "null"
2998-
}
2999-
],
3000-
"title": "Config Folder Path",
3001-
"description": "Path to EOS configuration directory."
3002-
},
3003-
"config_file_path": {
3004-
"anyOf": [
3005-
{
3006-
"type": "string",
3007-
"format": "path"
3008-
},
3009-
{
3010-
"type": "null"
3011-
}
3012-
],
3013-
"title": "Config File Path",
3014-
"description": "Path to EOS configuration file."
3015-
},
30162962
"pvforecast_planes": {
30172963
"items": {
30182964
"type": "string"
@@ -3100,6 +3046,34 @@
31003046
"description": "Compute data_cache_path based on data_folder_path.",
31013047
"readOnly": true
31023048
},
3049+
"config_folder_path": {
3050+
"anyOf": [
3051+
{
3052+
"type": "string",
3053+
"format": "path"
3054+
},
3055+
{
3056+
"type": "null"
3057+
}
3058+
],
3059+
"title": "Config Folder Path",
3060+
"description": "Path to EOS configuration directory.",
3061+
"readOnly": true
3062+
},
3063+
"config_file_path": {
3064+
"anyOf": [
3065+
{
3066+
"type": "string",
3067+
"format": "path"
3068+
},
3069+
{
3070+
"type": "null"
3071+
}
3072+
],
3073+
"title": "Config File Path",
3074+
"description": "Path to EOS configuration file.",
3075+
"readOnly": true
3076+
},
31033077
"config_default_file_path": {
31043078
"type": "string",
31053079
"format": "path",
@@ -3128,11 +3102,13 @@
31283102
"timezone",
31293103
"data_output_path",
31303104
"data_cache_path",
3105+
"config_folder_path",
3106+
"config_file_path",
31313107
"config_default_file_path",
31323108
"config_keys"
31333109
],
31343110
"title": "ConfigEOS",
3135-
"description": "Singleton configuration handler for the EOS application.\n\nConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic\ninitialization.\n\n`ConfigEOS` ensures that only one instance of the class is created throughout the application,\nallowing consistent access to EOS configuration settings. This singleton instance loads\nconfiguration data from a predefined set of directories or creates a default configuration if\nnone is found.\n\nInitialization Process:\n - Upon instantiation, the singleton instance attempts to load a configuration file in this order:\n 1. The directory specified by the `EOS_DIR` environment variable.\n 2. A platform specific default directory for EOS.\n 3. The current working directory.\n - The first available configuration file found in these directories is loaded.\n - If no configuration file is found, a default configuration file is created in the platform\n specific default directory, and default settings are loaded into it.\n\nAttributes from the loaded configuration are accessible directly as instance attributes of\n`ConfigEOS`, providing a centralized, shared configuration object for EOS.\n\nSingleton Behavior:\n - This class uses the `SingletonMixin` to ensure that all requests for `ConfigEOS` return\n the same instance, which contains the most up-to-date configuration. Modifying the configuration\n in one part of the application reflects across all references to this class.\n\nAttributes:\n _settings (ClassVar[SettingsEOS]): Holds application-wide settings.\n _file_settings (ClassVar[SettingsEOS]): Stores configuration loaded from file.\n config_folder_path (Optional[Path]): Path to the configuration directory.\n config_file_path (Optional[Path]): Path to the configuration file.\n\nRaises:\n FileNotFoundError: If no configuration file is found, and creating a default configuration fails.\n\nExample:\n To initialize and access configuration attributes (only one instance is created):\n ```python\n config_eos = ConfigEOS() # Always returns the same instance\n print(config_eos.prediction_hours) # Access a setting from the loaded configuration\n ```"
3111+
"description": "Singleton configuration handler for the EOS application.\n\nConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic\ninitialization.\n\n`ConfigEOS` ensures that only one instance of the class is created throughout the application,\nallowing consistent access to EOS configuration settings. This singleton instance loads\nconfiguration data from a predefined set of directories or creates a default configuration if\nnone is found.\n\nInitialization Process:\n - Upon instantiation, the singleton instance attempts to load a configuration file in this order:\n 1. The directory specified by the `EOS_CONFIG_DIR` environment variable\n 2. The directory specified by the `EOS_DIR` environment variable.\n 3. A platform specific default directory for EOS.\n 4. The current working directory.\n - The first available configuration file found in these directories is loaded.\n - If no configuration file is found, a default configuration file is created in the platform\n specific default directory, and default settings are loaded into it.\n\nAttributes from the loaded configuration are accessible directly as instance attributes of\n`ConfigEOS`, providing a centralized, shared configuration object for EOS.\n\nSingleton Behavior:\n - This class uses the `SingletonMixin` to ensure that all requests for `ConfigEOS` return\n the same instance, which contains the most up-to-date configuration. Modifying the configuration\n in one part of the application reflects across all references to this class.\n\nAttributes:\n _settings (ClassVar[SettingsEOS]): Holds application-wide settings.\n _file_settings (ClassVar[SettingsEOS]): Stores configuration loaded from file.\n config_folder_path (Optional[Path]): Path to the configuration directory.\n config_file_path (Optional[Path]): Path to the configuration file.\n\nRaises:\n FileNotFoundError: If no configuration file is found, and creating a default configuration fails.\n\nExample:\n To initialize and access configuration attributes (only one instance is created):\n ```python\n config_eos = ConfigEOS() # Always returns the same instance\n print(config_eos.prediction_hours) # Access a setting from the loaded configuration\n ```"
31363112
},
31373113
"ElectricVehicleParameters": {
31383114
"properties": {

‎src/akkudoktoreos/config/config.py

+57-58
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from pathlib import Path
1515
from typing import Any, ClassVar, List, Optional
1616

17-
import platformdirs
17+
from platformdirs import user_config_dir, user_data_dir
1818
from pydantic import Field, ValidationError, computed_field
1919

2020
# settings
@@ -40,6 +40,24 @@
4040
logger = get_logger(__name__)
4141

4242

43+
def get_absolute_path(
44+
basepath: Optional[Path | str], subpath: Optional[Path | str]
45+
) -> Optional[Path]:
46+
"""Get path based on base path."""
47+
if isinstance(basepath, str):
48+
basepath = Path(basepath)
49+
if subpath is None:
50+
return basepath
51+
52+
if isinstance(subpath, str):
53+
subpath = Path(subpath)
54+
if subpath.is_absolute():
55+
return subpath
56+
if basepath is not None:
57+
return basepath.joinpath(subpath)
58+
return None
59+
60+
4361
class ConfigCommonSettings(SettingsBaseModel):
4462
"""Settings for common configuration."""
4563

@@ -60,22 +78,14 @@ class ConfigCommonSettings(SettingsBaseModel):
6078
@property
6179
def data_output_path(self) -> Optional[Path]:
6280
"""Compute data_output_path based on data_folder_path."""
63-
if self.data_output_subpath is None:
64-
return self.data_folder_path
65-
if self.data_folder_path and self.data_output_subpath:
66-
return self.data_folder_path.joinpath(self.data_output_subpath)
67-
return None
81+
return get_absolute_path(self.data_folder_path, self.data_output_subpath)
6882

6983
# Computed fields
7084
@computed_field # type: ignore[prop-decorator]
7185
@property
7286
def data_cache_path(self) -> Optional[Path]:
7387
"""Compute data_cache_path based on data_folder_path."""
74-
if self.data_cache_subpath is None:
75-
return self.data_folder_path
76-
if self.data_folder_path and self.data_cache_subpath:
77-
return self.data_folder_path.joinpath(self.data_cache_subpath)
78-
return None
88+
return get_absolute_path(self.data_folder_path, self.data_cache_subpath)
7989

8090

8191
class SettingsEOS(
@@ -114,9 +124,10 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
114124
115125
Initialization Process:
116126
- Upon instantiation, the singleton instance attempts to load a configuration file in this order:
117-
1. The directory specified by the `EOS_DIR` environment variable.
118-
2. A platform specific default directory for EOS.
119-
3. The current working directory.
127+
1. The directory specified by the `EOS_CONFIG_DIR` environment variable
128+
2. The directory specified by the `EOS_DIR` environment variable.
129+
3. A platform specific default directory for EOS.
130+
4. The current working directory.
120131
- The first available configuration file found in these directories is loaded.
121132
- If no configuration file is found, a default configuration file is created in the platform
122133
specific default directory, and default settings are loaded into it.
@@ -150,21 +161,29 @@ class ConfigEOS(SingletonMixin, SettingsEOS):
150161
APP_NAME: ClassVar[str] = "net.akkudoktor.eos" # reverse order
151162
APP_AUTHOR: ClassVar[str] = "akkudoktor"
152163
EOS_DIR: ClassVar[str] = "EOS_DIR"
164+
EOS_CONFIG_DIR: ClassVar[str] = "EOS_CONFIG_DIR"
153165
ENCODING: ClassVar[str] = "UTF-8"
154166
CONFIG_FILE_NAME: ClassVar[str] = "EOS.config.json"
155167

156168
_settings: ClassVar[Optional[SettingsEOS]] = None
157169
_file_settings: ClassVar[Optional[SettingsEOS]] = None
158170

159-
config_folder_path: Optional[Path] = Field(
160-
None, description="Path to EOS configuration directory."
161-
)
162-
163-
config_file_path: Optional[Path] = Field(
164-
default=None, description="Path to EOS configuration file."
165-
)
171+
_config_folder_path: Optional[Path] = None
172+
_config_file_path: Optional[Path] = None
166173

167174
# Computed fields
175+
@computed_field # type: ignore[prop-decorator]
176+
@property
177+
def config_folder_path(self) -> Optional[Path]:
178+
"""Path to EOS configuration directory."""
179+
return self._config_folder_path
180+
181+
@computed_field # type: ignore[prop-decorator]
182+
@property
183+
def config_file_path(self) -> Optional[Path]:
184+
"""Path to EOS configuration file."""
185+
return self._config_file_path
186+
168187
@computed_field # type: ignore[prop-decorator]
169188
@property
170189
def config_default_file_path(self) -> Path:
@@ -297,7 +316,7 @@ def _update_data_folder_path(self) -> None:
297316
pass
298317
# From platform specific default path
299318
try:
300-
data_dir = platformdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR)
319+
data_dir = Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR))
301320
if data_dir is not None:
302321
data_dir.mkdir(parents=True, exist_ok=True)
303322
self.data_folder_path = data_dir
@@ -308,62 +327,42 @@ def _update_data_folder_path(self) -> None:
308327
data_dir = Path.cwd()
309328
self.data_folder_path = data_dir
310329

311-
def _config_folder_path(self) -> Optional[Path]:
312-
"""Finds the first directory containing a valid configuration file.
330+
def _get_config_file_path(self) -> tuple[Path, bool]:
331+
"""Finds the a valid configuration file or returns the desired path for a new config file.
313332
314333
Returns:
315-
Path: The path to the configuration directory, or None if not found.
334+
tuple[Path, bool]: The path to the configuration directory and if there is already a config file there
316335
"""
317336
config_dirs = []
318-
config_dir = None
319-
env_dir = os.getenv(self.EOS_DIR)
320-
logger.debug(f"Envionment '{self.EOS_DIR}': '{env_dir}'")
337+
env_base_dir = os.getenv(self.EOS_DIR)
338+
env_config_dir = os.getenv(self.EOS_CONFIG_DIR)
339+
env_dir = get_absolute_path(env_base_dir, env_config_dir)
340+
logger.debug(f"Envionment config dir: '{env_dir}'")
321341
if env_dir is not None:
322-
config_dirs.append(Path(env_dir).resolve())
323-
config_dirs.append(Path(platformdirs.user_config_dir(self.APP_NAME)))
342+
config_dirs.append(env_dir.resolve())
343+
config_dirs.append(Path(user_config_dir(self.APP_NAME)))
324344
config_dirs.append(Path.cwd())
325345
for cdir in config_dirs:
326346
cfile = cdir.joinpath(self.CONFIG_FILE_NAME)
327347
if cfile.exists():
328348
logger.debug(f"Found config file: '{cfile}'")
329-
config_dir = cdir
330-
break
331-
return config_dir
332-
333-
def _config_file_path(self) -> Path:
334-
"""Finds the path to the configuration file.
335-
336-
Returns:
337-
Path: The path to the configuration file. May not exist.
338-
"""
339-
config_file = None
340-
config_dir = self._config_folder_path()
341-
if config_dir is None:
342-
# There is currently no configuration file - create it in default path
343-
env_dir = os.getenv(self.EOS_DIR)
344-
if env_dir is not None:
345-
config_dir = Path(env_dir).resolve()
346-
else:
347-
config_dir = Path(platformdirs.user_config_dir(self.APP_NAME))
348-
config_file = config_dir.joinpath(self.CONFIG_FILE_NAME)
349-
else:
350-
config_file = config_dir.joinpath(self.CONFIG_FILE_NAME)
351-
return config_file
349+
return cfile, True
350+
return config_dirs[0].joinpath(self.CONFIG_FILE_NAME), False
352351

353352
def from_config_file(self) -> None:
354353
"""Loads the configuration file settings for EOS.
355354
356355
Raises:
357356
ValueError: If the configuration file is invalid or incomplete.
358357
"""
359-
config_file = self._config_file_path()
358+
config_file, exists = self._get_config_file_path()
360359
config_dir = config_file.parent
361-
if not config_file.exists():
360+
if not exists:
362361
config_dir.mkdir(parents=True, exist_ok=True)
363362
try:
364363
shutil.copy2(self.config_default_file_path, config_file)
365364
except Exception as exc:
366-
logger.warning(f"Could not copy default config: {exc}. Using default copy...")
365+
logger.warning(f"Could not copy default config: {exc}. Using default config...")
367366
config_file = self.config_default_file_path
368367
config_dir = config_file.parent
369368

@@ -376,8 +375,8 @@ def from_config_file(self) -> None:
376375

377376
self.update()
378377
# Everthing worked, remember the values
379-
self.config_folder_path = config_dir
380-
self.config_file_path = config_file
378+
self._config_folder_path = config_dir
379+
self._config_file_path = config_file
381380

382381
def to_config_file(self) -> None:
383382
"""Saves the current configuration to the configuration file.

0 commit comments

Comments
 (0)
Please sign in to comment.