From 94618f5f6661073d753cbbe6d67a85032c5af403 Mon Sep 17 00:00:00 2001 From: Dominique Lasserre Date: Sun, 26 Jan 2025 20:54:27 +0100 Subject: [PATCH] REST: Allow setting single config value * /v1/config/{path} supports setting single config value (post body). Lists are supported as well by using the index: - general/latitude (value: 55.55) - optimize/ev_available_charge_rates_percent/0 (value: 42) Whole tree can be overriden as well (no merge): - optimize/ev_available_charge_rates_percent (value: [42, 43, 44] * ConfigEOS: Add set_config_value, get_config_value --- docs/_generated/openapi.md | 64 ++++ openapi.json | 577 +++++++++++++++++------------ src/akkudoktoreos/config/config.py | 28 +- src/akkudoktoreos/core/pydantic.py | 64 ++++ src/akkudoktoreos/server/eos.py | 56 ++- tests/test_config.py | 162 ++++++++ 6 files changed, 709 insertions(+), 242 deletions(-) diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index dac73b9..00f2e7e 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -257,6 +257,70 @@ Returns: --- +## GET /v1/config/{path} + +**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_get_key_v1_config__path__get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_get_key_v1_config__path__get) + +Fastapi Config Get Key + +``` +Get the value of a nested key or index in the config model. + +Args: + path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0"). + +Returns: + value (Any): The value of the selected nested key. +``` + +**Parameters**: + +- `path` (path, required): The nested path to the configuration key (e.g., general/latitude). + +**Responses**: + +- **200**: Successful Response + +- **422**: Validation Error + +--- + +## PUT /v1/config/{path} + +**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_put_key_v1_config__path__put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_put_key_v1_config__path__put) + +Fastapi Config Put Key + +``` +Update a nested key or index in the config model. + +Args: + path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0"). + value (Any): The new value to assign to the key or index at path. + +Returns: + configuration (ConfigEOS): The current configuration after the update. +``` + +**Parameters**: + +- `path` (path, required): The nested path to the configuration key (e.g., general/latitude). + +**Request Body**: + +- `application/json`: { + "description": "The value to assign to the specified configuration path.", + "title": "Value" +} + +**Responses**: + +- **200**: Successful Response + +- **422**: Validation Error + +--- + ## PUT /v1/measurement/data **Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put) diff --git a/openapi.json b/openapi.json index bc419af..d930856 100644 --- a/openapi.json +++ b/openapi.json @@ -106,246 +106,6 @@ "title": "BaseBatteryParameters", "type": "object" }, - "GeneralSettings-Input": { - "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", - "properties": { - "data_cache_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "cache", - "description": "Sub-path for the EOS cache data directory.", - "title": "Data Cache Subpath" - }, - "data_folder_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS data directory.", - "examples": [ - null, - "/home/eos/data" - ], - "title": "Data Folder Path" - }, - "data_output_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "output", - "description": "Sub-path for the EOS output data directory.", - "title": "Data Output Subpath" - }, - "latitude": { - "anyOf": [ - { - "maximum": 90.0, - "minimum": -90.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 52.52, - "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", - "title": "Latitude" - }, - "longitude": { - "anyOf": [ - { - "maximum": 180.0, - "minimum": -180.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 13.405, - "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", - "title": "Longitude" - } - }, - "title": "GeneralSettings", - "type": "object" - }, - "GeneralSettings-Output": { - "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", - "properties": { - "config_file_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS configuration file.", - "readOnly": true, - "title": "Config File Path" - }, - "config_folder_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS configuration directory.", - "readOnly": true, - "title": "Config Folder Path" - }, - "data_cache_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Compute data_cache_path based on data_folder_path.", - "readOnly": true, - "title": "Data Cache Path" - }, - "data_cache_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "cache", - "description": "Sub-path for the EOS cache data directory.", - "title": "Data Cache Subpath" - }, - "data_folder_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS data directory.", - "examples": [ - null, - "/home/eos/data" - ], - "title": "Data Folder Path" - }, - "data_output_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Compute data_output_path based on data_folder_path.", - "readOnly": true, - "title": "Data Output Path" - }, - "data_output_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "output", - "description": "Sub-path for the EOS output data directory.", - "title": "Data Output Subpath" - }, - "latitude": { - "anyOf": [ - { - "maximum": 90.0, - "minimum": -90.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 52.52, - "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", - "title": "Latitude" - }, - "longitude": { - "anyOf": [ - { - "maximum": 180.0, - "minimum": -180.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 13.405, - "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", - "title": "Longitude" - }, - "timezone": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Compute timezone based on latitude and longitude.", - "readOnly": true, - "title": "Timezone" - } - }, - "required": [ - "timezone", - "data_output_path", - "data_cache_path", - "config_folder_path", - "config_file_path" - ], - "title": "GeneralSettings", - "type": "object" - }, "ConfigEOS": { "additionalProperties": false, "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 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 ```", @@ -877,6 +637,246 @@ "title": "ForecastResponse", "type": "object" }, + "GeneralSettings-Input": { + "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", + "properties": { + "data_cache_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "cache", + "description": "Sub-path for the EOS cache data directory.", + "title": "Data Cache Subpath" + }, + "data_folder_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS data directory.", + "examples": [ + null, + "/home/eos/data" + ], + "title": "Data Folder Path" + }, + "data_output_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "output", + "description": "Sub-path for the EOS output data directory.", + "title": "Data Output Subpath" + }, + "latitude": { + "anyOf": [ + { + "maximum": 90.0, + "minimum": -90.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 52.52, + "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "maximum": 180.0, + "minimum": -180.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 13.405, + "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", + "title": "Longitude" + } + }, + "title": "GeneralSettings", + "type": "object" + }, + "GeneralSettings-Output": { + "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", + "properties": { + "config_file_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS configuration file.", + "readOnly": true, + "title": "Config File Path" + }, + "config_folder_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS configuration directory.", + "readOnly": true, + "title": "Config Folder Path" + }, + "data_cache_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Compute data_cache_path based on data_folder_path.", + "readOnly": true, + "title": "Data Cache Path" + }, + "data_cache_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "cache", + "description": "Sub-path for the EOS cache data directory.", + "title": "Data Cache Subpath" + }, + "data_folder_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS data directory.", + "examples": [ + null, + "/home/eos/data" + ], + "title": "Data Folder Path" + }, + "data_output_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Compute data_output_path based on data_folder_path.", + "readOnly": true, + "title": "Data Output Path" + }, + "data_output_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "output", + "description": "Sub-path for the EOS output data directory.", + "title": "Data Output Subpath" + }, + "latitude": { + "anyOf": [ + { + "maximum": 90.0, + "minimum": -90.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 52.52, + "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "maximum": 180.0, + "minimum": -180.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 13.405, + "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", + "title": "Longitude" + }, + "timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Compute timezone based on latitude and longitude.", + "readOnly": true, + "title": "Timezone" + } + }, + "required": [ + "timezone", + "data_output_path", + "data_cache_path", + "config_folder_path", + "config_file_path" + ], + "title": "GeneralSettings", + "type": "object" + }, "GesamtlastRequest": { "properties": { "hours": { @@ -3166,6 +3166,103 @@ ] } }, + "/v1/config/{path}": { + "get": { + "description": "Get the value of a nested key or index in the config model.\n\nArgs:\n path (str): The nested path to the key (e.g., \"general/latitude\" or \"optimize/nested_list/0\").\n\nReturns:\n value (Any): The value of the selected nested key.", + "operationId": "fastapi_config_get_key_v1_config__path__get", + "parameters": [ + { + "description": "The nested path to the configuration key (e.g., general/latitude).", + "in": "path", + "name": "path", + "required": true, + "schema": { + "description": "The nested path to the configuration key (e.g., general/latitude).", + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Fastapi Config Get Key", + "tags": [ + "config" + ] + }, + "put": { + "description": "Update a nested key or index in the config model.\n\nArgs:\n path (str): The nested path to the key (e.g., \"general/latitude\" or \"optimize/nested_list/0\").\n value (Any): The new value to assign to the key or index at path.\n\nReturns:\n configuration (ConfigEOS): The current configuration after the update.", + "operationId": "fastapi_config_put_key_v1_config__path__put", + "parameters": [ + { + "description": "The nested path to the configuration key (e.g., general/latitude).", + "in": "path", + "name": "path", + "required": true, + "schema": { + "description": "The nested path to the configuration key (e.g., general/latitude).", + "title": "Path", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "description": "The value to assign to the specified configuration path.", + "title": "Value" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigEOS" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Fastapi Config Put Key", + "tags": [ + "config" + ] + } + }, "/v1/measurement/data": { "put": { "description": "Merge the measurement data given as datetime data into EOS measurements.", diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index 8e658aa..91f6126 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -30,7 +30,7 @@ from akkudoktoreos.core.coreabc import SingletonMixin from akkudoktoreos.core.decorators import classproperty from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logsettings import LoggingCommonSettings -from akkudoktoreos.core.pydantic import merge_models +from akkudoktoreos.core.pydantic import access_nested_value, merge_models from akkudoktoreos.devices.settings import DevicesCommonSettings from akkudoktoreos.measurement.measurement import MeasurementCommonSettings from akkudoktoreos.optimization.optimization import OptimizationCommonSettings @@ -379,6 +379,32 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): """ self._setup() + def set_config_value(self, path: str, value: Any) -> None: + """Set a configuration value based on the provided path. + + Supports string paths (with '/' separators) or sequence paths (list/tuple). + Trims leading and trailing '/' from string paths. + + Args: + path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0). + value (Any]): The value to set. + """ + access_nested_value(self, path, True, value) + + def get_config_value(self, path: str) -> Any: + """Get a configuration value based on the provided path. + + Supports string paths (with '/' separators) or sequence paths (list/tuple). + Trims leading and trailing '/' from string paths. + + Args: + path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0). + + Returns: + Any: The retrieved value. + """ + return access_nested_value(self, path, False) + def _create_initial_config_file(self) -> None: if self.general.config_file_path and not self.general.config_file_path.exists(): self.general.config_file_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/akkudoktoreos/core/pydantic.py b/src/akkudoktoreos/core/pydantic.py index b06e0e6..adf884a 100644 --- a/src/akkudoktoreos/core/pydantic.py +++ b/src/akkudoktoreos/core/pydantic.py @@ -51,6 +51,70 @@ def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, An return merged_dict +def access_nested_value( + model: BaseModel, path: str, setter: bool, value: Optional[Any] = None +) -> Any: + """Get or set a nested model value based on the provided path. + + Supports string paths (with '/' separators) or sequence paths (list/tuple). + Trims leading and trailing '/' from string paths. + + Args: + model (BaseModel): The model object for partial assignment. + path (str): The path to the model key (e.g., "key1/key2/key3" or key1/key2/0). + setter (bool): True to set value at path, False to return value at path. + value (Optional[Any]): The value to set. + + Returns: + Any: The retrieved value if acting as a getter, or None if setting a value. + """ + path_elements = path.strip("/").split("/") + + cfg: Any = model + parent: BaseModel = model + model_key: str = "" + + for i, key in enumerate(path_elements): + is_final_key = i == len(path_elements) - 1 + + if isinstance(cfg, list): + try: + idx = int(key) + if is_final_key: + if not setter: # Getter + return cfg[idx] + else: # Setter + new_list = list(cfg) + new_list[idx] = value + # Trigger validation + setattr(parent, model_key, new_list) + else: + cfg = cfg[idx] + except ValidationError as e: + raise ValueError(f"Error updating model: {e}") from e + except (ValueError, IndexError) as e: + raise IndexError(f"Invalid list index at {path}: {key}") from e + + elif isinstance(cfg, BaseModel): + parent = cfg + model_key = key + if is_final_key: + if not setter: # Getter + return getattr(cfg, key) + else: # Setter + try: + # Verification also if nested value is provided opposed to just setattr + # Will merge partial assignment + cfg = cfg.__pydantic_validator__.validate_assignment(cfg, key, value) + except Exception as e: + raise ValueError(f"Error updating model: {e}") from e + else: + cfg = getattr(cfg, key) + + else: + raise KeyError(f"Key '{key}' not found in model.") + + class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]): """Custom type adapter for Pendulum DateTime fields.""" diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 73fb2d6..f430b2d 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -10,7 +10,9 @@ from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union import httpx import uvicorn -from fastapi import FastAPI, Query, Request +from fastapi import Body, FastAPI +from fastapi import Path as FastapiPath +from fastapi import Query, Request from fastapi.exceptions import HTTPException from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response @@ -302,6 +304,58 @@ def fastapi_config_put(settings: SettingsEOS) -> ConfigEOS: return config_eos +@app.put("/v1/config/{path:path}", tags=["config"]) +def fastapi_config_put_key( + path: str = FastapiPath( + ..., description="The nested path to the configuration key (e.g., general/latitude)." + ), + value: Any = Body(..., description="The value to assign to the specified configuration path."), +) -> ConfigEOS: + """Update a nested key or index in the config model. + + Args: + path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0"). + value (Any): The new value to assign to the key or index at path. + + Returns: + configuration (ConfigEOS): The current configuration after the update. + """ + try: + config_eos.set_config_value(path, value) + except IndexError as e: + raise HTTPException(status_code=400, detail=str(e)) + except KeyError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + return config_eos + + +@app.get("/v1/config/{path:path}", tags=["config"]) +def fastapi_config_get_key( + path: str = FastapiPath( + ..., description="The nested path to the configuration key (e.g., general/latitude)." + ), +) -> Response: + """Get the value of a nested key or index in the config model. + + Args: + path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0"). + + Returns: + value (Any): The value of the selected nested key. + """ + try: + return config_eos.get_config_value(path) + except IndexError as e: + raise HTTPException(status_code=400, detail=str(e)) + except KeyError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + @app.get("/v1/measurement/keys", tags=["measurement"]) def fastapi_measurement_keys_get() -> list[str]: """Get a list of available measurement keys.""" diff --git a/tests/test_config.py b/tests/test_config.py index a0f09fe..bc792d9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -212,3 +212,165 @@ def test_config_common_settings_timezone_none_when_coordinates_missing(): assert config_no_latitude.timezone is None assert config_no_longitude.timezone is None assert config_no_coords.timezone is None + + +# Test partial assignments and possible side effects +@pytest.mark.parametrize( + "path, value, expected, exception", + [ + # Correct value assignment + ( + "general/latitude", + 42.0, + [("general.latitude", 42.0), ("general.longitude", 13.405)], + None, + ), + # Correct value assignment (trailing /) + ( + "general/latitude/", + 41, + [("general.latitude", 41.0), ("general.longitude", 13.405)], + None, + ), + # Correct value assignment (cast) + ( + "general/latitude", + "43.0", + [("general.latitude", 43.0), ("general.longitude", 13.405)], + None, + ), + # Invalid value assignment (constraint) + ( + "general/latitude", + 91.0, + [("general.latitude", 52.52), ("general.longitude", 13.405)], + ValueError, + ), + # Invalid value assignment (type) + ( + "general/latitude", + "test", + [("general.latitude", 52.52), ("general.longitude", 13.405)], + ValueError, + ), + # Invalid path + ( + "general/latitude/test", + "", + [("general.latitude", 52.52), ("general.longitude", 13.405)], + KeyError, + ), + # Correct value nested assignment + ( + "general", + {"latitude": 22}, + [("general.latitude", 22.0), ("general.longitude", 13.405)], + None, + ), + # Invalid value nested assignment + ( + "general", + {"latitude": "test"}, + [("general.latitude", 52.52), ("general.longitude", 13.405)], + ValueError, + ), + # Correct value for list + ( + "optimization/ev_available_charge_rates_percent/0", + 0.1, + [ + ( + "optimization.ev_available_charge_rates_percent", + [0.1, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0], + ) + ], + None, + ), + # Invalid value for list + ( + "optimization/ev_available_charge_rates_percent/0", + "invalid", + [ + ( + "optimization.ev_available_charge_rates_percent", + [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0], + ) + ], + ValueError, + ), + # Invalid index (out of bound) + ( + "optimization/ev_available_charge_rates_percent/10", + 0, + [ + ( + "optimization.ev_available_charge_rates_percent", + [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0], + ) + ], + IndexError, + ), + # Invalid index (no number) + ( + "optimization/ev_available_charge_rates_percent/test", + 0, + [ + ( + "optimization.ev_available_charge_rates_percent", + [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0], + ) + ], + IndexError, + ), + # Unset value (set None) + ( + "optimization/ev_available_charge_rates_percent", + None, + [ + ( + "optimization.ev_available_charge_rates_percent", + None, + ) + ], + None, + ), + ], +) +def test_set_nested_key(path, value, expected, exception, config_eos): + if not exception: + config_eos.set_config_value(path, value) + for expected_path, expected_value in expected: + assert eval(f"config_eos.{expected_path}") == expected_value + else: + with pytest.raises(exception): + config_eos.set_config_value(path, value) + for expected_path, expected_value in expected: + assert eval(f"config_eos.{expected_path}") == expected_value + + +@pytest.mark.parametrize( + "path, expected_value, exception", + [ + ("general/latitude", 52.52, None), + ("general/latitude/", 52.52, None), + ("general/latitude/test", None, KeyError), + ( + "optimization/ev_available_charge_rates_percent/1", + 0.375, + None, + ), + ("optimization/ev_available_charge_rates_percent/10", 0, IndexError), + ("optimization/ev_available_charge_rates_percent/test", 0, IndexError), + ( + "optimization/ev_available_charge_rates_percent", + [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0], + None, + ), + ], +) +def test_get_nested_key(path, expected_value, exception, config_eos): + if not exception: + assert config_eos.get_config_value(path) == expected_value + else: + with pytest.raises(exception): + config_eos.get_config_value(path)