ci(ruff): add bandit checks (#575)

Added bandit checks to continuous integration.

Updated sources to pass bandit checks:
- replaced asserts
- added timeouts to requests
- added checks for process command execution
- changed to 127.0.0.1 as default IP address for EOS and EOSdash for security reasons

Added a rudimentary check for outdated config files.

BREAKING CHANGE: Default IP address for EOS and EOSdash changed to 127.0.0.1

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte 2025-06-03 08:30:37 +02:00 committed by GitHub
parent aa39ff475c
commit 3421b2303b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 163 additions and 86 deletions

View File

@ -880,11 +880,11 @@ Validators:
| Name | Environment Variable | Type | Read-Only | Default | Description | | 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. | | port | `EOS_SERVER__PORT` | `Optional[int]` | `rw` | `8503` | EOS server IP port number. |
| verbose | `EOS_SERVER__VERBOSE` | `Optional[bool]` | `rw` | `False` | Enable debug output | | 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. | | 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. | | eosdash_port | `EOS_SERVER__EOSDASH_PORT` | `Optional[int]` | `rw` | `8504` | EOSdash server IP port number. |
::: :::
@ -895,11 +895,11 @@ Validators:
{ {
"server": { "server": {
"host": "0.0.0.0", "host": "127.0.0.1",
"port": 8503, "port": 8503,
"verbose": false, "verbose": false,
"startup_eosdash": true, "startup_eosdash": true,
"eosdash_host": "0.0.0.0", "eosdash_host": "127.0.0.1",
"eosdash_port": 8504 "eosdash_port": 8504
} }
} }
@ -1054,11 +1054,11 @@ Validators:
"provider_settings": null "provider_settings": null
}, },
"server": { "server": {
"host": "0.0.0.0", "host": "127.0.0.1",
"port": 8503, "port": 8503,
"verbose": false, "verbose": false,
"startup_eosdash": true, "startup_eosdash": true,
"eosdash_host": "0.0.0.0", "eosdash_host": "127.0.0.1",
"eosdash_port": 8504 "eosdash_port": 8504
}, },
"utils": {} "utils": {}

View File

@ -220,9 +220,9 @@
"server": { "server": {
"$ref": "#/components/schemas/ServerCommonSettings", "$ref": "#/components/schemas/ServerCommonSettings",
"default": { "default": {
"eosdash_host": "0.0.0.0", "eosdash_host": "127.0.0.1",
"eosdash_port": 8504, "eosdash_port": 8504,
"host": "0.0.0.0", "host": "127.0.0.1",
"port": 8503, "port": 8503,
"startup_eosdash": true, "startup_eosdash": true,
"verbose": false "verbose": false
@ -2279,7 +2279,7 @@
"type": "null" "type": "null"
} }
], ],
"default": "0.0.0.0", "default": "127.0.0.1",
"description": "EOSdash server IP address.", "description": "EOSdash server IP address.",
"title": "Eosdash Host" "title": "Eosdash Host"
}, },
@ -2306,7 +2306,7 @@
"type": "null" "type": "null"
} }
], ],
"default": "0.0.0.0", "default": "127.0.0.1",
"description": "EOS server IP address.", "description": "EOS server IP address.",
"title": "Host" "title": "Host"
}, },

View File

@ -43,12 +43,18 @@ profile = "black"
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
exclude = [
"tests",
"scripts",
]
output-format = "full"
[tool.ruff.lint] [tool.ruff.lint]
select = [ select = [
"F", # Enable all `Pyflakes` rules. "F", # Enable all `Pyflakes` rules.
"D", # Enable all `pydocstyle` rules, limiting to those that adhere to the "D", # Enable all `pydocstyle` rules, limiting to those that adhere to the
# Google convention via `convention = "google"`, below. # Google convention via `convention = "google"`, below.
"S", # Enable all `flake8-bandit` rules.
] ]
ignore = [ ignore = [
# Prevent errors due to ruff false positives # Prevent errors due to ruff false positives

View File

@ -298,7 +298,7 @@ def main():
try: try:
config_md = generate_config_md(config_eos) config_md = generate_config_md(config_eos)
if os.name == "nt": 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: if args.output_file:
# Write to file # Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f: with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:

View File

@ -58,8 +58,6 @@ def main():
try: try:
openapi_spec = generate_openapi() openapi_spec = generate_openapi()
openapi_spec_str = json.dumps(openapi_spec, indent=2) 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: if args.output_file:
# Write to file # Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f: with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:

View File

@ -286,7 +286,7 @@ def main():
try: try:
openapi_md = generate_openapi_md() openapi_md = generate_openapi_md()
if os.name == "nt": 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: if args.output_file:
# Write to file # Write to file
with open(args.output_file, "w", encoding="utf-8", newline="\n") as f: with open(args.output_file, "w", encoding="utf-8", newline="\n") as f:

View File

@ -376,6 +376,15 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
def _setup(self, *args: Any, **kwargs: Any) -> None: def _setup(self, *args: Any, **kwargs: Any) -> None:
"""Re-initialize global settings.""" """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 # Assure settings base knows EOS configuration
SettingsBaseModel.config = self SettingsBaseModel.config = self
# (Re-)load settings # (Re-)load settings
@ -394,7 +403,9 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
ValueError: If the `settings` is not a `SettingsEOS` instance. ValueError: If the `settings` is not a `SettingsEOS` instance.
""" """
if not isinstance(settings, SettingsEOS): 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)) self.merge_settings_from_dict(settings.model_dump(exclude_none=True, exclude_unset=True))
@ -471,10 +482,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
@classmethod @classmethod
def _get_config_file_path(cls) -> tuple[Path, bool]: 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: 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 = [] config_dirs = []
env_base_dir = os.getenv(cls.EOS_DIR) env_base_dir = os.getenv(cls.EOS_DIR)

View File

@ -956,7 +956,7 @@ def cache_in_file(
logger.debug("Used cache file for function: " + func.__name__) logger.debug("Used cache file for function: " + func.__name__)
cache_file.seek(0) cache_file.seek(0)
if "b" in mode: if "b" in mode:
result = pickle.load(cache_file) result = pickle.load(cache_file) # noqa: S301
else: else:
result = cache_file.read() result = cache_file.read()
except Exception as e: except Exception as e:

View File

@ -34,7 +34,7 @@ class classproperty:
argument and returns a value. argument and returns a value.
Raises: 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: 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: def __get__(self, _: Any, owner_cls: Optional[type[Any]] = None) -> Any:
if owner_cls is None: if owner_cls is None:
return self 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) return self.fget(owner_cls)

View File

@ -393,7 +393,8 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
# Fetch objects # Fetch objects
battery = self.battery battery = self.battery
assert battery # to please mypy if battery is None:
raise ValueError(f"battery not set: {battery}")
ev = self.ev ev = self.ev
home_appliance = self.home_appliance home_appliance = self.home_appliance
inverter = self.inverter inverter = self.inverter

View File

@ -450,8 +450,8 @@ class PydanticBaseModel(BaseModel, PydanticModelNestedValueMixin):
if expected_type is pendulum.DateTime or expected_type is AwareDatetime: if expected_type is pendulum.DateTime or expected_type is AwareDatetime:
try: try:
value = to_datetime(value) value = to_datetime(value)
except: except Exception as e:
pass raise ValueError(f"Cannot convert {value!r} to datetime: {e}")
return value return value
# Override Pydantics serialization for all DateTime fields # Override Pydantics serialization for all DateTime fields

View File

@ -121,7 +121,8 @@ class Battery(DeviceBase):
def _setup(self) -> None: def _setup(self) -> None:
"""Sets up the battery parameters based on configuration or provided parameters.""" """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.capacity_wh = self.parameters.capacity_wh
self.initial_soc_percentage = self.parameters.initial_soc_percentage self.initial_soc_percentage = self.parameters.initial_soc_percentage
self.charging_efficiency = self.parameters.charging_efficiency self.charging_efficiency = self.parameters.charging_efficiency

View File

@ -170,7 +170,8 @@ class DevicesBase(DevicesStartEndMixin, PredictionMixin):
def add_device(self, device: Optional["DeviceBase"]) -> None: def add_device(self, device: Optional["DeviceBase"]) -> None:
if device is None: if device is None:
return 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 self.devices[device.device_id] = device
def remove_device(self, device: Type["DeviceBase"] | str) -> bool: def remove_device(self, device: Type["DeviceBase"] | str) -> bool:

View File

@ -34,7 +34,8 @@ class HomeAppliance(DeviceBase):
super().__init__(parameters) super().__init__(parameters)
def _setup(self) -> None: 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.load_curve = np.zeros(self.hours) # Initialize the load curve with zeros
self.duration_h = self.parameters.duration_h self.duration_h = self.parameters.duration_h
self.consumption_wh = self.parameters.consumption_wh self.consumption_wh = self.parameters.consumption_wh

View File

@ -28,7 +28,8 @@ class Inverter(DeviceBase):
super().__init__(parameters) super().__init__(parameters)
def _setup(self) -> None: 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: if self.parameters.battery_id is None:
# For the moment raise exception # For the moment raise exception
# TODO: Make battery configurable by config # TODO: Make battery configurable by config
@ -41,7 +42,8 @@ class Inverter(DeviceBase):
) # Maximum power that the inverter can handle ) # Maximum power that the inverter can handle
def _post_setup(self) -> None: 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) self.battery = self.devices.get_device_by_id(self.parameters.battery_id)
def process_energy( def process_energy(

View File

@ -121,7 +121,7 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi
if self.fix_seed is not None: if self.fix_seed is not None:
random.seed(self.fix_seed) random.seed(self.fix_seed)
elif logger.level == "DEBUG": 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) random.seed(self.fix_seed)
def decode_charge_discharge( def decode_charge_discharge(

View File

@ -104,12 +104,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
- add the file cache again. - add the file cache again.
""" """
source = "https://api.akkudoktor.net" 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 # 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") 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") 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}" 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}") logger.debug(f"Response from {url}: {response}")
response.raise_for_status() # Raise an error for bad responses response.raise_for_status() # Raise an error for bad responses
akkudoktor_data = self._validate_data(response.content) akkudoktor_data = self._validate_data(response.content)
@ -148,7 +149,8 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
""" """
# Get Akkudoktor electricity price data # Get Akkudoktor electricity price data
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore 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 # Assumption that all lists are the same length and are ordered chronologically
# in ascending order and have the same timestamps. # in ascending order and have the same timestamps.
@ -178,7 +180,10 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
) )
amount_datasets = len(self.records) 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 # 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( needed_hours = int(

View File

@ -14,7 +14,7 @@ class SelfConsumptionProbabilityInterpolator:
self.filepath = filepath self.filepath = filepath
# Load the RegularGridInterpolator # Load the RegularGridInterpolator
with open(self.filepath, "rb") as file: 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) @lru_cache(maxsize=128)
def generate_points( def generate_points(

View File

@ -291,7 +291,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
Raises: Raises:
ValueError: If the API response does not include expected `meta` data. 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 response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {self._url()}: {response}") logger.debug(f"Response from {self._url()}: {response}")
akkudoktor_data = self._validate_data(response.content) akkudoktor_data = self._validate_data(response.content)
@ -332,7 +332,8 @@ class PVForecastAkkudoktor(PVForecastProvider):
logger.error(f"Akkudoktor schema change: {error_msg}") logger.error(f"Akkudoktor schema change: {error_msg}")
raise ValueError(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 # Iterate over forecast data points
for forecast_values in zip(*akkudoktor_data.values): for forecast_values in zip(*akkudoktor_data.values):

View File

@ -100,7 +100,8 @@ class WeatherBrightSky(WeatherProvider):
date = to_datetime(self.start_datetime, as_string=True) date = to_datetime(self.start_datetime, as_string=True)
last_date = to_datetime(self.end_datetime, as_string=True) last_date = to_datetime(self.end_datetime, as_string=True)
response = requests.get( 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 response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {source}: {response}") logger.debug(f"Response from {source}: {response}")
@ -222,7 +223,7 @@ class WeatherBrightSky(WeatherProvider):
# Add Preciptable Water (PWAT) with a PVLib method. # Add Preciptable Water (PWAT) with a PVLib method.
key = WeatherDataRecord.key_from_description("Temperature (°C)") key = WeatherDataRecord.key_from_description("Temperature (°C)")
assert key assert key # noqa: S101
temperature = self.key_to_array( temperature = self.key_to_array(
key=key, key=key,
start_datetime=self.start_datetime, start_datetime=self.start_datetime,
@ -235,7 +236,7 @@ class WeatherBrightSky(WeatherProvider):
logger.debug(debug_msg) logger.debug(debug_msg)
return return
key = WeatherDataRecord.key_from_description("Relative Humidity (%)") key = WeatherDataRecord.key_from_description("Relative Humidity (%)")
assert key assert key # noqa: S101
humidity = self.key_to_array( humidity = self.key_to_array(
key=key, key=key,
start_datetime=self.start_datetime, start_datetime=self.start_datetime,

View File

@ -93,7 +93,7 @@ class WeatherClearOutside(WeatherProvider):
source = "https://clearoutside.com/forecast" source = "https://clearoutside.com/forecast"
latitude = round(self.config.general.latitude, 2) latitude = round(self.config.general.latitude, 2)
longitude = round(self.config.general.longitude, 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 response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {source}: {response}") logger.debug(f"Response from {source}: {response}")
# We are working on fresh data (no cache), report update time # We are working on fresh data (no cache), report update time

View File

@ -82,8 +82,8 @@ def AdminConfig(
try: try:
if config: if config:
config_file_path = get_nested_value(config, ["general", "config_file_path"]) config_file_path = get_nested_value(config, ["general", "config_file_path"])
except: except Exception as e:
pass logger.debug(f"general.config_file_path: {e}")
# export config file # export config file
export_to_file_next_tag = to_datetime(as_string="YYYYMMDDHHmmss") export_to_file_next_tag = to_datetime(as_string="YYYYMMDDHHmmss")
export_to_file_status = (None,) export_to_file_status = (None,)
@ -95,7 +95,7 @@ def AdminConfig(
if data["action"] == "save_to_file": if data["action"] == "save_to_file":
# Safe current configuration to file # Safe current configuration to file
try: try:
result = requests.put(f"{server}/v1/config/file") result = requests.put(f"{server}/v1/config/file", timeout=10)
result.raise_for_status() result.raise_for_status()
config_file_path = result.json()["general"]["config_file_path"] config_file_path = result.json()["general"]["config_file_path"]
status = Success(f"Saved to '{config_file_path}' on '{eos_hostname}'") status = Success(f"Saved to '{config_file_path}' on '{eos_hostname}'")
@ -143,7 +143,7 @@ def AdminConfig(
try: try:
with import_file_path.open("r", encoding="utf-8", newline=None) as fd: with import_file_path.open("r", encoding="utf-8", newline=None) as fd:
import_config = json.load(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() result.raise_for_status()
import_from_file_status = Success( import_from_file_status = Success(
f"Config imported from '{import_file_path}' on '{eosdash_hostname}'" 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 # Get current configuration from server
server = f"http://{eos_host}:{eos_port}" server = f"http://{eos_host}:{eos_port}"
try: try:
result = requests.get(f"{server}/v1/config") result = requests.get(f"{server}/v1/config", timeout=10)
result.raise_for_status() result.raise_for_status()
config = result.json() config = result.json()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:

View File

@ -218,7 +218,7 @@ def get_configuration(eos_host: str, eos_port: Union[str, int]) -> list[dict]:
# Get current configuration from server # Get current configuration from server
try: try:
result = requests.get(f"{server}/v1/config") result = requests.get(f"{server}/v1/config", timeout=10)
result.raise_for_status() result.raise_for_status()
config = result.json() config = result.json()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
@ -303,9 +303,14 @@ def ConfigPlanesCard(
planes_update_open = True planes_update_open = True
plane_update_open = True plane_update_open = True
# Make mypy happy - should never trigger # Make mypy happy - should never trigger
assert isinstance(update_error, (str, type(None))) if (
assert isinstance(update_value, (str, type(None))) not isinstance(update_error, (str, type(None)))
assert isinstance(update_open, (bool, 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( plane_rows.append(
ConfigCard( ConfigCard(
config["name"], config["name"],
@ -441,9 +446,14 @@ def Configuration(
update_value = config_update_latest.get(config["name"], {}).get("value") update_value = config_update_latest.get(config["name"], {}).get("value")
update_open = config_update_latest.get(config["name"], {}).get("open") update_open = config_update_latest.get(config["name"], {}).get("open")
# Make mypy happy - should never trigger # Make mypy happy - should never trigger
assert isinstance(update_error, (str, type(None))) if (
assert isinstance(update_value, (str, type(None))) not isinstance(update_error, (str, type(None)))
assert isinstance(update_open, (bool, 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 ( if (
config["type"] config["type"]
== "Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]" == "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 error = None
config = None config = None
try: 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() response.raise_for_status()
config = response.json() config = response.json()
except requests.exceptions.HTTPError as err: except requests.exceptions.HTTPError as err:

View File

@ -75,9 +75,9 @@
}, },
"server": { "server": {
"startup_eosdash": true, "startup_eosdash": true,
"host": "0.0.0.0", "host": "127.0.0.1",
"port": 8503, "port": 8503,
"eosdash_host": "0.0.0.0", "eosdash_host": "127.0.0.1",
"eosdash_port": 8504 "eosdash_port": 8504
}, },
"weather": { "weather": {

View File

@ -186,7 +186,7 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str:
# Get current configuration from server # Get current configuration from server
try: try:
result = requests.get(f"{server}/v1/config") result = requests.get(f"{server}/v1/config", timeout=10)
result.raise_for_status() result.raise_for_status()
except requests.exceptions.HTTPError as err: except requests.exceptions.HTTPError as err:
detail = result.json()["detail"] 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: with FILE_DEMOCONFIG.open("r", encoding="utf-8") as fd:
democonfig = json.load(fd) democonfig = json.load(fd)
try: 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() result.raise_for_status()
except requests.exceptions.HTTPError as err: except requests.exceptions.HTTPError as err:
detail = result.json()["detail"] detail = result.json()["detail"]
# Try to reset to original config # 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( return P(
f"Can not set demo configuration on {server}: {err}, {detail}", f"Can not set demo configuration on {server}: {err}, {detail}",
cls="text-center", cls="text-center",
@ -213,12 +213,12 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str:
# Update all predictions # Update all predictions
try: try:
result = requests.post(f"{server}/v1/prediction/update") result = requests.post(f"{server}/v1/prediction/update", timeout=10)
result.raise_for_status() result.raise_for_status()
except requests.exceptions.HTTPError as err: except requests.exceptions.HTTPError as err:
detail = result.json()["detail"] detail = result.json()["detail"]
# Try to reset to original config # 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( return P(
f"Can not update predictions on {server}: {err}, {detail}", f"Can not update predictions on {server}: {err}, {detail}",
cls="text-center", cls="text-center",
@ -239,7 +239,7 @@ def Demo(eos_host: str, eos_port: Union[str, int]) -> str:
"load_mean_adjusted", "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() result.raise_for_status()
predictions = PydanticDateTimeDataFrame(**result.json()).to_dataframe() predictions = PydanticDateTimeDataFrame(**result.json()).to_dataframe()
except requests.exceptions.HTTPError as err: 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 # Reset to original config
requests.put(f"{server}/v1/config", json=config) requests.put(f"{server}/v1/config", json=config, timeout=10)
return Grid( return Grid(
DemoPVForecast(predictions, democonfig), DemoPVForecast(predictions, democonfig),

View File

@ -24,7 +24,7 @@ def get_alive(eos_host: str, eos_port: Union[str, int]) -> str:
""" """
result = requests.Response() result = requests.Response()
try: 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: if result.status_code == 200:
alive = result.json()["status"] alive = result.json()["status"]
else: else:

View File

@ -49,7 +49,11 @@ from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_pr
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
from akkudoktoreos.server.rest.error import create_error_page from akkudoktoreos.server.rest.error import create_error_page
from akkudoktoreos.server.rest.tasks import repeat_every 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 from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
logger = get_logger(__name__) logger = get_logger(__name__)
@ -100,6 +104,11 @@ def start_eosdash(
Raises: Raises:
RuntimeError: If the EOSdash server fails to start. 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") eosdash_path = Path(__file__).parent.resolve().joinpath("eosdash.py")
# Do a one time check for port free to generate warnings if not so # 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 env["EOS_CONFIG_DIR"] = eos_config_dir
try: try:
server_process = subprocess.Popen( server_process = subprocess.Popen( # noqa: S603
cmd, cmd,
env=env, env=env,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@ -240,10 +249,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
access_log = args.access_log access_log = args.access_log
reload = args.reload 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_host = eos_host if eos_host else get_default_host()
eos_port = eos_port if eos_port else 8503 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_dir = str(config_eos.general.data_folder_path)
eos_config_dir = str(config_eos.general.config_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_DIR"] = str(config_eos.general.data_folder_path)
env["EOS_CONFIG_DIR"] = str(config_eos.general.config_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, sys.executable,
] ]
@ -1208,7 +1217,7 @@ def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectRespons
if port is None: if port is None:
port = 8504 port = 8504
# Make hostname Windows friendly # 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" host = "localhost"
url = f"http://{host}:{port}/" url = f"http://{host}:{port}/"
error_page = create_error_page( error_page = create_error_page(
@ -1225,7 +1234,7 @@ Did you want to connect to <a href="{url}" class="back-button">EOSdash</a>?
# Make hostname Windows friendly # Make hostname Windows friendly
host = str(config_eos.server.eosdash_host) 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" host = "localhost"
if host and config_eos.server.eosdash_port: if host and config_eos.server.eosdash_port:
# Redirect to EOSdash server # Redirect to EOSdash server
@ -1258,7 +1267,7 @@ def run_eos(host: str, port: int, log_level: str, access_log: bool, reload: bool
None None
""" """
# Make hostname Windows friendly # 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" host = "localhost"
# Wait for EOS port to be free - e.g. in case of restart # Wait for EOS port to be free - e.g. in case of restart

View File

@ -242,7 +242,7 @@ def run_eosdash() -> None:
reload = False reload = False
# Make hostname Windows friendly # 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" eosdash_host = "localhost"
# Wait for EOSdash port to be free - e.g. in case of restart # Wait for EOSdash port to be free - e.g. in case of restart

View File

@ -1,6 +1,7 @@
"""Server Module.""" """Server Module."""
import os import ipaddress
import re
import time import time
from typing import Optional, Union from typing import Optional, Union
@ -14,9 +15,39 @@ logger = get_logger(__name__)
def get_default_host() -> str: def get_default_host() -> str:
if os.name == "nt": """Default host for EOS."""
return "127.0.0.1" return "127.0.0.1"
return "0.0.0.0"
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}(?<!-)"
r"(?:\.(?!-)[A-Z\d-]{1,63}(?<!-))*\.?$",
re.IGNORECASE,
)
return bool(hostname_regex.fullmatch(value))
def wait_for_port_free(port: int, timeout: int = 0, waiting_app_name: str = "App") -> bool: def wait_for_port_free(port: int, timeout: int = 0, waiting_app_name: str = "App") -> bool:
@ -110,6 +141,8 @@ class ServerCommonSettings(SettingsBaseModel):
cls, value: Optional[Union[str, IPvAnyAddress]] cls, value: Optional[Union[str, IPvAnyAddress]]
) -> Optional[Union[str, IPvAnyAddress]]: ) -> Optional[Union[str, IPvAnyAddress]]:
if isinstance(value, str): if isinstance(value, str):
if not is_valid_ip_or_hostname(value):
raise ValueError(f"Invalid host: {value}")
if value.lower() in ("localhost", "loopback"): if value.lower() in ("localhost", "loopback"):
value = "127.0.0.1" value = "127.0.0.1"
return value return value

View File

@ -429,7 +429,7 @@ def server(xprocess, config_eos, config_default_dirs) -> Generator[str, None, No
Provides URL of the server. Provides URL of the server.
""" """
# create url/port info to 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): class Starter(ProcessStarter):
# Set environment before any subprocess run, to keep custom config dir # Set environment before any subprocess run, to keep custom config dir

View File

@ -28,8 +28,6 @@ def test_openapi_spec_current(config_eos):
spec = generate_openapi.generate_openapi() spec = generate_openapi.generate_openapi()
spec_str = json.dumps(spec, indent=4, sort_keys=True) 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: with new_spec_path.open("w", encoding="utf-8", newline="\n") as f_new:
f_new.write(spec_str) f_new.write(spec_str)
@ -62,8 +60,6 @@ def test_openapi_md_current(config_eos):
spec_md = generate_openapi_md.generate_openapi_md() 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: with new_spec_md_path.open("w", encoding="utf-8", newline="\n") as f_new:
f_new.write(spec_md) 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) config_md = generate_config_md.generate_config_md(config_eos)
if os.name == "nt": 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: with new_config_md_path.open("w", encoding="utf-8", newline="\n") as f_new:
f_new.write(config_md) f_new.write(config_md)

View File

@ -83,7 +83,7 @@ class TestEOSdashConfig:
item["name"] == "server.eosdash_port" and item["value"] == "8504" for item in config item["name"] == "server.eosdash_port" and item["value"] == "8504" for item in config
) )
assert any( 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 for item in config
) )

View File

@ -152,7 +152,7 @@ class TestPydanticBaseModel:
assert model.datetime_field == dt assert model.datetime_field == dt
def test_invalid_datetime_string(self): 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") PydanticTestModel(datetime_field="invalid_datetime")
def test_iso8601_serialization(self): def test_iso8601_serialization(self):

View File

@ -75,9 +75,9 @@
}, },
"server": { "server": {
"startup_eosdash": true, "startup_eosdash": true,
"host": "0.0.0.0", "host": "127.0.0.1",
"port": 8503, "port": 8503,
"eosdash_host": "0.0.0.0", "eosdash_host": "127.0.0.1",
"eosdash_port": 8504 "eosdash_port": 8504
}, },
"weather": { "weather": {