diff --git a/docs/_generated/config.md b/docs/_generated/config.md index 07c4d73..e265e82 100644 --- a/docs/_generated/config.md +++ b/docs/_generated/config.md @@ -880,11 +880,11 @@ Validators: | Name | Environment Variable | Type | Read-Only | Default | Description | | ---- | -------------------- | ---- | --------- | ------- | ----------- | -| host | `EOS_SERVER__HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `0.0.0.0` | EOS server IP address. | +| host | `EOS_SERVER__HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `127.0.0.1` | EOS server IP address. | | port | `EOS_SERVER__PORT` | `Optional[int]` | `rw` | `8503` | EOS server IP port number. | | verbose | `EOS_SERVER__VERBOSE` | `Optional[bool]` | `rw` | `False` | Enable debug output | | startup_eosdash | `EOS_SERVER__STARTUP_EOSDASH` | `Optional[bool]` | `rw` | `True` | EOS server to start EOSdash server. | -| eosdash_host | `EOS_SERVER__EOSDASH_HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `0.0.0.0` | EOSdash server IP address. | +| eosdash_host | `EOS_SERVER__EOSDASH_HOST` | `Optional[pydantic.networks.IPvAnyAddress]` | `rw` | `127.0.0.1` | EOSdash server IP address. | | eosdash_port | `EOS_SERVER__EOSDASH_PORT` | `Optional[int]` | `rw` | `8504` | EOSdash server IP port number. | ::: @@ -895,11 +895,11 @@ Validators: { "server": { - "host": "0.0.0.0", + "host": "127.0.0.1", "port": 8503, "verbose": false, "startup_eosdash": true, - "eosdash_host": "0.0.0.0", + "eosdash_host": "127.0.0.1", "eosdash_port": 8504 } } @@ -1054,11 +1054,11 @@ Validators: "provider_settings": null }, "server": { - "host": "0.0.0.0", + "host": "127.0.0.1", "port": 8503, "verbose": false, "startup_eosdash": true, - "eosdash_host": "0.0.0.0", + "eosdash_host": "127.0.0.1", "eosdash_port": 8504 }, "utils": {} diff --git a/openapi.json b/openapi.json index 9e8d1e6..c08951b 100644 --- a/openapi.json +++ b/openapi.json @@ -220,9 +220,9 @@ "server": { "$ref": "#/components/schemas/ServerCommonSettings", "default": { - "eosdash_host": "0.0.0.0", + "eosdash_host": "127.0.0.1", "eosdash_port": 8504, - "host": "0.0.0.0", + "host": "127.0.0.1", "port": 8503, "startup_eosdash": true, "verbose": false @@ -2279,7 +2279,7 @@ "type": "null" } ], - "default": "0.0.0.0", + "default": "127.0.0.1", "description": "EOSdash server IP address.", "title": "Eosdash Host" }, @@ -2306,7 +2306,7 @@ "type": "null" } ], - "default": "0.0.0.0", + "default": "127.0.0.1", "description": "EOS server IP address.", "title": "Host" }, diff --git a/pyproject.toml b/pyproject.toml index 69195e7..da426e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,12 +43,18 @@ profile = "black" [tool.ruff] line-length = 100 +exclude = [ + "tests", + "scripts", +] +output-format = "full" [tool.ruff.lint] select = [ "F", # Enable all `Pyflakes` rules. "D", # Enable all `pydocstyle` rules, limiting to those that adhere to the # Google convention via `convention = "google"`, below. + "S", # Enable all `flake8-bandit` rules. ] ignore = [ # Prevent errors due to ruff false positives diff --git a/scripts/generate_config_md.py b/scripts/generate_config_md.py index 27fd760..ff599eb 100755 --- a/scripts/generate_config_md.py +++ b/scripts/generate_config_md.py @@ -298,7 +298,7 @@ def main(): try: config_md = generate_config_md(config_eos) if os.name == "nt": - config_md = config_md.replace("127.0.0.1", "0.0.0.0").replace("\\\\", "/") + config_md = config_md.replace("\\\\", "/") if args.output_file: # Write to file with open(args.output_file, "w", encoding="utf-8", newline="\n") as f: diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index 25c15d5..9aaec58 100755 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -58,8 +58,6 @@ def main(): try: openapi_spec = generate_openapi() openapi_spec_str = json.dumps(openapi_spec, indent=2) - if os.name == "nt": - openapi_spec_str = openapi_spec_str.replace("127.0.0.1", "0.0.0.0") if args.output_file: # Write to file with open(args.output_file, "w", encoding="utf-8", newline="\n") as f: diff --git a/scripts/generate_openapi_md.py b/scripts/generate_openapi_md.py index 418bd72..ffa74eb 100755 --- a/scripts/generate_openapi_md.py +++ b/scripts/generate_openapi_md.py @@ -286,7 +286,7 @@ def main(): try: openapi_md = generate_openapi_md() if os.name == "nt": - openapi_md = openapi_md.replace("127.0.0.1", "0.0.0.0") + openapi_md = openapi_md.replace("127.0.0.1", "127.0.0.1") if args.output_file: # Write to file with open(args.output_file, "w", encoding="utf-8", newline="\n") as f: diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index d9f1d9d..e9b5a3e 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -376,6 +376,15 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): def _setup(self, *args: Any, **kwargs: Any) -> None: """Re-initialize global settings.""" + # Check for config file content/ version type + config_file, exists = self._get_config_file_path() + if exists: + with config_file.open("r", encoding="utf-8", newline=None) as f_config: + config_txt = f_config.read() + if '"directories": {' in config_txt or '"server_eos_host": ' in config_txt: + error_msg = f"Configuration file '{config_file}' is outdated. Please remove or update manually." + logger.error(error_msg) + raise ValueError(error_msg) # Assure settings base knows EOS configuration SettingsBaseModel.config = self # (Re-)load settings @@ -394,7 +403,9 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): ValueError: If the `settings` is not a `SettingsEOS` instance. """ if not isinstance(settings, SettingsEOS): - raise ValueError(f"Settings must be an instance of SettingsEOS: '{settings}'.") + error_msg = f"Settings must be an instance of SettingsEOS: '{settings}'." + logger.error(error_msg) + raise ValueError(error_msg) self.merge_settings_from_dict(settings.model_dump(exclude_none=True, exclude_unset=True)) @@ -471,10 +482,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): @classmethod def _get_config_file_path(cls) -> tuple[Path, bool]: - """Finds the a valid configuration file or returns the desired path for a new config file. + """Find a valid configuration file or return the desired path for a new config file. Returns: - tuple[Path, bool]: The path to the configuration directory and if there is already a config file there + tuple[Path, bool]: The path to the configuration file and if there is already a config file there """ config_dirs = [] env_base_dir = os.getenv(cls.EOS_DIR) diff --git a/src/akkudoktoreos/core/cache.py b/src/akkudoktoreos/core/cache.py index 02b1260..d4a1da6 100644 --- a/src/akkudoktoreos/core/cache.py +++ b/src/akkudoktoreos/core/cache.py @@ -956,7 +956,7 @@ def cache_in_file( logger.debug("Used cache file for function: " + func.__name__) cache_file.seek(0) if "b" in mode: - result = pickle.load(cache_file) + result = pickle.load(cache_file) # noqa: S301 else: result = cache_file.read() except Exception as e: diff --git a/src/akkudoktoreos/core/decorators.py b/src/akkudoktoreos/core/decorators.py index 55120d8..ee83ed3 100644 --- a/src/akkudoktoreos/core/decorators.py +++ b/src/akkudoktoreos/core/decorators.py @@ -34,7 +34,7 @@ class classproperty: argument and returns a value. Raises: - AssertionError: If `fget` is not defined when `__get__` is called. + RuntimeError: If `fget` is not defined when `__get__` is called. """ def __init__(self, fget: Callable[[Any], Any]) -> None: @@ -43,5 +43,6 @@ class classproperty: def __get__(self, _: Any, owner_cls: Optional[type[Any]] = None) -> Any: if owner_cls is None: return self - assert self.fget is not None + if self.fget is None: + raise RuntimeError("'fget' not defined when `__get__` is called") return self.fget(owner_cls) diff --git a/src/akkudoktoreos/core/ems.py b/src/akkudoktoreos/core/ems.py index d989792..c366c12 100644 --- a/src/akkudoktoreos/core/ems.py +++ b/src/akkudoktoreos/core/ems.py @@ -393,7 +393,8 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas # Fetch objects battery = self.battery - assert battery # to please mypy + if battery is None: + raise ValueError(f"battery not set: {battery}") ev = self.ev home_appliance = self.home_appliance inverter = self.inverter diff --git a/src/akkudoktoreos/core/pydantic.py b/src/akkudoktoreos/core/pydantic.py index 9f5e126..933c8d6 100644 --- a/src/akkudoktoreos/core/pydantic.py +++ b/src/akkudoktoreos/core/pydantic.py @@ -450,8 +450,8 @@ class PydanticBaseModel(BaseModel, PydanticModelNestedValueMixin): if expected_type is pendulum.DateTime or expected_type is AwareDatetime: try: value = to_datetime(value) - except: - pass + except Exception as e: + raise ValueError(f"Cannot convert {value!r} to datetime: {e}") return value # Override Pydantic’s serialization for all DateTime fields diff --git a/src/akkudoktoreos/devices/battery.py b/src/akkudoktoreos/devices/battery.py index 55dd816..f12dff7 100644 --- a/src/akkudoktoreos/devices/battery.py +++ b/src/akkudoktoreos/devices/battery.py @@ -121,7 +121,8 @@ class Battery(DeviceBase): def _setup(self) -> None: """Sets up the battery parameters based on configuration or provided parameters.""" - assert self.parameters is not None + if self.parameters is None: + raise ValueError(f"Parameters not set: {self.parameters}") self.capacity_wh = self.parameters.capacity_wh self.initial_soc_percentage = self.parameters.initial_soc_percentage self.charging_efficiency = self.parameters.charging_efficiency diff --git a/src/akkudoktoreos/devices/devicesabc.py b/src/akkudoktoreos/devices/devicesabc.py index 50ba6ed..925f18b 100644 --- a/src/akkudoktoreos/devices/devicesabc.py +++ b/src/akkudoktoreos/devices/devicesabc.py @@ -170,7 +170,8 @@ class DevicesBase(DevicesStartEndMixin, PredictionMixin): def add_device(self, device: Optional["DeviceBase"]) -> None: if device is None: return - assert device.device_id not in self.devices, f"{device.device_id} already registered" + if device.device_id in self.devices: + raise ValueError(f"{device.device_id} already registered") self.devices[device.device_id] = device def remove_device(self, device: Type["DeviceBase"] | str) -> bool: diff --git a/src/akkudoktoreos/devices/generic.py b/src/akkudoktoreos/devices/generic.py index 1963b32..701bc0a 100644 --- a/src/akkudoktoreos/devices/generic.py +++ b/src/akkudoktoreos/devices/generic.py @@ -34,7 +34,8 @@ class HomeAppliance(DeviceBase): super().__init__(parameters) def _setup(self) -> None: - assert self.parameters is not None + if self.parameters is None: + raise ValueError(f"Parameters not set: {self.parameters}") self.load_curve = np.zeros(self.hours) # Initialize the load curve with zeros self.duration_h = self.parameters.duration_h self.consumption_wh = self.parameters.consumption_wh diff --git a/src/akkudoktoreos/devices/inverter.py b/src/akkudoktoreos/devices/inverter.py index e7dd9b4..8c15141 100644 --- a/src/akkudoktoreos/devices/inverter.py +++ b/src/akkudoktoreos/devices/inverter.py @@ -28,7 +28,8 @@ class Inverter(DeviceBase): super().__init__(parameters) def _setup(self) -> None: - assert self.parameters is not None + if self.parameters is None: + raise ValueError(f"Parameters not set: {self.parameters}") if self.parameters.battery_id is None: # For the moment raise exception # TODO: Make battery configurable by config @@ -41,7 +42,8 @@ class Inverter(DeviceBase): ) # Maximum power that the inverter can handle def _post_setup(self) -> None: - assert self.parameters is not None + if self.parameters is None: + raise ValueError(f"Parameters not set: {self.parameters}") self.battery = self.devices.get_device_by_id(self.parameters.battery_id) def process_energy( diff --git a/src/akkudoktoreos/optimization/genetic.py b/src/akkudoktoreos/optimization/genetic.py index a1b8e4b..05369af 100644 --- a/src/akkudoktoreos/optimization/genetic.py +++ b/src/akkudoktoreos/optimization/genetic.py @@ -121,7 +121,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi if self.fix_seed is not None: random.seed(self.fix_seed) elif logger.level == "DEBUG": - self.fix_seed = random.randint(1, 100000000000) + self.fix_seed = random.randint(1, 100000000000) # noqa: S311 random.seed(self.fix_seed) def decode_charge_discharge( diff --git a/src/akkudoktoreos/prediction/elecpriceakkudoktor.py b/src/akkudoktoreos/prediction/elecpriceakkudoktor.py index 60eb525..6a05e81 100644 --- a/src/akkudoktoreos/prediction/elecpriceakkudoktor.py +++ b/src/akkudoktoreos/prediction/elecpriceakkudoktor.py @@ -104,12 +104,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider): - add the file cache again. """ source = "https://api.akkudoktor.net" - assert self.start_datetime # mypy fix + if not self.start_datetime: + raise ValueError(f"Start DateTime not set: {self.start_datetime}") # Try to take data from 5 weeks back for prediction date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD") last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.general.timezone}" - response = requests.get(url) + response = requests.get(url, timeout=10) logger.debug(f"Response from {url}: {response}") response.raise_for_status() # Raise an error for bad responses akkudoktor_data = self._validate_data(response.content) @@ -148,7 +149,8 @@ class ElecPriceAkkudoktor(ElecPriceProvider): """ # Get Akkudoktor electricity price data akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore - assert self.start_datetime # mypy fix + if not self.start_datetime: + raise ValueError(f"Start DateTime not set: {self.start_datetime}") # Assumption that all lists are the same length and are ordered chronologically # in ascending order and have the same timestamps. @@ -178,7 +180,10 @@ class ElecPriceAkkudoktor(ElecPriceProvider): ) amount_datasets = len(self.records) - assert highest_orig_datetime # mypy fix + if not highest_orig_datetime: # mypy fix + error_msg = f"Highest original datetime not available: {highest_orig_datetime}" + logger.error(error_msg) + raise ValueError(error_msg) # some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours needed_hours = int( diff --git a/src/akkudoktoreos/prediction/interpolator.py b/src/akkudoktoreos/prediction/interpolator.py index 6eb2f89..47d1033 100644 --- a/src/akkudoktoreos/prediction/interpolator.py +++ b/src/akkudoktoreos/prediction/interpolator.py @@ -14,7 +14,7 @@ class SelfConsumptionProbabilityInterpolator: self.filepath = filepath # Load the RegularGridInterpolator with open(self.filepath, "rb") as file: - self.interpolator: RegularGridInterpolator = pickle.load(file) + self.interpolator: RegularGridInterpolator = pickle.load(file) # noqa: S301 @lru_cache(maxsize=128) def generate_points( diff --git a/src/akkudoktoreos/prediction/pvforecastakkudoktor.py b/src/akkudoktoreos/prediction/pvforecastakkudoktor.py index 30e0e13..78f25a6 100644 --- a/src/akkudoktoreos/prediction/pvforecastakkudoktor.py +++ b/src/akkudoktoreos/prediction/pvforecastakkudoktor.py @@ -291,7 +291,7 @@ class PVForecastAkkudoktor(PVForecastProvider): Raises: ValueError: If the API response does not include expected `meta` data. """ - response = requests.get(self._url()) + response = requests.get(self._url(), timeout=10) response.raise_for_status() # Raise an error for bad responses logger.debug(f"Response from {self._url()}: {response}") akkudoktor_data = self._validate_data(response.content) @@ -332,7 +332,8 @@ class PVForecastAkkudoktor(PVForecastProvider): logger.error(f"Akkudoktor schema change: {error_msg}") raise ValueError(error_msg) - assert self.start_datetime # mypy fix + if not self.start_datetime: + raise ValueError(f"Start DateTime not set: {self.start_datetime}") # Iterate over forecast data points for forecast_values in zip(*akkudoktor_data.values): diff --git a/src/akkudoktoreos/prediction/weatherbrightsky.py b/src/akkudoktoreos/prediction/weatherbrightsky.py index e24c854..1197718 100644 --- a/src/akkudoktoreos/prediction/weatherbrightsky.py +++ b/src/akkudoktoreos/prediction/weatherbrightsky.py @@ -100,7 +100,8 @@ class WeatherBrightSky(WeatherProvider): date = to_datetime(self.start_datetime, as_string=True) last_date = to_datetime(self.end_datetime, as_string=True) response = requests.get( - f"{source}/weather?lat={self.config.general.latitude}&lon={self.config.general.longitude}&date={date}&last_date={last_date}&tz={self.config.general.timezone}" + f"{source}/weather?lat={self.config.general.latitude}&lon={self.config.general.longitude}&date={date}&last_date={last_date}&tz={self.config.general.timezone}", + timeout=10, ) response.raise_for_status() # Raise an error for bad responses logger.debug(f"Response from {source}: {response}") @@ -222,7 +223,7 @@ class WeatherBrightSky(WeatherProvider): # Add Preciptable Water (PWAT) with a PVLib method. key = WeatherDataRecord.key_from_description("Temperature (°C)") - assert key + assert key # noqa: S101 temperature = self.key_to_array( key=key, start_datetime=self.start_datetime, @@ -235,7 +236,7 @@ class WeatherBrightSky(WeatherProvider): logger.debug(debug_msg) return key = WeatherDataRecord.key_from_description("Relative Humidity (%)") - assert key + assert key # noqa: S101 humidity = self.key_to_array( key=key, start_datetime=self.start_datetime, diff --git a/src/akkudoktoreos/prediction/weatherclearoutside.py b/src/akkudoktoreos/prediction/weatherclearoutside.py index 3032b63..9bf00f2 100644 --- a/src/akkudoktoreos/prediction/weatherclearoutside.py +++ b/src/akkudoktoreos/prediction/weatherclearoutside.py @@ -93,7 +93,7 @@ class WeatherClearOutside(WeatherProvider): source = "https://clearoutside.com/forecast" latitude = round(self.config.general.latitude, 2) longitude = round(self.config.general.longitude, 2) - response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true") + response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true", timeout=10) response.raise_for_status() # Raise an error for bad responses logger.debug(f"Response from {source}: {response}") # We are working on fresh data (no cache), report update time diff --git a/src/akkudoktoreos/server/dash/admin.py b/src/akkudoktoreos/server/dash/admin.py index 4dafd82..7605f4f 100644 --- a/src/akkudoktoreos/server/dash/admin.py +++ b/src/akkudoktoreos/server/dash/admin.py @@ -82,8 +82,8 @@ def AdminConfig( try: if config: config_file_path = get_nested_value(config, ["general", "config_file_path"]) - except: - pass + except Exception as e: + logger.debug(f"general.config_file_path: {e}") # export config file export_to_file_next_tag = to_datetime(as_string="YYYYMMDDHHmmss") export_to_file_status = (None,) @@ -95,7 +95,7 @@ def AdminConfig( if data["action"] == "save_to_file": # Safe current configuration to file try: - result = requests.put(f"{server}/v1/config/file") + result = requests.put(f"{server}/v1/config/file", timeout=10) result.raise_for_status() config_file_path = result.json()["general"]["config_file_path"] status = Success(f"Saved to '{config_file_path}' on '{eos_hostname}'") @@ -143,7 +143,7 @@ def AdminConfig( try: with import_file_path.open("r", encoding="utf-8", newline=None) as fd: import_config = json.load(fd) - result = requests.put(f"{server}/v1/config", json=import_config) + result = requests.put(f"{server}/v1/config", json=import_config, timeout=10) result.raise_for_status() import_from_file_status = Success( f"Config imported from '{import_file_path}' on '{eosdash_hostname}'" @@ -267,7 +267,7 @@ def Admin(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None) # Get current configuration from server server = f"http://{eos_host}:{eos_port}" try: - result = requests.get(f"{server}/v1/config") + result = requests.get(f"{server}/v1/config", timeout=10) result.raise_for_status() config = result.json() except requests.exceptions.HTTPError as e: diff --git a/src/akkudoktoreos/server/dash/configuration.py b/src/akkudoktoreos/server/dash/configuration.py index 90f16eb..9c72b15 100644 --- a/src/akkudoktoreos/server/dash/configuration.py +++ b/src/akkudoktoreos/server/dash/configuration.py @@ -218,7 +218,7 @@ def get_configuration(eos_host: str, eos_port: Union[str, int]) -> list[dict]: # Get current configuration from server try: - result = requests.get(f"{server}/v1/config") + result = requests.get(f"{server}/v1/config", timeout=10) result.raise_for_status() config = result.json() except requests.exceptions.HTTPError as e: @@ -303,9 +303,14 @@ def ConfigPlanesCard( planes_update_open = True plane_update_open = True # Make mypy happy - should never trigger - assert isinstance(update_error, (str, type(None))) - assert isinstance(update_value, (str, type(None))) - assert isinstance(update_open, (bool, type(None))) + if ( + not isinstance(update_error, (str, type(None))) + or not isinstance(update_value, (str, type(None))) + or not isinstance(update_open, (bool, type(None))) + ): + error_msg = "update_error or update_value or update_open of wrong type." + logger.error(error_msg) + raise TypeError(error_msg) plane_rows.append( ConfigCard( config["name"], @@ -441,9 +446,14 @@ def Configuration( update_value = config_update_latest.get(config["name"], {}).get("value") update_open = config_update_latest.get(config["name"], {}).get("open") # Make mypy happy - should never trigger - assert isinstance(update_error, (str, type(None))) - assert isinstance(update_value, (str, type(None))) - assert isinstance(update_open, (bool, type(None))) + if ( + not isinstance(update_error, (str, type(None))) + or not isinstance(update_value, (str, type(None))) + or not isinstance(update_open, (bool, type(None))) + ): + error_msg = "update_error or update_value or update_open of wrong type." + logger.error(error_msg) + raise TypeError(error_msg) if ( config["type"] == "Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]" @@ -505,7 +515,7 @@ def ConfigKeyUpdate(eos_host: str, eos_port: Union[str, int], key: str, value: s error = None config = None try: - response = requests.put(f"{server}/v1/config/{path}", json=data) + response = requests.put(f"{server}/v1/config/{path}", json=data, timeout=10) response.raise_for_status() config = response.json() except requests.exceptions.HTTPError as err: diff --git a/src/akkudoktoreos/server/dash/data/democonfig.json b/src/akkudoktoreos/server/dash/data/democonfig.json index cf07d08..db79876 100644 --- a/src/akkudoktoreos/server/dash/data/democonfig.json +++ b/src/akkudoktoreos/server/dash/data/democonfig.json @@ -75,9 +75,9 @@ }, "server": { "startup_eosdash": true, - "host": "0.0.0.0", + "host": "127.0.0.1", "port": 8503, - "eosdash_host": "0.0.0.0", + "eosdash_host": "127.0.0.1", "eosdash_port": 8504 }, "weather": { diff --git a/src/akkudoktoreos/server/dash/demo.py b/src/akkudoktoreos/server/dash/demo.py index 88a5001..34d8bf2 100644 --- a/src/akkudoktoreos/server/dash/demo.py +++ b/src/akkudoktoreos/server/dash/demo.py @@ -186,7 +186,7 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str: # Get current configuration from server try: - result = requests.get(f"{server}/v1/config") + result = requests.get(f"{server}/v1/config", timeout=10) result.raise_for_status() except requests.exceptions.HTTPError as err: detail = result.json()["detail"] @@ -200,12 +200,12 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str: with FILE_DEMOCONFIG.open("r", encoding="utf-8") as fd: democonfig = json.load(fd) try: - result = requests.put(f"{server}/v1/config", json=democonfig) + result = requests.put(f"{server}/v1/config", json=democonfig, timeout=10) result.raise_for_status() except requests.exceptions.HTTPError as err: detail = result.json()["detail"] # Try to reset to original config - requests.put(f"{server}/v1/config", json=config) + requests.put(f"{server}/v1/config", json=config, timeout=10) return P( f"Can not set demo configuration on {server}: {err}, {detail}", cls="text-center", @@ -213,12 +213,12 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str: # Update all predictions try: - result = requests.post(f"{server}/v1/prediction/update") + result = requests.post(f"{server}/v1/prediction/update", timeout=10) result.raise_for_status() except requests.exceptions.HTTPError as err: detail = result.json()["detail"] # Try to reset to original config - requests.put(f"{server}/v1/config", json=config) + requests.put(f"{server}/v1/config", json=config, timeout=10) return P( f"Can not update predictions on {server}: {err}, {detail}", cls="text-center", @@ -239,7 +239,7 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str: "load_mean_adjusted", ], } - result = requests.get(f"{server}/v1/prediction/dataframe", params=params) + result = requests.get(f"{server}/v1/prediction/dataframe", params=params, timeout=10) result.raise_for_status() predictions = PydanticDateTimeDataFrame(**result.json()).to_dataframe() except requests.exceptions.HTTPError as err: @@ -255,7 +255,7 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str: ) # Reset to original config - requests.put(f"{server}/v1/config", json=config) + requests.put(f"{server}/v1/config", json=config, timeout=10) return Grid( DemoPVForecast(predictions, democonfig), diff --git a/src/akkudoktoreos/server/dash/footer.py b/src/akkudoktoreos/server/dash/footer.py index c0097e8..97b66b0 100644 --- a/src/akkudoktoreos/server/dash/footer.py +++ b/src/akkudoktoreos/server/dash/footer.py @@ -24,7 +24,7 @@ def get_alive(eos_host: str, eos_port: Union[str, int]) -> str: """ result = requests.Response() try: - result = requests.get(f"http://{eos_host}:{eos_port}/v1/health") + result = requests.get(f"http://{eos_host}:{eos_port}/v1/health", timeout=10) if result.status_code == 200: alive = result.json()["status"] else: diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 74cf7a9..6335a10 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -49,7 +49,11 @@ from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_pr from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings from akkudoktoreos.server.rest.error import create_error_page from akkudoktoreos.server.rest.tasks import repeat_every -from akkudoktoreos.server.server import get_default_host, wait_for_port_free +from akkudoktoreos.server.server import ( + get_default_host, + is_valid_ip_or_hostname, + wait_for_port_free, +) from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration logger = get_logger(__name__) @@ -100,6 +104,11 @@ def start_eosdash( Raises: RuntimeError: If the EOSdash server fails to start. """ + if not is_valid_ip_or_hostname(host): + raise ValueError(f"Invalid EOSdash host: {host}") + if not is_valid_ip_or_hostname(eos_host): + raise ValueError(f"Invalid EOS host: {eos_host}") + eosdash_path = Path(__file__).parent.resolve().joinpath("eosdash.py") # Do a one time check for port free to generate warnings if not so @@ -130,7 +139,7 @@ def start_eosdash( env["EOS_CONFIG_DIR"] = eos_config_dir try: - server_process = subprocess.Popen( + server_process = subprocess.Popen( # noqa: S603 cmd, env=env, stdout=subprocess.PIPE, @@ -240,10 +249,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: access_log = args.access_log reload = args.reload - host = host if host else get_default_host() - port = port if port else 8504 eos_host = eos_host if eos_host else get_default_host() eos_port = eos_port if eos_port else 8503 + host = host if host else eos_host + port = port if port else 8504 eos_dir = str(config_eos.general.data_folder_path) eos_config_dir = str(config_eos.general.config_folder_path) @@ -370,7 +379,7 @@ async def fastapi_admin_server_restart_post() -> dict: env["EOS_DIR"] = str(config_eos.general.data_folder_path) env["EOS_CONFIG_DIR"] = str(config_eos.general.config_folder_path) - new_process = subprocess.Popen( + new_process = subprocess.Popen( # noqa: S603 [ sys.executable, ] @@ -1208,7 +1217,7 @@ def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectRespons if port is None: port = 8504 # Make hostname Windows friendly - if host == "0.0.0.0" and os.name == "nt": + if host == "0.0.0.0" and os.name == "nt": # noqa: S104 host = "localhost" url = f"http://{host}:{port}/" error_page = create_error_page( @@ -1225,7 +1234,7 @@ Did you want to connect to EOSdash? # Make hostname Windows friendly host = str(config_eos.server.eosdash_host) - if host == "0.0.0.0" and os.name == "nt": + if host == "0.0.0.0" and os.name == "nt": # noqa: S104 host = "localhost" if host and config_eos.server.eosdash_port: # Redirect to EOSdash server @@ -1258,7 +1267,7 @@ def run_eos(host: str, port: int, log_level: str, access_log: bool, reload: bool None """ # Make hostname Windows friendly - if host == "0.0.0.0" and os.name == "nt": + if host == "0.0.0.0" and os.name == "nt": # noqa: S104 host = "localhost" # Wait for EOS port to be free - e.g. in case of restart diff --git a/src/akkudoktoreos/server/eosdash.py b/src/akkudoktoreos/server/eosdash.py index 7f9d4d5..e290479 100644 --- a/src/akkudoktoreos/server/eosdash.py +++ b/src/akkudoktoreos/server/eosdash.py @@ -242,7 +242,7 @@ def run_eosdash() -> None: reload = False # Make hostname Windows friendly - if eosdash_host == "0.0.0.0" and os.name == "nt": + if eosdash_host == "0.0.0.0" and os.name == "nt": # noqa: S104 eosdash_host = "localhost" # Wait for EOSdash port to be free - e.g. in case of restart diff --git a/src/akkudoktoreos/server/server.py b/src/akkudoktoreos/server/server.py index 993e9ad..8578589 100644 --- a/src/akkudoktoreos/server/server.py +++ b/src/akkudoktoreos/server/server.py @@ -1,6 +1,7 @@ """Server Module.""" -import os +import ipaddress +import re import time from typing import Optional, Union @@ -14,9 +15,39 @@ logger = get_logger(__name__) def get_default_host() -> str: - if os.name == "nt": - return "127.0.0.1" - return "0.0.0.0" + """Default host for EOS.""" + return "127.0.0.1" + + +def is_valid_ip_or_hostname(value: str) -> bool: + """Validate whether a string is a valid IP address (IPv4 or IPv6) or hostname. + + This function first attempts to interpret the input as an IP address using the + standard library `ipaddress` module. If that fails, it checks whether the input + is a valid hostname according to RFC 1123, which allows domain names consisting + of alphanumeric characters and hyphens, with specific length and structure rules. + + Args: + value (str): The input string to validate. + + Returns: + bool: True if the input is a valid IP address or hostname, False otherwise. + """ + try: + ipaddress.ip_address(value) + return True + except ValueError: + pass + + if len(value) > 253: + return False + + hostname_regex = re.compile( + r"^(?=.{1,253}$)(?!-)[A-Z\d-]{1,63}(? bool: @@ -110,6 +141,8 @@ class ServerCommonSettings(SettingsBaseModel): cls, value: Optional[Union[str, IPvAnyAddress]] ) -> Optional[Union[str, IPvAnyAddress]]: if isinstance(value, str): + if not is_valid_ip_or_hostname(value): + raise ValueError(f"Invalid host: {value}") if value.lower() in ("localhost", "loopback"): value = "127.0.0.1" return value diff --git a/tests/conftest.py b/tests/conftest.py index 59e2371..b6d6d78 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -429,7 +429,7 @@ def server(xprocess, config_eos, config_default_dirs) -> Generator[str, None, No Provides URL of the server. """ # create url/port info to the server - url = "http://0.0.0.0:8503" + url = "http://127.0.0.1:8503" class Starter(ProcessStarter): # Set environment before any subprocess run, to keep custom config dir diff --git a/tests/test_doc.py b/tests/test_doc.py index b08fa88..1bd903a 100644 --- a/tests/test_doc.py +++ b/tests/test_doc.py @@ -28,8 +28,6 @@ def test_openapi_spec_current(config_eos): spec = generate_openapi.generate_openapi() spec_str = json.dumps(spec, indent=4, sort_keys=True) - if os.name == "nt": - spec_str = spec_str.replace("127.0.0.1", "0.0.0.0") with new_spec_path.open("w", encoding="utf-8", newline="\n") as f_new: f_new.write(spec_str) @@ -62,8 +60,6 @@ def test_openapi_md_current(config_eos): spec_md = generate_openapi_md.generate_openapi_md() - if os.name == "nt": - spec_md = spec_md.replace("127.0.0.1", "0.0.0.0") with new_spec_md_path.open("w", encoding="utf-8", newline="\n") as f_new: f_new.write(spec_md) @@ -94,7 +90,7 @@ def test_config_md_current(config_eos): config_md = generate_config_md.generate_config_md(config_eos) if os.name == "nt": - config_md = config_md.replace("127.0.0.1", "0.0.0.0").replace("\\\\", "/") + config_md = config_md.replace("\\\\", "/") with new_config_md_path.open("w", encoding="utf-8", newline="\n") as f_new: f_new.write(config_md) diff --git a/tests/test_eosdashconfig.py b/tests/test_eosdashconfig.py index 1ec29e7..79f0cb6 100644 --- a/tests/test_eosdashconfig.py +++ b/tests/test_eosdashconfig.py @@ -83,7 +83,7 @@ class TestEOSdashConfig: item["name"] == "server.eosdash_port" and item["value"] == "8504" for item in config ) assert any( - item["name"] == "server.eosdash_host" and item["value"] == '"0.0.0.0"' + item["name"] == "server.eosdash_host" and item["value"] == '"127.0.0.1"' for item in config ) diff --git a/tests/test_pydantic.py b/tests/test_pydantic.py index 40a5de0..9b5e9cb 100644 --- a/tests/test_pydantic.py +++ b/tests/test_pydantic.py @@ -152,7 +152,7 @@ class TestPydanticBaseModel: assert model.datetime_field == dt def test_invalid_datetime_string(self): - with pytest.raises(ValidationError, match="Input should be an instance of DateTime"): + with pytest.raises(ValidationError, match="Cannot convert 'invalid_datetime' to datetime"): PydanticTestModel(datetime_field="invalid_datetime") def test_iso8601_serialization(self): diff --git a/tests/testdata/eosserver_config_1.json b/tests/testdata/eosserver_config_1.json index e42d254..8d99f95 100644 --- a/tests/testdata/eosserver_config_1.json +++ b/tests/testdata/eosserver_config_1.json @@ -75,9 +75,9 @@ }, "server": { "startup_eosdash": true, - "host": "0.0.0.0", + "host": "127.0.0.1", "port": 8503, - "eosdash_host": "0.0.0.0", + "eosdash_host": "127.0.0.1", "eosdash_port": 8504 }, "weather": {