From d5542a3997bef21f1f3d8d1476c0de53f3d033d9 Mon Sep 17 00:00:00 2001 From: Mike Degatano <michael.degatano@gmail.com> Date: Thu, 13 Mar 2025 20:13:00 +0000 Subject: [PATCH 1/3] Fix mypy issues in addons --- supervisor/addons/addon.py | 48 +++++--- supervisor/addons/build.py | 11 +- supervisor/addons/manager.py | 2 +- supervisor/addons/model.py | 4 +- supervisor/addons/options.py | 8 +- supervisor/api/addons.py | 3 +- supervisor/api/backups.py | 4 +- supervisor/backups/manager.py | 16 ++- supervisor/core.py | 4 +- supervisor/coresys.py | 10 +- supervisor/hardware/monitor.py | 2 +- supervisor/homeassistant/module.py | 4 +- supervisor/host/apparmor.py | 12 +- supervisor/jobs/decorator.py | 2 +- supervisor/os/manager.py | 4 +- supervisor/plugins/audio.py | 8 +- supervisor/plugins/dns.py | 16 ++- .../resolution/checks/supervisor_trust.py | 2 +- supervisor/resolution/evaluate.py | 2 +- supervisor/resolution/evaluations/base.py | 2 +- .../resolution/evaluations/container.py | 2 +- .../resolution/evaluations/source_mods.py | 4 +- supervisor/resolution/module.py | 44 +++---- supervisor/store/data.py | 4 +- supervisor/supervisor.py | 4 +- tests/api/test_resolution.py | 42 ++++--- tests/jobs/test_job_decorator.py | 2 +- tests/misc/test_filter_data.py | 4 +- .../fixup/test_addon_execute_remove.py | 20 ++-- tests/resolution/fixup/test_fixup.py | 4 +- .../fixup/test_store_execute_reload.py | 8 +- .../fixup/test_store_execute_remove.py | 12 +- .../fixup/test_store_execute_reset.py | 8 +- .../fixup/test_system_adopt_data_disk.py | 40 ++++--- .../fixup/test_system_clear_full_backup.py | 4 +- .../fixup/test_system_create_full_backup.py | 4 +- .../fixup/test_system_execute_integrity.py | 12 +- .../fixup/test_system_execute_reboot.py | 6 +- .../fixup/test_system_rename_data_disk.py | 30 +++-- tests/resolution/test_resolution_manager.py | 108 +++++++++++------- 40 files changed, 307 insertions(+), 219 deletions(-) diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index e58dbf9a509..212267d9371 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -18,7 +18,7 @@ from typing import Any, Final import aiohttp -from awesomeversion import AwesomeVersionCompareException +from awesomeversion import AwesomeVersion, AwesomeVersionCompareException from deepmerge import Merger from securetar import AddFileError, atomic_contents_add, secure_path import voluptuous as vol @@ -285,28 +285,28 @@ def is_detached(self) -> bool: @property def with_icon(self) -> bool: """Return True if an icon exists.""" - if self.is_detached: + if self.is_detached or not self.addon_store: return super().with_icon return self.addon_store.with_icon @property def with_logo(self) -> bool: """Return True if a logo exists.""" - if self.is_detached: + if self.is_detached or not self.addon_store: return super().with_logo return self.addon_store.with_logo @property def with_changelog(self) -> bool: """Return True if a changelog exists.""" - if self.is_detached: + if self.is_detached or not self.addon_store: return super().with_changelog return self.addon_store.with_changelog @property def with_documentation(self) -> bool: """Return True if a documentation exists.""" - if self.is_detached: + if self.is_detached or not self.addon_store: return super().with_documentation return self.addon_store.with_documentation @@ -316,7 +316,7 @@ def available(self) -> bool: return self._available(self.data_store) @property - def version(self) -> str | None: + def version(self) -> AwesomeVersion: """Return installed version.""" return self.persist[ATTR_VERSION] @@ -464,7 +464,7 @@ def ingress_entry(self) -> str | None: return None @property - def latest_version(self) -> str: + def latest_version(self) -> AwesomeVersion: """Return version of add-on.""" return self.data_store[ATTR_VERSION] @@ -518,9 +518,8 @@ def ingress_url(self) -> str | None: def webui(self) -> str | None: """Return URL to webui or None.""" url = super().webui - if not url: + if not url or not (webui := RE_WEBUI.match(url)): return None - webui = RE_WEBUI.match(url) # extract arguments t_port = webui.group("t_port") @@ -675,10 +674,9 @@ async def save_persist(self) -> None: async def watchdog_application(self) -> bool: """Return True if application is running.""" - url = super().watchdog - if not url: + url = self.watchdog_url + if not url or not (application := RE_WATCHDOG.match(url)): return True - application = RE_WATCHDOG.match(url) # extract arguments t_port = int(application.group("t_port")) @@ -687,8 +685,10 @@ async def watchdog_application(self) -> bool: s_suffix = application.group("s_suffix") or "" # search host port for this docker port - if self.host_network: - port = self.ports.get(f"{t_port}/tcp", t_port) + if self.host_network and self.ports: + port = self.ports.get(f"{t_port}/tcp") + if port is None: + port = t_port else: port = t_port @@ -777,6 +777,9 @@ async def _check_ingress_port(self): ) async def install(self) -> None: """Install and setup this addon.""" + if not self.addon_store: + raise AddonsError("Missing from store, cannot install!") + await self.sys_addons.data.install(self.addon_store) await self.load() @@ -880,6 +883,9 @@ async def update(self) -> asyncio.Task | None: Returns a Task that completes when addon has state 'started' (see start) if it was running. Else nothing is returned. """ + if not self.addon_store: + raise AddonsError("Missing from store, cannot update!") + old_image = self.image # Cache data to prevent races with other updates to global store = self.addon_store.clone() @@ -936,7 +942,9 @@ async def rebuild(self) -> asyncio.Task | None: except DockerError as err: raise AddonsError() from err - await self.sys_addons.data.update(self.addon_store) + if self.addon_store: + await self.sys_addons.data.update(self.addon_store) + await self._check_ingress_port() _LOGGER.info("Add-on '%s' successfully rebuilt", self.slug) @@ -965,7 +973,9 @@ def write_pulse_config(): await self.sys_run_in_executor(write_pulse_config) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) _LOGGER.error( "Add-on %s can't write pulse/client.config: %s", self.slug, err ) @@ -1324,7 +1334,7 @@ def _addon_backup( arcname="config", ) - wait_for_start: Awaitable[None] | None = None + wait_for_start: asyncio.Task | None = None data = { ATTR_USER: self.persist, @@ -1370,7 +1380,7 @@ async def restore(self, tar_file: tarfile.TarFile) -> asyncio.Task | None: Returns a Task that completes when addon has state 'started' (see start) if addon is started after restore. Else nothing is returned. """ - wait_for_start: Awaitable[None] | None = None + wait_for_start: asyncio.Task | None = None # Extract backup def _extract_tarfile() -> tuple[TemporaryDirectory, dict[str, Any]]: @@ -1594,6 +1604,6 @@ async def watchdog_container(self, event: DockerContainerStateEvent) -> None: def refresh_path_cache(self) -> Awaitable[None]: """Refresh cache of existing paths.""" - if self.is_detached: + if self.is_detached or not self.addon_store: return super().refresh_path_cache() return self.addon_store.refresh_path_cache() diff --git a/supervisor/addons/build.py b/supervisor/addons/build.py index ce49cc9f5e9..63c1165e614 100644 --- a/supervisor/addons/build.py +++ b/supervisor/addons/build.py @@ -4,7 +4,7 @@ from functools import cached_property from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from awesomeversion import AwesomeVersion @@ -15,6 +15,7 @@ ATTR_SQUASH, FILE_SUFFIX_CONFIGURATION, META_ADDON, + CpuArch, ) from ..coresys import CoreSys, CoreSysAttributes from ..docker.interface import MAP_ARCH @@ -23,7 +24,7 @@ from .validate import SCHEMA_BUILD_CONFIG if TYPE_CHECKING: - from . import AnyAddon + from .manager import AnyAddon class AddonBuild(FileConfiguration, CoreSysAttributes): @@ -63,7 +64,7 @@ async def save_data(self): @cached_property def arch(self) -> str: """Return arch of the add-on.""" - return self.sys_arch.match(self.addon.arch) + return self.sys_arch.match([self.addon.arch]) @property def base_image(self) -> str: @@ -126,14 +127,14 @@ def get_docker_args(self, version: AwesomeVersion, image: str | None = None): Must be run in executor. """ - args = { + args: dict[str, Any] = { "path": str(self.addon.path_location), "tag": f"{image or self.addon.image}:{version!s}", "dockerfile": str(self.get_dockerfile()), "pull": True, "forcerm": not self.sys_dev, "squash": self.squash, - "platform": MAP_ARCH[self.arch], + "platform": MAP_ARCH[CpuArch[self.arch]], "labels": { "io.hass.version": version, "io.hass.arch": self.arch, diff --git a/supervisor/addons/manager.py b/supervisor/addons/manager.py index 56eae30ed66..fa8c0ef9b10 100644 --- a/supervisor/addons/manager.py +++ b/supervisor/addons/manager.py @@ -313,7 +313,7 @@ async def restore( if slug not in self.local: _LOGGER.debug("Add-on %s is not local available for restore", slug) addon = Addon(self.coresys, slug) - had_ingress = False + had_ingress: bool | None = False else: _LOGGER.debug("Add-on %s is local available for restore", slug) addon = self.local[slug] diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index 4ebd4599774..d4a785a4a89 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -294,7 +294,7 @@ def webui(self) -> str | None: return self.data.get(ATTR_WEBUI) @property - def watchdog(self) -> str | None: + def watchdog_url(self) -> str | None: """Return URL to for watchdog or None.""" return self.data.get(ATTR_WATCHDOG) @@ -606,7 +606,7 @@ def schema(self) -> AddonOptions: return AddonOptions(self.coresys, raw_schema, self.name, self.slug) @property - def schema_ui(self) -> list[dict[any, any]] | None: + def schema_ui(self) -> list[dict[Any, Any]] | None: """Create a UI schema for add-on options.""" raw_schema = self.data[ATTR_SCHEMA] diff --git a/supervisor/addons/options.py b/supervisor/addons/options.py index be27ecc47fb..7ae4b312d01 100644 --- a/supervisor/addons/options.py +++ b/supervisor/addons/options.py @@ -137,7 +137,7 @@ def _single_validate(self, typ: str, value: Any, key: str): ) from None # prepare range - range_args = {} + range_args: dict[str, Any] = {} for group_name in _SCHEMA_LENGTH_PARTS: group_value = match.group(group_name) if group_value: @@ -390,14 +390,14 @@ def _nested_ui_dict( multiple: bool = False, ) -> None: """UI nested dict items.""" - ui_node = { + ui_node: dict[str, Any] = { "name": key, "type": "schema", "optional": True, "multiple": multiple, } - nested_schema = [] + nested_schema: list[dict[str, Any]] = [] for c_key, c_value in option_dict.items(): # Nested? if isinstance(c_value, list): @@ -413,7 +413,7 @@ def _create_device_filter(str_filter: str) -> dict[str, Any]: """Generate device Filter.""" raw_filter = dict(value.split("=") for value in str_filter.split(";")) - clean_filter = {} + clean_filter: dict[str, Any] = {} for key, value in raw_filter.items(): if key == "subsystem": clean_filter[key] = UdevSubsystem(value) diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index 0a2e6bef879..a0524802cf5 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -10,7 +10,6 @@ from voluptuous.humanize import humanize_error from ..addons.addon import Addon -from ..addons.manager import AnyAddon from ..addons.utils import rating_security from ..const import ( ATTR_ADDONS, @@ -204,7 +203,7 @@ async def reload(self, request: web.Request) -> None: async def info(self, request: web.Request) -> dict[str, Any]: """Return add-on information.""" - addon: AnyAddon = self.get_addon_for_request(request) + addon: Addon = self.get_addon_for_request(request) data = { ATTR_NAME: addon.name, diff --git a/supervisor/api/backups.py b/supervisor/api/backups.py index d7f067ba7a4..43de22d00af 100644 --- a/supervisor/api/backups.py +++ b/supervisor/api/backups.py @@ -558,7 +558,9 @@ def close_backup_file() -> None: LOCATION_CLOUD_BACKUP, None, }: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) _LOGGER.error("Can't write new backup file: %s", err) return False diff --git a/supervisor/backups/manager.py b/supervisor/backups/manager.py index 8a2d3d80c00..f367b473ea9 100644 --- a/supervisor/backups/manager.py +++ b/supervisor/backups/manager.py @@ -196,7 +196,9 @@ def find_backups() -> list[Path]: self.sys_config.path_backup, self.sys_config.path_core_backup, }: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) _LOGGER.error("Could not list backups from %s: %s", path.as_posix(), err) return [] @@ -350,7 +352,9 @@ async def remove( None, LOCATION_CLOUD_BACKUP, }: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) raise BackupError(msg, _LOGGER.error) from err # If backup has been removed from all locations, remove it from cache @@ -404,7 +408,9 @@ def copy_to_additional_locations() -> dict[str | None, Path]: copy_to_additional_locations ) except BackupDataDiskBadMessageError: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) raise backup.all_locations.update( @@ -445,7 +451,9 @@ async def import_backup( await self.sys_run_in_executor(backup.tarfile.rename, tar_file) except OSError as err: if err.errno == errno.EBADMSG and location in {LOCATION_CLOUD_BACKUP, None}: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) _LOGGER.error("Can't move backup file to storage: %s", err) return None diff --git a/supervisor/core.py b/supervisor/core.py index 19795365b11..fdc9952efe2 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -114,7 +114,7 @@ async def connect(self): self.sys_resolution.create_issue( IssueType.UPDATE_ROLLBACK, ContextType.SUPERVISOR ) - self.sys_resolution.unhealthy = UnhealthyReason.SUPERVISOR + self.sys_resolution.add_unhealthy_reason(UnhealthyReason.SUPERVISOR) # Fix wrong version in config / avoid boot loop on OS self.sys_config.version = self.sys_supervisor.version @@ -177,7 +177,7 @@ async def setup(self): _LOGGER.critical( "Fatal error happening on load Task %s: %s", setup_task, err ) - self.sys_resolution.unhealthy = UnhealthyReason.SETUP + self.sys_resolution.add_unhealthy_reason(UnhealthyReason.SETUP) await async_capture_exception(err) # Set OS Agent diagnostics if needed diff --git a/supervisor/coresys.py b/supervisor/coresys.py index a7d6f0000a5..2e3fcbe78ae 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -807,7 +807,7 @@ def now(self) -> datetime: return self.coresys.now() def sys_run_in_executor( - self, funct: Callable[..., T], *args: tuple[Any], **kwargs: dict[str, Any] + self, funct: Callable[..., T], *args, **kwargs ) -> Coroutine[Any, Any, T]: """Add a job to the executor pool.""" return self.coresys.run_in_executor(funct, *args, **kwargs) @@ -820,8 +820,8 @@ def sys_call_later( self, delay: float, funct: Callable[..., Coroutine[Any, Any, T]], - *args: tuple[Any], - **kwargs: dict[str, Any], + *args, + **kwargs, ) -> asyncio.TimerHandle: """Start a task after a delay.""" return self.coresys.call_later(delay, funct, *args, **kwargs) @@ -830,8 +830,8 @@ def sys_call_at( self, when: datetime, funct: Callable[..., Coroutine[Any, Any, T]], - *args: tuple[Any], - **kwargs: dict[str, Any], + *args, + **kwargs, ) -> asyncio.TimerHandle: """Start a task at the specified datetime.""" return self.coresys.call_at(when, funct, *args, **kwargs) diff --git a/supervisor/hardware/monitor.py b/supervisor/hardware/monitor.py index 4d96e0891af..3517dacca96 100644 --- a/supervisor/hardware/monitor.py +++ b/supervisor/hardware/monitor.py @@ -40,7 +40,7 @@ async def load(self) -> None: ), ) except OSError: - self.sys_resolution.unhealthy = UnhealthyReason.PRIVILEGED + self.sys_resolution.add_unhealthy_reason(UnhealthyReason.PRIVILEGED) _LOGGER.critical("Not privileged to run udev monitor!") else: self.observer.start() diff --git a/supervisor/homeassistant/module.py b/supervisor/homeassistant/module.py index 4d782df3919..062ab2b2794 100644 --- a/supervisor/homeassistant/module.py +++ b/supervisor/homeassistant/module.py @@ -329,7 +329,9 @@ def write_pulse_config(): await self.sys_run_in_executor(write_pulse_config) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) _LOGGER.error("Home Assistant can't write pulse/client.config: %s", err) else: _LOGGER.info("Update pulse/client.config: %s", self.path_pulse) diff --git a/supervisor/host/apparmor.py b/supervisor/host/apparmor.py index 25dde37e6af..f7bed7e5bb4 100644 --- a/supervisor/host/apparmor.py +++ b/supervisor/host/apparmor.py @@ -90,7 +90,9 @@ async def load_profile(self, profile_name: str, profile_file: Path) -> None: await self.sys_run_in_executor(shutil.copyfile, profile_file, dest_profile) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) raise HostAppArmorError( f"Can't copy {profile_file}: {err}", _LOGGER.error ) from err @@ -115,7 +117,9 @@ async def remove_profile(self, profile_name: str) -> None: await self.sys_run_in_executor(profile_file.unlink) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) raise HostAppArmorError( f"Can't remove profile: {err}", _LOGGER.error ) from err @@ -131,7 +135,9 @@ def backup_profile(self, profile_name: str, backup_file: Path) -> None: shutil.copy(profile_file, backup_file) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) raise HostAppArmorError( f"Can't backup profile {profile_name}: {err}", _LOGGER.error ) from err diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index 033cda757bd..07db1eacd29 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -35,7 +35,7 @@ def __init__( name: str, conditions: list[JobCondition] | None = None, cleanup: bool = True, - on_condition: JobException | None = None, + on_condition: type[JobException] | None = None, limit: JobExecutionLimit | None = None, throttle_period: timedelta | Callable[[CoreSys, datetime, list[datetime] | None], timedelta] diff --git a/supervisor/os/manager.py b/supervisor/os/manager.py index 4906544e619..c56681db327 100644 --- a/supervisor/os/manager.py +++ b/supervisor/os/manager.py @@ -237,7 +237,9 @@ async def _download_raucb(self, url: str, raucb: Path) -> None: except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) raise HassOSUpdateError( f"Can't write OTA file: {err!s}", _LOGGER.error ) from err diff --git a/supervisor/plugins/audio.py b/supervisor/plugins/audio.py index 610f49767c5..3ee088ab6b0 100644 --- a/supervisor/plugins/audio.py +++ b/supervisor/plugins/audio.py @@ -94,7 +94,9 @@ async def load(self) -> None: ) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) _LOGGER.error("Can't read pulse-client.tmpl: %s", err) @@ -111,7 +113,9 @@ def setup_default_asound(): await self.sys_run_in_executor(setup_default_asound) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) _LOGGER.error("Can't create default asound: %s", err) @Job( diff --git a/supervisor/plugins/dns.py b/supervisor/plugins/dns.py index 853f3193d20..48d80414ef4 100644 --- a/supervisor/plugins/dns.py +++ b/supervisor/plugins/dns.py @@ -156,7 +156,9 @@ async def load(self) -> None: ) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) _LOGGER.error("Can't read resolve.tmpl: %s", err) try: @@ -165,7 +167,9 @@ async def load(self) -> None: ) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) _LOGGER.error("Can't read hosts.tmpl: %s", err) await self._init_hosts() @@ -343,7 +347,9 @@ async def write_hosts(self) -> None: ) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) raise CoreDNSError(f"Can't update hosts: {err}", _LOGGER.error) from err async def add_host( @@ -432,7 +438,9 @@ async def _write_resolv(self, resolv_conf: Path) -> None: await self.sys_run_in_executor(resolv_conf.write_text, data) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) _LOGGER.warning("Can't write/update %s: %s", resolv_conf, err) return diff --git a/supervisor/resolution/checks/supervisor_trust.py b/supervisor/resolution/checks/supervisor_trust.py index f779be0380f..b4be4329c8a 100644 --- a/supervisor/resolution/checks/supervisor_trust.py +++ b/supervisor/resolution/checks/supervisor_trust.py @@ -30,7 +30,7 @@ async def run_check(self) -> None: try: await self.sys_supervisor.check_trust() except CodeNotaryUntrusted: - self.sys_resolution.unhealthy = UnhealthyReason.UNTRUSTED + self.sys_resolution.add_unhealthy_reason(UnhealthyReason.UNTRUSTED) self.sys_resolution.create_issue(IssueType.TRUST, ContextType.SUPERVISOR) except CodeNotaryError: pass diff --git a/supervisor/resolution/evaluate.py b/supervisor/resolution/evaluate.py index 8929ec2696f..97436d0281e 100644 --- a/supervisor/resolution/evaluate.py +++ b/supervisor/resolution/evaluate.py @@ -67,6 +67,6 @@ async def evaluate_system(self) -> None: await async_capture_exception(err) if any(reason in self.sys_resolution.unsupported for reason in UNHEALTHY): - self.sys_resolution.unhealthy = UnhealthyReason.DOCKER + self.sys_resolution.add_unhealthy_reason(UnhealthyReason.DOCKER) _LOGGER.info("System evaluation complete") diff --git a/supervisor/resolution/evaluations/base.py b/supervisor/resolution/evaluations/base.py index 89d5acbe766..791a701bf5b 100644 --- a/supervisor/resolution/evaluations/base.py +++ b/supervisor/resolution/evaluations/base.py @@ -23,7 +23,7 @@ async def __call__(self) -> None: return if await self.evaluate(): if self.reason not in self.sys_resolution.unsupported: - self.sys_resolution.unsupported = self.reason + self.sys_resolution.add_unsupported_reason(self.reason) _LOGGER.warning( "%s (more-info: https://www.home-assistant.io/more-info/unsupported/%s)", self.on_failure, diff --git a/supervisor/resolution/evaluations/container.py b/supervisor/resolution/evaluations/container.py index b4184d13b06..f18f1fa22fa 100644 --- a/supervisor/resolution/evaluations/container.py +++ b/supervisor/resolution/evaluations/container.py @@ -101,6 +101,6 @@ async def evaluate(self) -> bool: "Found image in unhealthy image list '%s' on the host", image_name, ) - self.sys_resolution.unhealthy = UnhealthyReason.DOCKER + self.sys_resolution.add_unhealthy_reason(UnhealthyReason.DOCKER) return len(self._images) != 0 diff --git a/supervisor/resolution/evaluations/source_mods.py b/supervisor/resolution/evaluations/source_mods.py index ee6411ee835..8bca674a6f8 100644 --- a/supervisor/resolution/evaluations/source_mods.py +++ b/supervisor/resolution/evaluations/source_mods.py @@ -51,7 +51,9 @@ async def evaluate(self) -> bool: ) except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) self.sys_resolution.create_issue( IssueType.CORRUPT_FILESYSTEM, ContextType.SYSTEM diff --git a/supervisor/resolution/module.py b/supervisor/resolution/module.py index 515a8d67ac0..44d1e4dffc2 100644 --- a/supervisor/resolution/module.py +++ b/supervisor/resolution/module.py @@ -87,28 +87,12 @@ def issues(self) -> list[Issue]: """Return a list of issues.""" return self._issues - @issues.setter - def issues(self, issue: Issue) -> None: - """Add issues.""" - if issue in self._issues: - return - _LOGGER.info( - "Create new issue %s - %s / %s", issue.type, issue.context, issue.reference - ) - self._issues.append(issue) - - # Event on issue creation - self.sys_homeassistant.websocket.supervisor_event( - WSEvent.ISSUE_CHANGED, self._make_issue_message(issue) - ) - @property def suggestions(self) -> list[Suggestion]: """Return a list of suggestions that can handled.""" return self._suggestions - @suggestions.setter - def suggestions(self, suggestion: Suggestion) -> None: + def add_suggestion(self, suggestion: Suggestion) -> None: """Add suggestion.""" if suggestion in self._suggestions: return @@ -132,8 +116,7 @@ def unsupported(self) -> list[UnsupportedReason]: """Return a list of unsupported reasons.""" return self._unsupported - @unsupported.setter - def unsupported(self, reason: UnsupportedReason) -> None: + def add_unsupported_reason(self, reason: UnsupportedReason) -> None: """Add a reason for unsupported.""" if reason not in self._unsupported: self._unsupported.append(reason) @@ -144,12 +127,11 @@ def unsupported(self, reason: UnsupportedReason) -> None: @property def unhealthy(self) -> list[UnhealthyReason]: - """Return a list of unsupported reasons.""" + """Return a list of unhealthy reasons.""" return self._unhealthy - @unhealthy.setter - def unhealthy(self, reason: UnhealthyReason) -> None: - """Add a reason for unsupported.""" + def add_unhealthy_reason(self, reason: UnhealthyReason) -> None: + """Add a reason for unhrealthy.""" if reason not in self._unhealthy: self._unhealthy.append(reason) self.sys_homeassistant.websocket.supervisor_event( @@ -198,11 +180,21 @@ def add_issue( """Add an issue and suggestions.""" if suggestions: for suggestion in suggestions: - self.suggestions = Suggestion( - suggestion, issue.context, issue.reference + self.add_suggestion( + Suggestion(suggestion, issue.context, issue.reference) ) - self.issues = issue + if issue in self._issues: + return + _LOGGER.info( + "Create new issue %s - %s / %s", issue.type, issue.context, issue.reference + ) + self._issues.append(issue) + + # Event on issue creation + self.sys_homeassistant.websocket.supervisor_event( + WSEvent.ISSUE_CHANGED, self._make_issue_message(issue) + ) async def load(self): """Load the resoulution manager.""" diff --git a/supervisor/store/data.py b/supervisor/store/data.py index c7fa3fbc1ff..27a5606c3b3 100644 --- a/supervisor/store/data.py +++ b/supervisor/store/data.py @@ -179,7 +179,9 @@ def _get_addons_list() -> list[Path]: except OSError as err: suggestion = None if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) elif path.stem != StoreType.LOCAL: suggestion = [SuggestionType.EXECUTE_RESET] self.sys_resolution.create_issue( diff --git a/supervisor/supervisor.py b/supervisor/supervisor.py index 4e0d01b96e2..9ddc7829c5f 100644 --- a/supervisor/supervisor.py +++ b/supervisor/supervisor.py @@ -174,7 +174,9 @@ def write_profile() -> Path: except OSError as err: if err.errno == errno.EBADMSG: - self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE + self.sys_resolution.add_unhealthy_reason( + UnhealthyReason.OSERROR_BAD_MESSAGE + ) raise SupervisorAppArmorError( f"Can't write temporary profile: {err!s}", _LOGGER.error ) from err diff --git a/tests/api/test_resolution.py b/tests/api/test_resolution.py index 9fa69791860..eef1761012b 100644 --- a/tests/api/test_resolution.py +++ b/tests/api/test_resolution.py @@ -27,9 +27,9 @@ @pytest.mark.asyncio async def test_api_resolution_base(coresys: CoreSys, api_client: TestClient): """Test resolution manager api.""" - coresys.resolution.unsupported = UnsupportedReason.OS - coresys.resolution.suggestions = Suggestion( - SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM + coresys.resolution.add_unsupported_reason(UnsupportedReason.OS) + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM) ) coresys.resolution.create_issue(IssueType.FREE_SPACE, ContextType.SYSTEM) @@ -47,8 +47,8 @@ async def test_api_resolution_dismiss_suggestion( coresys: CoreSys, api_client: TestClient ): """Test resolution manager suggestion apply api.""" - coresys.resolution.suggestions = clear_backup = Suggestion( - SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM + coresys.resolution.add_suggestion( + clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM) ) assert coresys.resolution.suggestions[-1].type == SuggestionType.CLEAR_FULL_BACKUP @@ -61,11 +61,13 @@ async def test_api_resolution_apply_suggestion( coresys: CoreSys, api_client: TestClient ): """Test resolution manager suggestion apply api.""" - coresys.resolution.suggestions = clear_backup = Suggestion( - SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM + coresys.resolution.add_suggestion( + clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM) ) - coresys.resolution.suggestions = create_backup = Suggestion( - SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM + coresys.resolution.add_suggestion( + create_backup := Suggestion( + SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM + ) ) mock_backups = AsyncMock() @@ -89,8 +91,8 @@ async def test_api_resolution_apply_suggestion( @pytest.mark.asyncio async def test_api_resolution_dismiss_issue(coresys: CoreSys, api_client: TestClient): """Test resolution manager issue apply api.""" - coresys.resolution.issues = updated_failed = Issue( - IssueType.UPDATE_FAILED, ContextType.SYSTEM + coresys.resolution.add_issue( + updated_failed := Issue(IssueType.UPDATE_FAILED, ContextType.SYSTEM) ) assert coresys.resolution.issues[-1].type == IssueType.UPDATE_FAILED @@ -101,7 +103,7 @@ async def test_api_resolution_dismiss_issue(coresys: CoreSys, api_client: TestCl @pytest.mark.asyncio async def test_api_resolution_unhealthy(coresys: CoreSys, api_client: TestClient): """Test resolution manager api.""" - coresys.resolution.unhealthy = UnhealthyReason.DOCKER + coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER) resp = await api_client.get("/resolution/info") result = await resp.json() @@ -142,8 +144,8 @@ async def test_api_resolution_suggestions_for_issue( coresys: CoreSys, api_client: TestClient ): """Test getting suggestions that fix an issue.""" - coresys.resolution.issues = corrupt_repo = Issue( - IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "repo_1" + coresys.resolution.add_issue( + corrupt_repo := Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "repo_1") ) resp = await api_client.get(f"/resolution/issue/{corrupt_repo.uuid}/suggestions") @@ -151,11 +153,15 @@ async def test_api_resolution_suggestions_for_issue( assert result["data"]["suggestions"] == [] - coresys.resolution.suggestions = execute_reset = Suggestion( - SuggestionType.EXECUTE_RESET, ContextType.STORE, "repo_1" + coresys.resolution.add_suggestion( + execute_reset := Suggestion( + SuggestionType.EXECUTE_RESET, ContextType.STORE, "repo_1" + ) ) - coresys.resolution.suggestions = execute_remove = Suggestion( - SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_1" + coresys.resolution.add_suggestion( + execute_remove := Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_1" + ) ) resp = await api_client.get(f"/resolution/issue/{corrupt_repo.uuid}/suggestions") diff --git a/tests/jobs/test_job_decorator.py b/tests/jobs/test_job_decorator.py index 31802884a36..e8f6be3b9e8 100644 --- a/tests/jobs/test_job_decorator.py +++ b/tests/jobs/test_job_decorator.py @@ -50,7 +50,7 @@ async def execute(self): test = TestClass(coresys) assert await test.execute() - coresys.resolution.unhealthy = UnhealthyReason.DOCKER + coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER) assert not await test.execute() assert "blocked from execution, system is not healthy - docker" in caplog.text diff --git a/tests/misc/test_filter_data.py b/tests/misc/test_filter_data.py index e60ed9d272e..c51438524c9 100644 --- a/tests/misc/test_filter_data.py +++ b/tests/misc/test_filter_data.py @@ -96,7 +96,7 @@ def test_diagnostics_disabled(coresys): def test_not_supported(coresys): """Test if not supported.""" coresys.config.diagnostics = True - coresys.resolution.unsupported = UnsupportedReason.DOCKER_VERSION + coresys.resolution.add_unsupported_reason(UnsupportedReason.DOCKER_VERSION) assert filter_data(coresys, SAMPLE_EVENT, {}) is None @@ -215,7 +215,7 @@ async def test_unhealthy_on_report(coresys): coresys.config.diagnostics = True await coresys.core.set_state(CoreState.RUNNING) - coresys.resolution.unhealthy = UnhealthyReason.DOCKER + coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER) with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))): event = filter_data(coresys, SAMPLE_EVENT, {}) diff --git a/tests/resolution/fixup/test_addon_execute_remove.py b/tests/resolution/fixup/test_addon_execute_remove.py index 60376fc6d06..56a6fc2fe42 100644 --- a/tests/resolution/fixup/test_addon_execute_remove.py +++ b/tests/resolution/fixup/test_addon_execute_remove.py @@ -15,15 +15,19 @@ async def test_fixup(coresys: CoreSys, install_addon_ssh: Addon): assert addon_execute_remove.auto is False - coresys.resolution.suggestions = Suggestion( - SuggestionType.EXECUTE_REMOVE, - ContextType.ADDON, - reference=install_addon_ssh.slug, + coresys.resolution.add_suggestion( + Suggestion( + SuggestionType.EXECUTE_REMOVE, + ContextType.ADDON, + reference=install_addon_ssh.slug, + ) ) - coresys.resolution.issues = Issue( - IssueType.DETACHED_ADDON_REMOVED, - ContextType.ADDON, - reference=install_addon_ssh.slug, + coresys.resolution.add_issue( + Issue( + IssueType.DETACHED_ADDON_REMOVED, + ContextType.ADDON, + reference=install_addon_ssh.slug, + ) ) with patch.object(Addon, "uninstall") as uninstall: diff --git a/tests/resolution/fixup/test_fixup.py b/tests/resolution/fixup/test_fixup.py index 5e0e2285e67..03e123727e9 100644 --- a/tests/resolution/fixup/test_fixup.py +++ b/tests/resolution/fixup/test_fixup.py @@ -28,8 +28,8 @@ async def test_check_autofix(coresys: CoreSys): "system_create_full_backup" ].process_fixup.assert_not_called() - coresys.resolution.suggestions = Suggestion( - SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM) ) with patch( "supervisor.resolution.fixups.system_create_full_backup.FixupSystemCreateFullBackup.auto", diff --git a/tests/resolution/fixup/test_store_execute_reload.py b/tests/resolution/fixup/test_store_execute_reload.py index 13ce93bf487..a727350888b 100644 --- a/tests/resolution/fixup/test_store_execute_reload.py +++ b/tests/resolution/fixup/test_store_execute_reload.py @@ -15,11 +15,11 @@ async def test_fixup(coresys: CoreSys): assert store_execute_reload.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.EXECUTE_RELOAD, ContextType.STORE, reference="test" + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.EXECUTE_RELOAD, ContextType.STORE, reference="test") ) - coresys.resolution.issues = Issue( - IssueType.FATAL_ERROR, ContextType.STORE, reference="test" + coresys.resolution.add_issue( + Issue(IssueType.FATAL_ERROR, ContextType.STORE, reference="test") ) mock_repositorie = AsyncMock() diff --git a/tests/resolution/fixup/test_store_execute_remove.py b/tests/resolution/fixup/test_store_execute_remove.py index 552a46e2aa7..980d1eb530e 100644 --- a/tests/resolution/fixup/test_store_execute_remove.py +++ b/tests/resolution/fixup/test_store_execute_remove.py @@ -16,11 +16,15 @@ async def test_fixup(coresys: CoreSys, repository: Repository): assert store_execute_remove.auto is False - coresys.resolution.suggestions = Suggestion( - SuggestionType.EXECUTE_REMOVE, ContextType.STORE, reference=repository.slug + coresys.resolution.add_suggestion( + Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, reference=repository.slug + ) ) - coresys.resolution.issues = Issue( - IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference=repository.slug + coresys.resolution.add_issue( + Issue( + IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference=repository.slug + ) ) with patch.object(type(repository), "remove") as remove_repo: diff --git a/tests/resolution/fixup/test_store_execute_reset.py b/tests/resolution/fixup/test_store_execute_reset.py index 04015435e0b..4da27735133 100644 --- a/tests/resolution/fixup/test_store_execute_reset.py +++ b/tests/resolution/fixup/test_store_execute_reset.py @@ -18,11 +18,11 @@ async def test_fixup(coresys: CoreSys, tmp_path): assert store_execute_reset.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.EXECUTE_RESET, ContextType.STORE, reference="test" + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.EXECUTE_RESET, ContextType.STORE, reference="test") ) - coresys.resolution.issues = Issue( - IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference="test" + coresys.resolution.add_issue( + Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference="test") ) test_repo.mkdir() diff --git a/tests/resolution/fixup/test_system_adopt_data_disk.py b/tests/resolution/fixup/test_system_adopt_data_disk.py index 20e3027c4f7..1366cebcb51 100644 --- a/tests/resolution/fixup/test_system_adopt_data_disk.py +++ b/tests/resolution/fixup/test_system_adopt_data_disk.py @@ -85,11 +85,13 @@ async def test_fixup( assert not system_adopt_data_disk.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_suggestion( + Suggestion( + SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + ) ) - coresys.resolution.issues = Issue( - IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_issue( + Issue(IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1") ) udisks2_service.resolved_devices = [ ["/org/freedesktop/UDisks2/block_devices/sda1"], @@ -124,11 +126,13 @@ async def test_fixup_device_removed( assert not system_adopt_data_disk.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_suggestion( + Suggestion( + SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + ) ) - coresys.resolution.issues = Issue( - IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_issue( + Issue(IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1") ) udisks2_service.resolved_devices = [] @@ -159,11 +163,13 @@ async def test_fixup_reboot_failed( assert not system_adopt_data_disk.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_suggestion( + Suggestion( + SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + ) ) - coresys.resolution.issues = Issue( - IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_issue( + Issue(IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1") ) udisks2_service.resolved_devices = [ ["/org/freedesktop/UDisks2/block_devices/sda1"], @@ -209,11 +215,13 @@ async def test_fixup_disabled_data_disk( assert not system_adopt_data_disk.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_suggestion( + Suggestion( + SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + ) ) - coresys.resolution.issues = Issue( - IssueType.DISABLED_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_issue( + Issue(IssueType.DISABLED_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1") ) udisks2_service.resolved_devices = [ ["/org/freedesktop/UDisks2/block_devices/sda1"], diff --git a/tests/resolution/fixup/test_system_clear_full_backup.py b/tests/resolution/fixup/test_system_clear_full_backup.py index 710050f113f..6c4ae333f1e 100644 --- a/tests/resolution/fixup/test_system_clear_full_backup.py +++ b/tests/resolution/fixup/test_system_clear_full_backup.py @@ -17,8 +17,8 @@ async def test_fixup(coresys: CoreSys, backups: list[Backup]): assert not clear_full_backup.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM) ) newest_full_backup = coresys.backups._backups["sn4"] diff --git a/tests/resolution/fixup/test_system_create_full_backup.py b/tests/resolution/fixup/test_system_create_full_backup.py index ac017ca0f4a..313951a0320 100644 --- a/tests/resolution/fixup/test_system_create_full_backup.py +++ b/tests/resolution/fixup/test_system_create_full_backup.py @@ -17,8 +17,8 @@ async def test_fixup(coresys: CoreSys): assert not create_full_backup.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM) ) mock_backups = AsyncMock() diff --git a/tests/resolution/fixup/test_system_execute_integrity.py b/tests/resolution/fixup/test_system_execute_integrity.py index 8bc04e026a5..d90a94a15d7 100644 --- a/tests/resolution/fixup/test_system_execute_integrity.py +++ b/tests/resolution/fixup/test_system_execute_integrity.py @@ -22,10 +22,10 @@ async def test_fixup(coresys: CoreSys): assert system_execute_integrity.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.EXECUTE_INTEGRITY, ContextType.SYSTEM + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.EXECUTE_INTEGRITY, ContextType.SYSTEM) ) - coresys.resolution.issues = Issue(IssueType.TRUST, ContextType.SYSTEM) + coresys.resolution.add_issue(Issue(IssueType.TRUST, ContextType.SYSTEM)) coresys.security.integrity_check = AsyncMock( return_value=IntegrityResult( @@ -48,10 +48,10 @@ async def test_fixup_error(coresys: CoreSys): assert system_execute_integrity.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.EXECUTE_INTEGRITY, ContextType.SYSTEM + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.EXECUTE_INTEGRITY, ContextType.SYSTEM) ) - coresys.resolution.issues = Issue(IssueType.TRUST, ContextType.SYSTEM) + coresys.resolution.add_issue(Issue(IssueType.TRUST, ContextType.SYSTEM)) coresys.security.integrity_check = AsyncMock( return_value=IntegrityResult( diff --git a/tests/resolution/fixup/test_system_execute_reboot.py b/tests/resolution/fixup/test_system_execute_reboot.py index 2751cf3554f..084cfcde75d 100644 --- a/tests/resolution/fixup/test_system_execute_reboot.py +++ b/tests/resolution/fixup/test_system_execute_reboot.py @@ -20,10 +20,10 @@ async def test_fixup( system_execute_reboot = FixupSystemExecuteReboot(coresys) assert system_execute_reboot.auto is False - coresys.resolution.suggestions = Suggestion( - SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM) ) - coresys.resolution.issues = Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM) + coresys.resolution.add_issue(Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM)) await system_execute_reboot() diff --git a/tests/resolution/fixup/test_system_rename_data_disk.py b/tests/resolution/fixup/test_system_rename_data_disk.py index d2ad03fc00f..32b1e5f29b1 100644 --- a/tests/resolution/fixup/test_system_rename_data_disk.py +++ b/tests/resolution/fixup/test_system_rename_data_disk.py @@ -43,11 +43,13 @@ async def test_fixup(coresys: CoreSys, sda1_filesystem_service: FilesystemServic assert not system_rename_data_disk.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.RENAME_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_suggestion( + Suggestion( + SuggestionType.RENAME_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + ) ) - coresys.resolution.issues = Issue( - IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_issue( + Issue(IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1") ) await system_rename_data_disk() @@ -73,11 +75,13 @@ async def test_fixup_device_removed( assert not system_rename_data_disk.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.RENAME_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_suggestion( + Suggestion( + SuggestionType.RENAME_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + ) ) - coresys.resolution.issues = Issue( - IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_issue( + Issue(IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1") ) udisks2_service.resolved_devices = [] @@ -98,11 +102,13 @@ async def test_fixup_device_not_filesystem( assert not system_rename_data_disk.auto - coresys.resolution.suggestions = Suggestion( - SuggestionType.RENAME_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_suggestion( + Suggestion( + SuggestionType.RENAME_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + ) ) - coresys.resolution.issues = Issue( - IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1" + coresys.resolution.add_issue( + Issue(IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1") ) udisks2_service.resolved_devices = ["/org/freedesktop/UDisks2/block_devices/sda"] diff --git a/tests/resolution/test_resolution_manager.py b/tests/resolution/test_resolution_manager.py index 183412add28..607996dc572 100644 --- a/tests/resolution/test_resolution_manager.py +++ b/tests/resolution/test_resolution_manager.py @@ -22,7 +22,7 @@ def test_properies_unsupported(coresys: CoreSys): """Test resolution manager properties unsupported.""" assert coresys.core.supported - coresys.resolution.unsupported = UnsupportedReason.OS + coresys.resolution.add_unsupported_reason(UnsupportedReason.OS) assert not coresys.core.supported @@ -30,15 +30,15 @@ def test_properies_unhealthy(coresys: CoreSys): """Test resolution manager properties unhealthy.""" assert coresys.core.healthy - coresys.resolution.unhealthy = UnhealthyReason.SUPERVISOR + coresys.resolution.add_unhealthy_reason(UnhealthyReason.SUPERVISOR) assert not coresys.core.healthy @pytest.mark.asyncio async def test_resolution_dismiss_suggestion(coresys: CoreSys): """Test resolution manager suggestion apply api.""" - coresys.resolution.suggestions = clear_backup = Suggestion( - SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM + coresys.resolution.add_suggestion( + clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM) ) assert coresys.resolution.suggestions[-1].type == SuggestionType.CLEAR_FULL_BACKUP @@ -52,11 +52,13 @@ async def test_resolution_dismiss_suggestion(coresys: CoreSys): @pytest.mark.asyncio async def test_resolution_apply_suggestion(coresys: CoreSys): """Test resolution manager suggestion apply api.""" - coresys.resolution.suggestions = clear_backup = Suggestion( - SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM + coresys.resolution.add_suggestion( + clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM) ) - coresys.resolution.suggestions = create_backup = Suggestion( - SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM + coresys.resolution.add_suggestion( + create_backup := Suggestion( + SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM + ) ) mock_backups = AsyncMock() @@ -80,8 +82,8 @@ async def test_resolution_apply_suggestion(coresys: CoreSys): @pytest.mark.asyncio async def test_resolution_dismiss_issue(coresys: CoreSys): """Test resolution manager issue apply api.""" - coresys.resolution.issues = updated_failed = Issue( - IssueType.UPDATE_FAILED, ContextType.SYSTEM + coresys.resolution.add_issue( + updated_failed := Issue(IssueType.UPDATE_FAILED, ContextType.SYSTEM) ) assert coresys.resolution.issues[-1].type == IssueType.UPDATE_FAILED @@ -113,7 +115,7 @@ async def test_resolution_create_issue_suggestion(coresys: CoreSys): @pytest.mark.asyncio async def test_resolution_dismiss_unsupported(coresys: CoreSys): """Test resolution manager dismiss unsupported reason.""" - coresys.resolution.unsupported = UnsupportedReason.SOFTWARE + coresys.resolution.add_unsupported_reason(UnsupportedReason.SOFTWARE) coresys.resolution.dismiss_unsupported(UnsupportedReason.SOFTWARE) assert UnsupportedReason.SOFTWARE not in coresys.resolution.unsupported @@ -124,26 +126,32 @@ async def test_resolution_dismiss_unsupported(coresys: CoreSys): async def test_suggestions_for_issue(coresys: CoreSys): """Test getting suggestions that fix an issue.""" - coresys.resolution.issues = corrupt_repo = Issue( - IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test_repo" + coresys.resolution.add_issue( + corrupt_repo := Issue( + IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test_repo" + ) ) # Unrelated suggestions don't appear - coresys.resolution.suggestions = Suggestion( - SuggestionType.EXECUTE_RESET, ContextType.SUPERVISOR + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.EXECUTE_RESET, ContextType.SUPERVISOR) ) - coresys.resolution.suggestions = Suggestion( - SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "other_repo" + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "other_repo") ) assert coresys.resolution.suggestions_for_issue(corrupt_repo) == set() # Related suggestions do - coresys.resolution.suggestions = execute_remove = Suggestion( - SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo" + coresys.resolution.add_suggestion( + execute_remove := Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo" + ) ) - coresys.resolution.suggestions = execute_reset = Suggestion( - SuggestionType.EXECUTE_RESET, ContextType.STORE, "test_repo" + coresys.resolution.add_suggestion( + execute_reset := Suggestion( + SuggestionType.EXECUTE_RESET, ContextType.STORE, "test_repo" + ) ) assert coresys.resolution.suggestions_for_issue(corrupt_repo) == { @@ -154,24 +162,28 @@ async def test_suggestions_for_issue(coresys: CoreSys): async def test_issues_for_suggestion(coresys: CoreSys): """Test getting issues fixed by a suggestion.""" - coresys.resolution.suggestions = execute_reset = Suggestion( - SuggestionType.EXECUTE_RESET, ContextType.STORE, "test_repo" + coresys.resolution.add_suggestion( + execute_reset := Suggestion( + SuggestionType.EXECUTE_RESET, ContextType.STORE, "test_repo" + ) ) # Unrelated issues don't appear - coresys.resolution.issues = Issue(IssueType.FATAL_ERROR, ContextType.CORE) - coresys.resolution.issues = Issue( - IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "other_repo" + coresys.resolution.add_issue(Issue(IssueType.FATAL_ERROR, ContextType.CORE)) + coresys.resolution.add_issue( + Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "other_repo") ) assert coresys.resolution.issues_for_suggestion(execute_reset) == set() # Related issues do - coresys.resolution.issues = fatal_error = Issue( - IssueType.FATAL_ERROR, ContextType.STORE, "test_repo" + coresys.resolution.add_issue( + fatal_error := Issue(IssueType.FATAL_ERROR, ContextType.STORE, "test_repo") ) - coresys.resolution.issues = corrupt_repo = Issue( - IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test_repo" + coresys.resolution.add_issue( + corrupt_repo := Issue( + IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test_repo" + ) ) assert coresys.resolution.issues_for_suggestion(execute_reset) == { @@ -226,8 +238,10 @@ async def test_events_on_issue_changes(coresys: CoreSys, ha_ws_client: AsyncMock # Adding a suggestion that fixes the issue changes it ha_ws_client.async_send_command.reset_mock() - coresys.resolution.suggestions = execute_remove = Suggestion( - SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo" + coresys.resolution.add_suggestion( + execute_remove := Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo" + ) ) await asyncio.sleep(0) messages = [ @@ -270,14 +284,20 @@ async def test_events_on_issue_changes(coresys: CoreSys, ha_ws_client: AsyncMock async def test_resolution_apply_suggestion_multiple_copies(coresys: CoreSys): """Test resolution manager applies correct suggestion when has multiple that differ by reference.""" - coresys.resolution.suggestions = remove_store_1 = Suggestion( - SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_1" + coresys.resolution.add_suggestion( + remove_store_1 := Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_1" + ) ) - coresys.resolution.suggestions = remove_store_2 = Suggestion( - SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_2" + coresys.resolution.add_suggestion( + remove_store_2 := Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_2" + ) ) - coresys.resolution.suggestions = remove_store_3 = Suggestion( - SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_3" + coresys.resolution.add_suggestion( + remove_store_3 := Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_3" + ) ) await coresys.resolution.apply_suggestion(remove_store_2) @@ -294,7 +314,7 @@ async def test_events_on_unsupported_changed(coresys: CoreSys): ) as send_message: # Marking system as unsupported tells HA assert coresys.resolution.unsupported == [] - coresys.resolution.unsupported = UnsupportedReason.CONNECTIVITY_CHECK + coresys.resolution.add_unsupported_reason(UnsupportedReason.CONNECTIVITY_CHECK) await asyncio.sleep(0) assert coresys.resolution.unsupported == [UnsupportedReason.CONNECTIVITY_CHECK] send_message.assert_called_once_with( @@ -306,13 +326,13 @@ async def test_events_on_unsupported_changed(coresys: CoreSys): # Adding the same reason again does nothing send_message.reset_mock() - coresys.resolution.unsupported = UnsupportedReason.CONNECTIVITY_CHECK + coresys.resolution.add_unsupported_reason(UnsupportedReason.CONNECTIVITY_CHECK) await asyncio.sleep(0) assert coresys.resolution.unsupported == [UnsupportedReason.CONNECTIVITY_CHECK] send_message.assert_not_called() # Adding and removing additional reasons tells HA unsupported reasons changed - coresys.resolution.unsupported = UnsupportedReason.JOB_CONDITIONS + coresys.resolution.add_unsupported_reason(UnsupportedReason.JOB_CONDITIONS) await asyncio.sleep(0) assert coresys.resolution.unsupported == [ UnsupportedReason.CONNECTIVITY_CHECK, @@ -358,7 +378,7 @@ async def test_events_on_unhealthy_changed(coresys: CoreSys): ) as send_message: # Marking system as unhealthy tells HA assert coresys.resolution.unhealthy == [] - coresys.resolution.unhealthy = UnhealthyReason.DOCKER + coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER) await asyncio.sleep(0) assert coresys.resolution.unhealthy == [UnhealthyReason.DOCKER] send_message.assert_called_once_with( @@ -370,13 +390,13 @@ async def test_events_on_unhealthy_changed(coresys: CoreSys): # Adding the same reason again does nothing send_message.reset_mock() - coresys.resolution.unhealthy = UnhealthyReason.DOCKER + coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER) await asyncio.sleep(0) assert coresys.resolution.unhealthy == [UnhealthyReason.DOCKER] send_message.assert_not_called() # Adding an additional reason tells HA unhealthy reasons changed - coresys.resolution.unhealthy = UnhealthyReason.UNTRUSTED + coresys.resolution.add_unhealthy_reason(UnhealthyReason.UNTRUSTED) await asyncio.sleep(0) assert coresys.resolution.unhealthy == [ UnhealthyReason.DOCKER, From 9496317378df94c4e89169f9dc8a9d050d918d2d Mon Sep 17 00:00:00 2001 From: Mike Degatano <michael.degatano@gmail.com> Date: Mon, 17 Mar 2025 17:25:04 +0000 Subject: [PATCH 2/3] Fix mypy issues in api --- supervisor/addons/build.py | 3 +- supervisor/addons/manager.py | 1 + supervisor/api/__init__.py | 20 ++++++------- supervisor/api/addons.py | 41 +++++++++++++++------------ supervisor/api/audio.py | 6 ++-- supervisor/api/auth.py | 5 ++-- supervisor/api/backups.py | 38 +++++++++++++++---------- supervisor/api/discovery.py | 26 ++++++++++------- supervisor/api/hardware.py | 5 +++- supervisor/api/host.py | 9 +++--- supervisor/api/ingress.py | 17 +++++------ supervisor/api/jobs.py | 7 +++-- supervisor/api/middleware/security.py | 23 ++++++--------- supervisor/api/mounts.py | 17 ++++++----- supervisor/api/network.py | 20 ++++++++----- supervisor/api/proxy.py | 35 ++++++++++++----------- supervisor/api/resolution.py | 6 ++-- supervisor/api/services.py | 2 +- supervisor/api/store.py | 18 ++++++------ supervisor/api/supervisor.py | 17 ++++------- supervisor/api/utils.py | 18 +++++++----- supervisor/auth.py | 4 ++- supervisor/discovery/__init__.py | 4 +-- supervisor/docker/interface.py | 2 +- supervisor/homeassistant/api.py | 10 ++++--- supervisor/utils/systemd_journal.py | 2 +- 26 files changed, 195 insertions(+), 161 deletions(-) diff --git a/supervisor/addons/build.py b/supervisor/addons/build.py index 63c1165e614..675d1af5803 100644 --- a/supervisor/addons/build.py +++ b/supervisor/addons/build.py @@ -15,7 +15,6 @@ ATTR_SQUASH, FILE_SUFFIX_CONFIGURATION, META_ADDON, - CpuArch, ) from ..coresys import CoreSys, CoreSysAttributes from ..docker.interface import MAP_ARCH @@ -134,7 +133,7 @@ def get_docker_args(self, version: AwesomeVersion, image: str | None = None): "pull": True, "forcerm": not self.sys_dev, "squash": self.squash, - "platform": MAP_ARCH[CpuArch[self.arch]], + "platform": MAP_ARCH[self.arch], "labels": { "io.hass.version": version, "io.hass.arch": self.arch, diff --git a/supervisor/addons/manager.py b/supervisor/addons/manager.py index fa8c0ef9b10..93dd9968a41 100644 --- a/supervisor/addons/manager.py +++ b/supervisor/addons/manager.py @@ -194,6 +194,7 @@ async def install(self, slug: str) -> None: _LOGGER.info("Add-on '%s' successfully installed", slug) + @Job(name="addon_manager_uninstall") async def uninstall(self, slug: str, *, remove_config: bool = False) -> None: """Remove an add-on.""" if slug not in self.local: diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 7c0c33d9361..24db1d962ff 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -from aiohttp import web +from aiohttp import hdrs, web from ..const import AddonState from ..coresys import CoreSys, CoreSysAttributes @@ -82,15 +82,13 @@ def __init__(self, coresys: CoreSys): self._site: web.TCPSite | None = None # share single host API handler for reuse in logging endpoints - self._api_host: APIHost | None = None + self._api_host: APIHost = APIHost() + self._api_host.coresys = coresys async def load(self) -> None: """Register REST API Calls.""" static_resource_configs: list[StaticResourceConfig] = [] - self._api_host = APIHost() - self._api_host.coresys = self.coresys - self._register_addons() self._register_audio() self._register_auth() @@ -526,7 +524,7 @@ def _register_addons(self) -> None: self.webapp.add_routes( [ - web.get("/addons", api_addons.list), + web.get("/addons", api_addons.list_addons), web.post("/addons/{addon}/uninstall", api_addons.uninstall), web.post("/addons/{addon}/start", api_addons.start), web.post("/addons/{addon}/stop", api_addons.stop), @@ -594,7 +592,9 @@ def _register_ingress(self) -> None: web.post("/ingress/session", api_ingress.create_session), web.post("/ingress/validate_session", api_ingress.validate_session), web.get("/ingress/panels", api_ingress.panels), - web.view("/ingress/{token}/{path:.*}", api_ingress.handler), + web.route( + hdrs.METH_ANY, "/ingress/{token}/{path:.*}", api_ingress.handler + ), ] ) @@ -605,7 +605,7 @@ def _register_backups(self) -> None: self.webapp.add_routes( [ - web.get("/backups", api_backups.list), + web.get("/backups", api_backups.list_backups), web.get("/backups/info", api_backups.info), web.post("/backups/options", api_backups.options), web.post("/backups/reload", api_backups.reload), @@ -632,7 +632,7 @@ def _register_services(self) -> None: self.webapp.add_routes( [ - web.get("/services", api_services.list), + web.get("/services", api_services.list_services), web.get("/services/{service}", api_services.get_service), web.post("/services/{service}", api_services.set_service), web.delete("/services/{service}", api_services.del_service), @@ -646,7 +646,7 @@ def _register_discovery(self) -> None: self.webapp.add_routes( [ - web.get("/discovery", api_discovery.list), + web.get("/discovery", api_discovery.list_discovery), web.get("/discovery/{uuid}", api_discovery.get_discovery), web.delete("/discovery/{uuid}", api_discovery.del_discovery), web.post("/discovery", api_discovery.set_discovery), diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index a0524802cf5..0a74e836759 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Awaitable import logging -from typing import Any +from typing import Any, TypedDict from aiohttp import web import voluptuous as vol @@ -62,7 +62,6 @@ ATTR_MEMORY_LIMIT, ATTR_MEMORY_PERCENT, ATTR_MEMORY_USAGE, - ATTR_MESSAGE, ATTR_NAME, ATTR_NETWORK, ATTR_NETWORK_DESCRIPTION, @@ -71,7 +70,6 @@ ATTR_OPTIONS, ATTR_PRIVILEGED, ATTR_PROTECTED, - ATTR_PWNED, ATTR_RATING, ATTR_REPOSITORY, ATTR_SCHEMA, @@ -89,7 +87,6 @@ ATTR_UPDATE_AVAILABLE, ATTR_URL, ATTR_USB, - ATTR_VALID, ATTR_VERSION, ATTR_VERSION_LATEST, ATTR_VIDEO, @@ -145,12 +142,20 @@ # pylint: enable=no-value-for-parameter +class OptionsValidateResponse(TypedDict): + """Response object for options validate.""" + + message: str + valid: bool + pwned: bool | None + + class APIAddons(CoreSysAttributes): """Handle RESTful API for add-on functions.""" def get_addon_for_request(self, request: web.Request) -> Addon: """Return addon, throw an exception if it doesn't exist.""" - addon_slug: str = request.match_info.get("addon") + addon_slug: str = request.match_info.get("addon", "") # Lookup itself if addon_slug == "self": @@ -168,7 +173,7 @@ def get_addon_for_request(self, request: web.Request) -> Addon: return addon @api_process - async def list(self, request: web.Request) -> dict[str, Any]: + async def list_addons(self, request: web.Request) -> dict[str, Any]: """Return all add-ons or repositories.""" data_addons = [ { @@ -338,10 +343,10 @@ async def sys_options(self, request: web.Request) -> None: await addon.save_persist() @api_process - async def options_validate(self, request: web.Request) -> None: + async def options_validate(self, request: web.Request) -> OptionsValidateResponse: """Validate user options for add-on.""" addon = self.get_addon_for_request(request) - data = {ATTR_MESSAGE: "", ATTR_VALID: True, ATTR_PWNED: False} + data = OptionsValidateResponse(message="", valid=True, pwned=False) options = await request.json(loads=json_loads) or addon.options @@ -350,8 +355,8 @@ async def options_validate(self, request: web.Request) -> None: try: options_schema.validate(options) except vol.Invalid as ex: - data[ATTR_MESSAGE] = humanize_error(options, ex) - data[ATTR_VALID] = False + data["message"] = humanize_error(options, ex) + data["valid"] = False if not self.sys_security.pwned: return data @@ -362,24 +367,24 @@ async def options_validate(self, request: web.Request) -> None: await self.sys_security.verify_secret(secret) continue except PwnedSecret: - data[ATTR_PWNED] = True + data["pwned"] = True except PwnedError: - data[ATTR_PWNED] = None + data["pwned"] = None break - if self.sys_security.force and data[ATTR_PWNED] in (None, True): - data[ATTR_VALID] = False - if data[ATTR_PWNED] is None: - data[ATTR_MESSAGE] = "Error happening on pwned secrets check!" + if self.sys_security.force and data["pwned"] in (None, True): + data["valid"] = False + if data["pwned"] is None: + data["message"] = "Error happening on pwned secrets check!" else: - data[ATTR_MESSAGE] = "Add-on uses pwned secrets!" + data["message"] = "Add-on uses pwned secrets!" return data @api_process async def options_config(self, request: web.Request) -> None: """Validate user options for add-on.""" - slug: str = request.match_info.get("addon") + slug: str = request.match_info.get("addon", "") if slug != "self": raise APIForbidden("This can be only read by the Add-on itself!") addon = self.get_addon_for_request(request) diff --git a/supervisor/api/audio.py b/supervisor/api/audio.py index 6f42dd4758b..deb1913d07b 100644 --- a/supervisor/api/audio.py +++ b/supervisor/api/audio.py @@ -124,7 +124,7 @@ def reload(self, request: web.Request) -> Awaitable[None]: @api_process async def set_volume(self, request: web.Request) -> None: """Set audio volume on stream.""" - source: StreamType = StreamType(request.match_info.get("source")) + source: StreamType = StreamType(request.match_info.get("source", "")) application: bool = request.path.endswith("application") body = await api_validate(SCHEMA_VOLUME, request) @@ -137,7 +137,7 @@ async def set_volume(self, request: web.Request) -> None: @api_process async def set_mute(self, request: web.Request) -> None: """Mute audio volume on stream.""" - source: StreamType = StreamType(request.match_info.get("source")) + source: StreamType = StreamType(request.match_info.get("source", "")) application: bool = request.path.endswith("application") body = await api_validate(SCHEMA_MUTE, request) @@ -150,7 +150,7 @@ async def set_mute(self, request: web.Request) -> None: @api_process async def set_default(self, request: web.Request) -> None: """Set audio default stream.""" - source: StreamType = StreamType(request.match_info.get("source")) + source: StreamType = StreamType(request.match_info.get("source", "")) body = await api_validate(SCHEMA_DEFAULT, request) await asyncio.shield(self.sys_host.sound.set_default(source, body[ATTR_NAME])) diff --git a/supervisor/api/auth.py b/supervisor/api/auth.py index 3eea149a0d8..2d0dd46b2e9 100644 --- a/supervisor/api/auth.py +++ b/supervisor/api/auth.py @@ -1,6 +1,7 @@ """Init file for Supervisor auth/SSO RESTful API.""" import asyncio +from collections.abc import Awaitable import logging from typing import Any @@ -42,7 +43,7 @@ class APIAuth(CoreSysAttributes): """Handle RESTful API for auth functions.""" - def _process_basic(self, request: web.Request, addon: Addon) -> bool: + def _process_basic(self, request: web.Request, addon: Addon) -> Awaitable[bool]: """Process login request with basic auth. Return a coroutine. @@ -52,7 +53,7 @@ def _process_basic(self, request: web.Request, addon: Addon) -> bool: def _process_dict( self, request: web.Request, addon: Addon, data: dict[str, str] - ) -> bool: + ) -> Awaitable[bool]: """Process login with dict data. Return a coroutine. diff --git a/supervisor/api/backups.py b/supervisor/api/backups.py index 43de22d00af..7f0b9075ccf 100644 --- a/supervisor/api/backups.py +++ b/supervisor/api/backups.py @@ -10,9 +10,9 @@ from pathlib import Path import re from tempfile import TemporaryDirectory -from typing import Any +from typing import Any, cast -from aiohttp import web +from aiohttp import BodyPartReader, web from aiohttp.hdrs import CONTENT_DISPOSITION import voluptuous as vol from voluptuous.humanize import humanize_error @@ -52,8 +52,9 @@ ) from ..coresys import CoreSysAttributes from ..exceptions import APIError, APIForbidden, APINotFound -from ..jobs import JobSchedulerOptions +from ..jobs import JobSchedulerOptions, SupervisorJob from ..mounts.const import MountUsage +from ..mounts.mount import Mount from ..resolution.const import UnhealthyReason from .const import ( ATTR_ADDITIONAL_LOCATIONS, @@ -187,7 +188,7 @@ def _list_backups(self): ] @api_process - async def list(self, request): + async def list_backups(self, request): """Return backup list.""" data_backups = self._list_backups() @@ -295,8 +296,11 @@ async def _background_backup_task( ) -> tuple[asyncio.Task, str]: """Start backup task in background and return task and job ID.""" event = asyncio.Event() - job, backup_task = self.sys_jobs.schedule_job( - backup_method, JobSchedulerOptions(), *args, **kwargs + job, backup_task = cast( + tuple[SupervisorJob, asyncio.Task], + self.sys_jobs.schedule_job( + backup_method, JobSchedulerOptions(), *args, **kwargs + ), ) async def release_on_freeze(new_state: CoreState): @@ -311,10 +315,7 @@ async def release_on_freeze(new_state: CoreState): try: event_task = self.sys_create_task(event.wait()) _, pending = await asyncio.wait( - ( - backup_task, - event_task, - ), + (backup_task, event_task), return_when=asyncio.FIRST_COMPLETED, ) # It seems backup returned early (error or something), make sure to cancel @@ -497,8 +498,10 @@ async def upload(self, request: web.Request): locations: list[LOCATION_TYPE] | None = None tmp_path = self.sys_config.path_tmp if ATTR_LOCATION in request.query: - location_names: list[str] = request.query.getall(ATTR_LOCATION) - self._validate_cloud_backup_location(request, location_names) + location_names: list[str] = request.query.getall(ATTR_LOCATION, []) + self._validate_cloud_backup_location( + request, cast(list[str | None], location_names) + ) # Convert empty string to None if necessary locations = [ self._location_to_mount(location) @@ -509,7 +512,7 @@ async def upload(self, request: web.Request): location = locations.pop(0) if location and location != LOCATION_CLOUD_BACKUP: - tmp_path = location.local_where + tmp_path = cast(Mount, location).local_where or tmp_path filename: str | None = None if ATTR_FILENAME in request.query: @@ -540,10 +543,15 @@ def close_backup_file() -> None: try: reader = await request.multipart() contents = await reader.next() + if not isinstance(contents, BodyPartReader): + raise APIError("Improperly formatted upload, could not read backup") + tar_file = await self.sys_run_in_executor(open_backup_file) while chunk := await contents.read_chunk(size=2**16): - await self.sys_run_in_executor(backup_file_stream.write, chunk) - await self.sys_run_in_executor(backup_file_stream.close) + await self.sys_run_in_executor( + cast(IOBase, backup_file_stream).write, chunk + ) + await self.sys_run_in_executor(cast(IOBase, backup_file_stream).close) backup = await asyncio.shield( self.sys_backups.import_backup( diff --git a/supervisor/api/discovery.py b/supervisor/api/discovery.py index 689e8d2d993..d0ea7ecf0ea 100644 --- a/supervisor/api/discovery.py +++ b/supervisor/api/discovery.py @@ -1,7 +1,9 @@ """Init file for Supervisor network RESTful API.""" import logging +from typing import Any, cast +from aiohttp import web import voluptuous as vol from ..addons.addon import Addon @@ -16,6 +18,7 @@ AddonState, ) from ..coresys import CoreSysAttributes +from ..discovery import Message from ..exceptions import APIForbidden, APINotFound from .utils import api_process, api_validate, require_home_assistant @@ -32,16 +35,16 @@ class APIDiscovery(CoreSysAttributes): """Handle RESTful API for discovery functions.""" - def _extract_message(self, request): + def _extract_message(self, request: web.Request) -> Message: """Extract discovery message from URL.""" - message = self.sys_discovery.get(request.match_info.get("uuid")) + message = self.sys_discovery.get(request.match_info.get("uuid", "")) if not message: raise APINotFound("Discovery message not found") return message @api_process @require_home_assistant - async def list(self, request): + async def list_discovery(self, request: web.Request) -> dict[str, Any]: """Show registered and available services.""" # Get available discovery discovery = [ @@ -52,12 +55,16 @@ async def list(self, request): ATTR_CONFIG: message.config, } for message in self.sys_discovery.list_messages - if (addon := self.sys_addons.get(message.addon, local_only=True)) - and addon.state == AddonState.STARTED + if ( + discovered := cast( + Addon, self.sys_addons.get(message.addon, local_only=True) + ) + ) + and discovered.state == AddonState.STARTED ] # Get available services/add-ons - services = {} + services: dict[str, list[str]] = {} for addon in self.sys_addons.all: for name in addon.discovery: services.setdefault(name, []).append(addon.slug) @@ -65,7 +72,7 @@ async def list(self, request): return {ATTR_DISCOVERY: discovery, ATTR_SERVICES: services} @api_process - async def set_discovery(self, request): + async def set_discovery(self, request: web.Request) -> dict[str, str]: """Write data into a discovery pipeline.""" body = await api_validate(SCHEMA_DISCOVERY, request) addon: Addon = request[REQUEST_FROM] @@ -89,7 +96,7 @@ async def set_discovery(self, request): @api_process @require_home_assistant - async def get_discovery(self, request): + async def get_discovery(self, request: web.Request) -> dict[str, Any]: """Read data into a discovery message.""" message = self._extract_message(request) @@ -101,7 +108,7 @@ async def get_discovery(self, request): } @api_process - async def del_discovery(self, request): + async def del_discovery(self, request: web.Request) -> None: """Delete data into a discovery message.""" message = self._extract_message(request) addon = request[REQUEST_FROM] @@ -111,4 +118,3 @@ async def del_discovery(self, request): raise APIForbidden("Can't remove discovery message") await self.sys_discovery.remove(message) - return True diff --git a/supervisor/api/hardware.py b/supervisor/api/hardware.py index 0a416034052..3d4bd46811b 100644 --- a/supervisor/api/hardware.py +++ b/supervisor/api/hardware.py @@ -68,7 +68,10 @@ def filesystem_struct(fs_block: UDisks2Block) -> dict[str, Any]: ATTR_NAME: fs_block.id_label, ATTR_SYSTEM: fs_block.hint_system, ATTR_MOUNT_POINTS: [ - str(mount_point) for mount_point in fs_block.filesystem.mount_points + str(mount_point) + for mount_point in ( + fs_block.filesystem.mount_points if fs_block.filesystem else [] + ) ], } diff --git a/supervisor/api/host.py b/supervisor/api/host.py index bd6eb2d0dff..8ba18ff3cb5 100644 --- a/supervisor/api/host.py +++ b/supervisor/api/host.py @@ -3,6 +3,7 @@ import asyncio from contextlib import suppress import logging +from typing import Any from aiohttp import ClientConnectionResetError, web from aiohttp.hdrs import ACCEPT, RANGE @@ -195,11 +196,11 @@ async def advanced_logs_handler( ) -> web.StreamResponse: """Return systemd-journald logs.""" log_formatter = LogFormatter.PLAIN - params = {} + params: dict[str, Any] = {} if identifier: params[PARAM_SYSLOG_IDENTIFIER] = identifier elif IDENTIFIER in request.match_info: - params[PARAM_SYSLOG_IDENTIFIER] = request.match_info.get(IDENTIFIER) + params[PARAM_SYSLOG_IDENTIFIER] = request.match_info.get(IDENTIFIER, "") else: params[PARAM_SYSLOG_IDENTIFIER] = self.sys_host.logs.default_identifiers # host logs should be always verbose, no matter what Accept header is used @@ -207,7 +208,7 @@ async def advanced_logs_handler( if BOOTID in request.match_info: params[PARAM_BOOT_ID] = await self._get_boot_id( - request.match_info.get(BOOTID) + request.match_info.get(BOOTID, "") ) if follow: params[PARAM_FOLLOW] = "" @@ -241,7 +242,7 @@ async def advanced_logs_handler( # entries=cursor[[:num_skip]:num_entries] range_header = f"entries=:-{lines - 1}:{'' if follow else lines}" elif RANGE in request.headers: - range_header = request.headers.get(RANGE) + range_header = request.headers.get(RANGE, "") else: range_header = ( f"entries=:-{DEFAULT_LINES - 1}:{'' if follow else DEFAULT_LINES}" diff --git a/supervisor/api/ingress.py b/supervisor/api/ingress.py index a816e3cf7d3..1ed1b18510b 100644 --- a/supervisor/api/ingress.py +++ b/supervisor/api/ingress.py @@ -83,7 +83,7 @@ def __init__(self) -> None: def _extract_addon(self, request: web.Request) -> Addon: """Return addon, throw an exception it it doesn't exist.""" - token = request.match_info.get("token") + token = request.match_info.get("token", "") # Find correct add-on addon = self.sys_ingress.get(token) @@ -132,7 +132,7 @@ async def create_session(self, request: web.Request) -> dict[str, Any]: @api_process @require_home_assistant - async def validate_session(self, request: web.Request) -> dict[str, Any]: + async def validate_session(self, request: web.Request) -> None: """Validate session and extending how long it's valid for.""" data = await api_validate(VALIDATE_SESSION_DATA, request) @@ -147,14 +147,14 @@ async def handler( """Route data to Supervisor ingress service.""" # Check Ingress Session - session = request.cookies.get(COOKIE_INGRESS) + session = request.cookies.get(COOKIE_INGRESS, "") if not self.sys_ingress.validate_session(session): _LOGGER.warning("No valid ingress session %s", session) raise HTTPUnauthorized() # Process requests addon = self._extract_addon(request) - path = request.match_info.get("path") + path = request.match_info.get("path", "") session_data = self.sys_ingress.get_session_data(session) try: # Websocket @@ -183,7 +183,7 @@ async def _handle_websocket( for proto in request.headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",") ] else: - req_protocols = () + req_protocols = [] ws_server = web.WebSocketResponse( protocols=req_protocols, autoclose=False, autoping=False @@ -340,9 +340,10 @@ def _init_header( headers[name] = value # Update X-Forwarded-For - forward_for = request.headers.get(hdrs.X_FORWARDED_FOR) - connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) - headers[hdrs.X_FORWARDED_FOR] = f"{forward_for}, {connected_ip!s}" + if request.transport: + forward_for = request.headers.get(hdrs.X_FORWARDED_FOR) + connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) + headers[hdrs.X_FORWARDED_FOR] = f"{forward_for}, {connected_ip!s}" return headers diff --git a/supervisor/api/jobs.py b/supervisor/api/jobs.py index 4c7e5adf76e..9ee72bef181 100644 --- a/supervisor/api/jobs.py +++ b/supervisor/api/jobs.py @@ -26,7 +26,7 @@ class APIJobs(CoreSysAttributes): def _extract_job(self, request: web.Request) -> SupervisorJob: """Extract job from request or raise.""" try: - return self.sys_jobs.get_job(request.match_info.get("uuid")) + return self.sys_jobs.get_job(request.match_info.get("uuid", "")) except JobNotFound: raise APINotFound("Job does not exist") from None @@ -71,7 +71,10 @@ def _list_jobs(self, start: SupervisorJob | None = None) -> list[dict[str, Any]] if current_job.uuid in jobs_by_parent: queue.extend( - [(child_jobs, job) for job in jobs_by_parent.get(current_job.uuid)] + [ + (child_jobs, job) + for job in jobs_by_parent.get(current_job.uuid, []) + ] ) return job_list diff --git a/supervisor/api/middleware/security.py b/supervisor/api/middleware/security.py index 1c3ce7aa000..647bf355e1e 100644 --- a/supervisor/api/middleware/security.py +++ b/supervisor/api/middleware/security.py @@ -1,11 +1,12 @@ """Handle security part of this API.""" +from collections.abc import Callable import logging import re from typing import Final from urllib.parse import unquote -from aiohttp.web import Request, RequestHandler, Response, middleware +from aiohttp.web import Request, Response, middleware from aiohttp.web_exceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized from awesomeversion import AwesomeVersion @@ -23,7 +24,7 @@ ) from ...coresys import CoreSys, CoreSysAttributes from ...utils import version_is_new_enough -from ..utils import api_return_error, excract_supervisor_token +from ..utils import api_return_error, extract_supervisor_token _LOGGER: logging.Logger = logging.getLogger(__name__) _CORE_VERSION: Final = AwesomeVersion("2023.3.4") @@ -179,9 +180,7 @@ def _recursive_unquote(self, value: str) -> str: return unquoted @middleware - async def block_bad_requests( - self, request: Request, handler: RequestHandler - ) -> Response: + async def block_bad_requests(self, request: Request, handler: Callable) -> Response: """Process request and tblock commonly known exploit attempts.""" if FILTERS.search(self._recursive_unquote(request.path)): _LOGGER.warning( @@ -199,9 +198,7 @@ async def block_bad_requests( return await handler(request) @middleware - async def system_validation( - self, request: Request, handler: RequestHandler - ) -> Response: + async def system_validation(self, request: Request, handler: Callable) -> Response: """Check if core is ready to response.""" if self.sys_core.state not in ( CoreState.STARTUP, @@ -215,12 +212,10 @@ async def system_validation( return await handler(request) @middleware - async def token_validation( - self, request: Request, handler: RequestHandler - ) -> Response: + async def token_validation(self, request: Request, handler: Callable) -> Response: """Check security access of this layer.""" - request_from = None - supervisor_token = excract_supervisor_token(request) + request_from: CoreSysAttributes | None = None + supervisor_token = extract_supervisor_token(request) # Blacklist if BLACKLIST.match(request.path): @@ -288,7 +283,7 @@ async def token_validation( raise HTTPForbidden() @middleware - async def core_proxy(self, request: Request, handler: RequestHandler) -> Response: + async def core_proxy(self, request: Request, handler: Callable) -> Response: """Validate user from Core API proxy.""" if ( request[REQUEST_FROM] != self.sys_homeassistant diff --git a/supervisor/api/mounts.py b/supervisor/api/mounts.py index 2a4aa99a7cb..9327cd344fe 100644 --- a/supervisor/api/mounts.py +++ b/supervisor/api/mounts.py @@ -1,6 +1,6 @@ """Inits file for supervisor mounts REST API.""" -from typing import Any +from typing import Any, cast from aiohttp import web import voluptuous as vol @@ -10,7 +10,7 @@ from ..exceptions import APIError, APINotFound from ..mounts.const import ATTR_DEFAULT_BACKUP_MOUNT, MountUsage from ..mounts.mount import Mount -from ..mounts.validate import SCHEMA_MOUNT_CONFIG +from ..mounts.validate import SCHEMA_MOUNT_CONFIG, MountData from .const import ATTR_MOUNTS, ATTR_USER_PATH from .utils import api_process, api_validate @@ -26,7 +26,7 @@ class APIMounts(CoreSysAttributes): def _extract_mount(self, request: web.Request) -> Mount: """Extract mount from request or raise.""" - name = request.match_info.get("mount") + name = request.match_info.get("mount", "") if name not in self.sys_mounts: raise APINotFound(f"No mount exists with name {name}") return self.sys_mounts.get(name) @@ -71,10 +71,10 @@ async def options(self, request: web.Request) -> None: @api_process async def create_mount(self, request: web.Request) -> None: """Create a new mount in supervisor.""" - body = await api_validate(SCHEMA_MOUNT_CONFIG, request) + body = cast(MountData, await api_validate(SCHEMA_MOUNT_CONFIG, request)) - if body[ATTR_NAME] in self.sys_mounts: - raise APIError(f"A mount already exists with name {body[ATTR_NAME]}") + if body["name"] in self.sys_mounts: + raise APIError(f"A mount already exists with name {body['name']}") mount = Mount.from_dict(self.coresys, body) await self.sys_mounts.create_mount(mount) @@ -97,7 +97,10 @@ async def update_mount(self, request: web.Request) -> None: {vol.Optional(ATTR_NAME, default=current.name): current.name}, extra=vol.ALLOW_EXTRA, ) - body = await api_validate(vol.All(name_schema, SCHEMA_MOUNT_CONFIG), request) + body = cast( + MountData, + await api_validate(vol.All(name_schema, SCHEMA_MOUNT_CONFIG), request), + ) mount = Mount.from_dict(self.coresys, body) await self.sys_mounts.create_mount(mount) diff --git a/supervisor/api/network.py b/supervisor/api/network.py index f9374923644..bc8b017d371 100644 --- a/supervisor/api/network.py +++ b/supervisor/api/network.py @@ -132,8 +132,12 @@ def interface_struct(interface: Interface) -> dict[str, Any]: ATTR_CONNECTED: interface.connected, ATTR_PRIMARY: interface.primary, ATTR_MAC: interface.mac, - ATTR_IPV4: ipconfig_struct(interface.ipv4, interface.ipv4setting), - ATTR_IPV6: ipconfig_struct(interface.ipv6, interface.ipv6setting), + ATTR_IPV4: ipconfig_struct(interface.ipv4, interface.ipv4setting) + if interface.ipv4 and interface.ipv4setting + else None, + ATTR_IPV6: ipconfig_struct(interface.ipv6, interface.ipv6setting) + if interface.ipv6 and interface.ipv6setting + else None, ATTR_WIFI: wifi_struct(interface.wifi) if interface.wifi else None, ATTR_VLAN: vlan_struct(interface.vlan) if interface.vlan else None, } @@ -190,14 +194,14 @@ async def info(self, request: web.Request) -> dict[str, Any]: @api_process async def interface_info(self, request: web.Request) -> dict[str, Any]: """Return network information for a interface.""" - interface = self._get_interface(request.match_info.get(ATTR_INTERFACE)) + interface = self._get_interface(request.match_info.get(ATTR_INTERFACE, "")) return interface_struct(interface) @api_process async def interface_update(self, request: web.Request) -> None: """Update the configuration of an interface.""" - interface = self._get_interface(request.match_info.get(ATTR_INTERFACE)) + interface = self._get_interface(request.match_info.get(ATTR_INTERFACE, "")) # Validate data body = await api_validate(SCHEMA_UPDATE, request) @@ -243,7 +247,7 @@ def reload(self, request: web.Request) -> Awaitable[None]: @api_process async def scan_accesspoints(self, request: web.Request) -> dict[str, Any]: """Scan and return a list of available networks.""" - interface = self._get_interface(request.match_info.get(ATTR_INTERFACE)) + interface = self._get_interface(request.match_info.get(ATTR_INTERFACE, "")) # Only wlan is supported if interface.type != InterfaceType.WIRELESS: @@ -256,8 +260,10 @@ async def scan_accesspoints(self, request: web.Request) -> dict[str, Any]: @api_process async def create_vlan(self, request: web.Request) -> None: """Create a new vlan.""" - interface = self._get_interface(request.match_info.get(ATTR_INTERFACE)) - vlan = int(request.match_info.get(ATTR_VLAN)) + interface = self._get_interface(request.match_info.get(ATTR_INTERFACE, "")) + vlan = int(request.match_info.get(ATTR_VLAN, -1)) + if vlan < 0: + raise APIError(f"Invalid vlan specified: {vlan}") # Only ethernet is supported if interface.type != InterfaceType.ETHERNET: diff --git a/supervisor/api/proxy.py b/supervisor/api/proxy.py index 26d5326417a..8ee665a2804 100644 --- a/supervisor/api/proxy.py +++ b/supervisor/api/proxy.py @@ -1,6 +1,7 @@ """Utils for Home Assistant Proxy.""" import asyncio +from collections.abc import AsyncIterator from contextlib import asynccontextmanager import logging @@ -40,7 +41,7 @@ def _check_access(self, request: web.Request): bearer = request.headers[AUTHORIZATION] supervisor_token = bearer.split(" ")[-1] else: - supervisor_token = request.headers.get(HEADER_HA_ACCESS) + supervisor_token = request.headers.get(HEADER_HA_ACCESS, "") addon = self.sys_addons.from_token(supervisor_token) if not addon: @@ -54,7 +55,9 @@ def _check_access(self, request: web.Request): raise HTTPUnauthorized() @asynccontextmanager - async def _api_client(self, request: web.Request, path: str, timeout: int = 300): + async def _api_client( + self, request: web.Request, path: str, timeout: int | None = 300 + ) -> AsyncIterator[aiohttp.ClientResponse]: """Return a client request with proxy origin for Home Assistant.""" try: async with self.sys_homeassistant.api.make_request( @@ -93,7 +96,7 @@ async def stream(self, request: web.Request): _LOGGER.info("Home Assistant EventStream start") async with self._api_client(request, "stream", timeout=None) as client: response = web.StreamResponse() - response.content_type = request.headers.get(CONTENT_TYPE) + response.content_type = request.headers.get(CONTENT_TYPE, "") try: response.headers["X-Accel-Buffering"] = "no" await response.prepare(request) @@ -180,21 +183,19 @@ async def _proxy_message( target: web.WebSocketResponse | ClientWebSocketResponse, ) -> None: """Proxy a message from client to server or vice versa.""" - if read_task.exception(): - raise read_task.exception() - msg: WSMessage = read_task.result() - if msg.type == WSMsgType.TEXT: - return await target.send_str(msg.data) - if msg.type == WSMsgType.BINARY: - return await target.send_bytes(msg.data) - if msg.type == WSMsgType.CLOSE: - _LOGGER.debug("Received close message from WebSocket.") - return await target.close() - - raise TypeError( - f"Cannot proxy websocket message of unsupported type: {msg.type}" - ) + match msg.type: + case WSMsgType.TEXT: + await target.send_str(msg.data) + case WSMsgType.BINARY: + await target.send_bytes(msg.data) + case WSMsgType.CLOSE: + _LOGGER.debug("Received close message from WebSocket.") + await target.close() + case _: + raise TypeError( + f"Cannot proxy websocket message of unsupported type: {msg.type}" + ) async def websocket(self, request: web.Request): """Initialize a WebSocket API connection.""" diff --git a/supervisor/api/resolution.py b/supervisor/api/resolution.py index 7387087e1c2..bfc1983872d 100644 --- a/supervisor/api/resolution.py +++ b/supervisor/api/resolution.py @@ -33,7 +33,7 @@ class APIResoulution(CoreSysAttributes): def _extract_issue(self, request: web.Request) -> Issue: """Extract issue from request or raise.""" try: - return self.sys_resolution.get_issue(request.match_info.get("issue")) + return self.sys_resolution.get_issue(request.match_info.get("issue", "")) except ResolutionNotFound: raise APINotFound("The supplied UUID is not a valid issue") from None @@ -41,7 +41,7 @@ def _extract_suggestion(self, request: web.Request) -> Suggestion: """Extract suggestion from request or raise.""" try: return self.sys_resolution.get_suggestion( - request.match_info.get("suggestion") + request.match_info.get("suggestion", "") ) except ResolutionNotFound: raise APINotFound("The supplied UUID is not a valid suggestion") from None @@ -49,7 +49,7 @@ def _extract_suggestion(self, request: web.Request) -> Suggestion: def _extract_check(self, request: web.Request) -> CheckBase: """Extract check from request or raise.""" try: - return self.sys_resolution.check.get(request.match_info.get("check")) + return self.sys_resolution.check.get(request.match_info.get("check", "")) except ResolutionNotFound: raise APINotFound("The supplied check slug is not available") from None diff --git a/supervisor/api/services.py b/supervisor/api/services.py index e61fcc3214c..8903aa4dc2a 100644 --- a/supervisor/api/services.py +++ b/supervisor/api/services.py @@ -25,7 +25,7 @@ def _extract_service(self, request): return service @api_process - async def list(self, request): + async def list_services(self, request): """Show register services.""" services = [] for service in self.sys_services.list_services: diff --git a/supervisor/api/store.py b/supervisor/api/store.py index 4a878352ab9..d047741c7fb 100644 --- a/supervisor/api/store.py +++ b/supervisor/api/store.py @@ -3,11 +3,12 @@ import asyncio from collections.abc import Awaitable from pathlib import Path -from typing import Any +from typing import Any, cast from aiohttp import web import voluptuous as vol +from ..addons.addon import Addon from ..addons.manager import AnyAddon from ..addons.utils import rating_security from ..api.const import ATTR_SIGNED @@ -92,7 +93,7 @@ class APIStore(CoreSysAttributes): def _extract_addon(self, request: web.Request, installed=False) -> AnyAddon: """Return add-on, throw an exception it it doesn't exist.""" - addon_slug: str = request.match_info.get("addon") + addon_slug: str = request.match_info.get("addon", "") if not (addon := self.sys_addons.get(addon_slug)): raise APINotFound(f"Addon {addon_slug} does not exist") @@ -101,6 +102,7 @@ def _extract_addon(self, request: web.Request, installed=False) -> AnyAddon: raise APIError(f"Addon {addon_slug} is not installed") if not installed and addon.is_installed: + addon = cast(Addon, addon) if not addon.addon_store: raise APINotFound(f"Addon {addon_slug} does not exist in the store") return addon.addon_store @@ -109,7 +111,7 @@ def _extract_addon(self, request: web.Request, installed=False) -> AnyAddon: def _extract_repository(self, request: web.Request) -> Repository: """Return repository, throw an exception it it doesn't exist.""" - repository_slug: str = request.match_info.get("repository") + repository_slug: str = request.match_info.get("repository", "") if repository_slug not in self.sys_store.repositories: raise APINotFound( @@ -124,7 +126,7 @@ async def _generate_addon_information( """Generate addon information.""" installed = ( - self.sys_addons.get(addon.slug, local_only=True) + cast(Addon, self.sys_addons.get(addon.slug, local_only=True)) if addon.is_installed else None ) @@ -144,12 +146,10 @@ async def _generate_addon_information( ATTR_REPOSITORY: addon.repository, ATTR_SLUG: addon.slug, ATTR_STAGE: addon.stage, - ATTR_UPDATE_AVAILABLE: installed.need_update - if addon.is_installed - else False, + ATTR_UPDATE_AVAILABLE: installed.need_update if installed else False, ATTR_URL: addon.url, ATTR_VERSION_LATEST: addon.latest_version, - ATTR_VERSION: installed.version if addon.is_installed else None, + ATTR_VERSION: installed.version if installed else None, } if extended: data.update( @@ -246,7 +246,7 @@ async def addons_addon_info(self, request: web.Request) -> dict[str, Any]: # Used by legacy routing for addons/{addon}/info, can be refactored out when that is removed (1/2023) async def addons_addon_info_wrapped(self, request: web.Request) -> dict[str, Any]: """Return add-on information directly (not api).""" - addon: AddonStore = self._extract_addon(request) + addon = cast(AddonStore, self._extract_addon(request)) return await self._generate_addon_information(addon, True) @api_process_raw(CONTENT_TYPE_PNG) diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index 8b012a0f2e6..d6269da28a4 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -211,19 +211,12 @@ async def update(self, request: web.Request) -> None: await asyncio.shield(self.sys_supervisor.update(version)) @api_process - def reload(self, request: web.Request) -> Awaitable[None]: + def reload(self, request: web.Request) -> Awaitable: """Reload add-ons, configuration, etc.""" - return asyncio.shield( - asyncio.wait( - [ - self.sys_create_task(coro) - for coro in [ - self.sys_updater.reload(), - self.sys_homeassistant.secrets.reload(), - self.sys_resolution.evaluate.evaluate_system(), - ] - ] - ) + return asyncio.gather( + asyncio.shield(self.sys_updater.reload()), + asyncio.shield(self.sys_homeassistant.secrets.reload()), + asyncio.shield(self.sys_resolution.evaluate.evaluate_system()), ) @api_process diff --git a/supervisor/api/utils.py b/supervisor/api/utils.py index 4213632aedc..e7bc54aafdd 100644 --- a/supervisor/api/utils.py +++ b/supervisor/api/utils.py @@ -21,7 +21,7 @@ RESULT_ERROR, RESULT_OK, ) -from ..coresys import CoreSys +from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import APIError, BackupFileNotFoundError, DockerAPIError, HassioError from ..utils import check_exception_chain, get_message_from_exception_chain from ..utils.json import json_dumps, json_loads as json_loads_util @@ -29,7 +29,7 @@ from . import const -def excract_supervisor_token(request: web.Request) -> str | None: +def extract_supervisor_token(request: web.Request) -> str | None: """Extract Supervisor token from request.""" if supervisor_token := request.headers.get(HEADER_TOKEN): return supervisor_token @@ -58,7 +58,9 @@ def json_loads(data: Any) -> dict[str, Any]: def api_process(method): """Wrap function with true/false calls to rest api.""" - async def wrap_api(api, *args, **kwargs): + async def wrap_api( + api: CoreSysAttributes, *args, **kwargs + ) -> web.Response | web.StreamResponse: """Return API information.""" try: answer = await method(api, *args, **kwargs) @@ -85,7 +87,7 @@ async def wrap_api(api, *args, **kwargs): def require_home_assistant(method): """Ensure that the request comes from Home Assistant.""" - async def wrap_api(api, *args, **kwargs): + async def wrap_api(api: CoreSysAttributes, *args, **kwargs) -> Any: """Return API information.""" coresys: CoreSys = api.coresys request: Request = args[0] @@ -102,7 +104,9 @@ def api_process_raw(content, *, error_type=None): def wrap_method(method): """Wrap function with raw output to rest api.""" - async def wrap_api(api, *args, **kwargs): + async def wrap_api( + api: CoreSysAttributes, *args, **kwargs + ) -> web.Response | web.StreamResponse: """Return api information.""" try: msg_data = await method(api, *args, **kwargs) @@ -165,7 +169,7 @@ def api_return_error( ) -def api_return_ok(data: dict[str, Any] | None = None) -> web.Response: +def api_return_ok(data: dict[str, Any] | list[Any] | None = None) -> web.Response: """Return an API ok answer.""" return web.json_response( {JSON_RESULT: RESULT_OK, JSON_DATA: data or {}}, @@ -174,7 +178,7 @@ def api_return_ok(data: dict[str, Any] | None = None) -> web.Response: async def api_validate( - schema: vol.Schema, + schema: vol.Schema | vol.All, request: web.Request, origin: list[str] | None = None, ) -> dict[str, Any]: diff --git a/supervisor/auth.py b/supervisor/auth.py index cf74468d046..1823a815404 100644 --- a/supervisor/auth.py +++ b/supervisor/auth.py @@ -68,7 +68,9 @@ async def _dismatch_cache(self, username: str, password: str) -> None: self._data.pop(username_h, None) await self.save_data() - async def check_login(self, addon: Addon, username: str, password: str) -> bool: + async def check_login( + self, addon: Addon, username: str | None, password: str | None + ) -> bool: """Check username login.""" if password is None: raise AuthError("None as password is not supported!", _LOGGER.error) diff --git a/supervisor/discovery/__init__.py b/supervisor/discovery/__init__.py index 0b4d9094897..bf38dcf892b 100644 --- a/supervisor/discovery/__init__.py +++ b/supervisor/discovery/__init__.py @@ -5,7 +5,7 @@ from contextlib import suppress import logging from typing import TYPE_CHECKING, Any -from uuid import UUID, uuid4 +from uuid import uuid4 import attr @@ -31,7 +31,7 @@ class Message: addon: str = attr.ib() service: str = attr.ib() config: dict[str, Any] = attr.ib(eq=False) - uuid: UUID = attr.ib(factory=lambda: uuid4().hex, eq=False) + uuid: str = attr.ib(factory=lambda: uuid4().hex, eq=False) class Discovery(CoreSysAttributes, FileConfiguration): diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 6c8c843d861..760d8e319de 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -53,7 +53,7 @@ IMAGE_WITH_HOST = re.compile(r"^((?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,})\/.+") DOCKER_HUB = "hub.docker.com" -MAP_ARCH = { +MAP_ARCH: dict[CpuArch | str, str] = { CpuArch.ARMV7: "linux/arm/v7", CpuArch.ARMHF: "linux/arm/v6", CpuArch.AARCH64: "linux/arm64", diff --git a/supervisor/homeassistant/api.py b/supervisor/homeassistant/api.py index 26a30416e7b..6aad9ff678c 100644 --- a/supervisor/homeassistant/api.py +++ b/supervisor/homeassistant/api.py @@ -1,7 +1,8 @@ """Home Assistant control object.""" import asyncio -from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress from dataclasses import dataclass from datetime import UTC, datetime, timedelta import logging @@ -10,6 +11,7 @@ import aiohttp from aiohttp import hdrs from awesomeversion import AwesomeVersion +from multidict import MultiMapping from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import HomeAssistantAPIError, HomeAssistantAuthError @@ -84,10 +86,10 @@ async def make_request( json: dict[str, Any] | None = None, content_type: str | None = None, data: Any = None, - timeout: int = 30, - params: dict[str, str] | None = None, + timeout: int | None = 30, + params: MultiMapping[str] | None = None, headers: dict[str, str] | None = None, - ) -> AbstractAsyncContextManager[aiohttp.ClientResponse]: + ) -> AsyncIterator[aiohttp.ClientResponse]: """Async context manager to make a request with right auth.""" url = f"{self.sys_homeassistant.api_url}/{path}" headers = headers or {} diff --git a/supervisor/utils/systemd_journal.py b/supervisor/utils/systemd_journal.py index 00a8d7b8538..e13b9955582 100644 --- a/supervisor/utils/systemd_journal.py +++ b/supervisor/utils/systemd_journal.py @@ -61,7 +61,7 @@ def journal_verbose_formatter(entries: dict[str, str]) -> str: async def journal_logs_reader( journal_logs: ClientResponse, log_formatter: LogFormatter = LogFormatter.PLAIN -) -> AsyncGenerator[str | None, str]: +) -> AsyncGenerator[tuple[str | None, str]]: """Read logs from systemd journal line by line, formatted using the given formatter. Returns a generator of (cursor, formatted_entry) tuples. From 7c8ef05e888ac0885440b9ca12a79e66394a1386 Mon Sep 17 00:00:00 2001 From: Mike Degatano <michael.degatano@gmail.com> Date: Mon, 17 Mar 2025 18:33:34 +0000 Subject: [PATCH 3/3] fix docstring --- supervisor/resolution/module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supervisor/resolution/module.py b/supervisor/resolution/module.py index 44d1e4dffc2..4ef7b3acdab 100644 --- a/supervisor/resolution/module.py +++ b/supervisor/resolution/module.py @@ -131,7 +131,7 @@ def unhealthy(self) -> list[UnhealthyReason]: return self._unhealthy def add_unhealthy_reason(self, reason: UnhealthyReason) -> None: - """Add a reason for unhrealthy.""" + """Add a reason for unhealthy.""" if reason not in self._unhealthy: self._unhealthy.append(reason) self.sys_homeassistant.websocket.supervisor_event(