diff --git a/supervisor/__main__.py b/supervisor/__main__.py index de6920bd88b..f5c438e1ab8 100644 --- a/supervisor/__main__.py +++ b/supervisor/__main__.py @@ -54,7 +54,7 @@ def run_os_startup_check_cleanup() -> None: loop.set_debug(coresys.config.debug) loop.run_until_complete(coresys.core.connect()) - bootstrap.supervisor_debugger(coresys) + loop.run_until_complete(bootstrap.supervisor_debugger(coresys)) # Signal health startup for container run_os_startup_check_cleanup() diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 2d5dee5b97d..c7cfe4c285f 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -88,7 +88,7 @@ from ..utils import check_port from ..utils.apparmor import adjust_profile from ..utils.json import read_json_file, write_json_file -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .const import ( WATCHDOG_MAX_ATTEMPTS, WATCHDOG_RETRY_SECONDS, @@ -1530,7 +1530,7 @@ async def _restart_after_problem(self, state: ContainerState): except AddonsError as err: attempts = attempts + 1 _LOGGER.error("Watchdog restart of addon %s failed!", self.name) - capture_exception(err) + await async_capture_exception(err) else: break diff --git a/supervisor/addons/manager.py b/supervisor/addons/manager.py index 23cf175b755..56eae30ed66 100644 --- a/supervisor/addons/manager.py +++ b/supervisor/addons/manager.py @@ -23,7 +23,7 @@ from ..jobs.decorator import Job, JobCondition from ..resolution.const import ContextType, IssueType, SuggestionType from ..store.addon import AddonStore -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .addon import Addon from .const import ADDON_UPDATE_CONDITIONS from .data import AddonsData @@ -170,7 +170,7 @@ async def shutdown(self, stage: AddonStartup) -> None: await addon.stop() except Exception as err: # pylint: disable=broad-except _LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err) - capture_exception(err) + await async_capture_exception(err) @Job( name="addon_manager_install", @@ -388,7 +388,7 @@ async def sync_dns(self) -> None: reference=addon.slug, suggestions=[SuggestionType.EXECUTE_REPAIR], ) - capture_exception(err) + await async_capture_exception(err) else: add_host_coros.append( self.sys_plugins.dns.add_host( diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index 8f0fb803368..4ebd4599774 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -210,18 +210,6 @@ def description(self) -> str: """Return description of add-on.""" return self.data[ATTR_DESCRIPTON] - @property - def long_description(self) -> str | None: - """Return README.md as long_description.""" - readme = Path(self.path_location, "README.md") - - # If readme not exists - if not readme.exists(): - return None - - # Return data - return readme.read_text(encoding="utf-8") - @property def repository(self) -> str: """Return repository of add-on.""" @@ -646,6 +634,21 @@ def breaking_versions(self) -> list[AwesomeVersion]: """Return breaking versions of addon.""" return self.data[ATTR_BREAKING_VERSIONS] + async def long_description(self) -> str | None: + """Return README.md as long_description.""" + + def read_readme() -> str | None: + readme = Path(self.path_location, "README.md") + + # If readme not exists + if not readme.exists(): + return None + + # Return data + return readme.read_text(encoding="utf-8") + + return await self.sys_run_in_executor(read_readme) + def refresh_path_cache(self) -> Awaitable[None]: """Refresh cache of existing paths.""" diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index f71e028f9d3..fe3f17ace88 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -10,7 +10,7 @@ from ..const import AddonState from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import APIAddonNotInstalled, HostNotSupportedError -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .addons import APIAddons from .audio import APIAudio from .auth import APIAuth @@ -412,7 +412,7 @@ async def get_supervisor_logs(*args, **kwargs): if not isinstance(err, HostNotSupportedError): # No need to capture HostNotSupportedError to Sentry, the cause # is known and reported to the user using the resolution center. - capture_exception(err) + await async_capture_exception(err) kwargs.pop("follow", None) # Follow is not supported for Docker logs return await api_supervisor.logs(*args, **kwargs) diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index 7b862061d2e..0a2e6bef879 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -212,7 +212,7 @@ async def info(self, request: web.Request) -> dict[str, Any]: ATTR_HOSTNAME: addon.hostname, ATTR_DNS: addon.dns, ATTR_DESCRIPTON: addon.description, - ATTR_LONG_DESCRIPTION: addon.long_description, + ATTR_LONG_DESCRIPTION: await addon.long_description(), ATTR_ADVANCED: addon.advanced, ATTR_STAGE: addon.stage, ATTR_REPOSITORY: addon.repository, diff --git a/supervisor/api/host.py b/supervisor/api/host.py index 7d2cc6650b2..6e3399420ae 100644 --- a/supervisor/api/host.py +++ b/supervisor/api/host.py @@ -98,10 +98,10 @@ async def info(self, request): ATTR_VIRTUALIZATION: self.sys_host.info.virtualization, ATTR_CPE: self.sys_host.info.cpe, ATTR_DEPLOYMENT: self.sys_host.info.deployment, - ATTR_DISK_FREE: self.sys_host.info.free_space, - ATTR_DISK_TOTAL: self.sys_host.info.total_space, - ATTR_DISK_USED: self.sys_host.info.used_space, - ATTR_DISK_LIFE_TIME: self.sys_host.info.disk_life_time, + ATTR_DISK_FREE: await self.sys_host.info.free_space(), + ATTR_DISK_TOTAL: await self.sys_host.info.total_space(), + ATTR_DISK_USED: await self.sys_host.info.used_space(), + ATTR_DISK_LIFE_TIME: await self.sys_host.info.disk_life_time(), ATTR_FEATURES: self.sys_host.features, ATTR_HOSTNAME: self.sys_host.info.hostname, ATTR_LLMNR_HOSTNAME: self.sys_host.info.llmnr_hostname, diff --git a/supervisor/api/store.py b/supervisor/api/store.py index 9f689cbaca6..cfed482c3d9 100644 --- a/supervisor/api/store.py +++ b/supervisor/api/store.py @@ -69,12 +69,12 @@ ) -def _read_static_file(path: Path) -> Any: +def _read_static_file(path: Path, binary: bool = False) -> Any: """Read in a static file asset for API output. Must be run in executor. """ - with path.open("r") as asset: + with path.open("rb" if binary else "r") as asset: return asset.read() @@ -109,7 +109,7 @@ def _extract_repository(self, request: web.Request) -> Repository: return self.sys_store.get(repository_slug) - def _generate_addon_information( + async def _generate_addon_information( self, addon: AddonStore, extended: bool = False ) -> dict[str, Any]: """Generate addon information.""" @@ -156,7 +156,7 @@ def _generate_addon_information( ATTR_HOST_NETWORK: addon.host_network, ATTR_HOST_PID: addon.host_pid, ATTR_INGRESS: addon.with_ingress, - ATTR_LONG_DESCRIPTION: addon.long_description, + ATTR_LONG_DESCRIPTION: await addon.long_description(), ATTR_RATING: rating_security(addon), ATTR_SIGNED: addon.signed, } @@ -185,10 +185,12 @@ async def reload(self, request: web.Request) -> None: async def store_info(self, request: web.Request) -> dict[str, Any]: """Return store information.""" return { - ATTR_ADDONS: [ - self._generate_addon_information(self.sys_addons.store[addon]) - for addon in self.sys_addons.store - ], + ATTR_ADDONS: await asyncio.gather( + *[ + self._generate_addon_information(self.sys_addons.store[addon]) + for addon in self.sys_addons.store + ] + ), ATTR_REPOSITORIES: [ self._generate_repository_information(repository) for repository in self.sys_store.all @@ -199,10 +201,12 @@ async def store_info(self, request: web.Request) -> dict[str, Any]: async def addons_list(self, request: web.Request) -> dict[str, Any]: """Return all store add-ons.""" return { - ATTR_ADDONS: [ - self._generate_addon_information(self.sys_addons.store[addon]) - for addon in self.sys_addons.store - ] + ATTR_ADDONS: await asyncio.gather( + *[ + self._generate_addon_information(self.sys_addons.store[addon]) + for addon in self.sys_addons.store + ] + ) } @api_process @@ -234,7 +238,7 @@ async def addons_addon_info(self, request: web.Request) -> dict[str, Any]: 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) - return self._generate_addon_information(addon, True) + return await self._generate_addon_information(addon, True) @api_process_raw(CONTENT_TYPE_PNG) async def addons_addon_icon(self, request: web.Request) -> bytes: @@ -243,7 +247,7 @@ async def addons_addon_icon(self, request: web.Request) -> bytes: if not addon.with_icon: raise APIError(f"No icon found for add-on {addon.slug}!") - return await self.sys_run_in_executor(_read_static_file, addon.path_icon) + return await self.sys_run_in_executor(_read_static_file, addon.path_icon, True) @api_process_raw(CONTENT_TYPE_PNG) async def addons_addon_logo(self, request: web.Request) -> bytes: @@ -252,7 +256,7 @@ async def addons_addon_logo(self, request: web.Request) -> bytes: if not addon.with_logo: raise APIError(f"No logo found for add-on {addon.slug}!") - return await self.sys_run_in_executor(_read_static_file, addon.path_logo) + return await self.sys_run_in_executor(_read_static_file, addon.path_logo, True) @api_process_raw(CONTENT_TYPE_TEXT) async def addons_addon_changelog(self, request: web.Request) -> str: diff --git a/supervisor/backups/manager.py b/supervisor/backups/manager.py index a6403710002..ed60ea1dec5 100644 --- a/supervisor/backups/manager.py +++ b/supervisor/backups/manager.py @@ -36,7 +36,7 @@ from ..utils.common import FileConfiguration from ..utils.dt import utcnow from ..utils.sentinel import DEFAULT -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .backup import Backup from .const import ( DEFAULT_FREEZE_TIMEOUT, @@ -525,7 +525,7 @@ async def _do_backup( return None except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Backup %s error", backup.slug) - capture_exception(err) + await async_capture_exception(err) self.sys_jobs.current.capture_error( BackupError(f"Backup {backup.slug} error, see supervisor logs") ) @@ -718,7 +718,7 @@ async def _do_restore( raise except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Restore %s error", backup.slug) - capture_exception(err) + await async_capture_exception(err) raise BackupError( f"Restore {backup.slug} error, see supervisor logs" ) from err diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index 64ded55ce9c..c6ccfa14d7d 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -1,6 +1,7 @@ """Bootstrap Supervisor.""" # ruff: noqa: T100 +from importlib import import_module import logging import os import signal @@ -306,12 +307,12 @@ def reg_signal(loop, coresys: CoreSys) -> None: _LOGGER.warning("Could not bind to SIGINT") -def supervisor_debugger(coresys: CoreSys) -> None: +async def supervisor_debugger(coresys: CoreSys) -> None: """Start debugger if needed.""" if not coresys.config.debug: return - # pylint: disable=import-outside-toplevel - import debugpy + + debugpy = await coresys.run_in_executor(import_module, "debugpy") _LOGGER.info("Initializing Supervisor debugger") diff --git a/supervisor/core.py b/supervisor/core.py index 211cedfc511..913c4c8aa16 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -26,7 +26,7 @@ from .homeassistant.core import LANDINGPAGE from .resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason from .utils.dt import utcnow -from .utils.sentry import capture_exception +from .utils.sentry import async_capture_exception from .utils.whoami import WhoamiData, retrieve_whoami _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -172,7 +172,7 @@ async def setup(self): "Fatal error happening on load Task %s: %s", setup_task, err ) self.sys_resolution.unhealthy = UnhealthyReason.SETUP - capture_exception(err) + await async_capture_exception(err) # Set OS Agent diagnostics if needed if ( @@ -189,7 +189,7 @@ async def setup(self): self.sys_config.diagnostics, err, ) - capture_exception(err) + await async_capture_exception(err) # Evaluate the system await self.sys_resolution.evaluate.evaluate_system() @@ -246,12 +246,12 @@ async def start(self): await self.sys_homeassistant.core.start() except HomeAssistantCrashError as err: _LOGGER.error("Can't start Home Assistant Core - rebuiling") - capture_exception(err) + await async_capture_exception(err) with suppress(HomeAssistantError): await self.sys_homeassistant.core.rebuild() except HomeAssistantError as err: - capture_exception(err) + await async_capture_exception(err) else: _LOGGER.info("Skipping start of Home Assistant") diff --git a/supervisor/dbus/network/__init__.py b/supervisor/dbus/network/__init__.py index 741aceb0fc8..502a759b861 100644 --- a/supervisor/dbus/network/__init__.py +++ b/supervisor/dbus/network/__init__.py @@ -15,7 +15,7 @@ HostNotSupportedError, NetworkInterfaceNotFound, ) -from ...utils.sentry import capture_exception +from ...utils.sentry import async_capture_exception from ..const import ( DBUS_ATTR_CONNECTION_ENABLED, DBUS_ATTR_DEVICES, @@ -223,13 +223,13 @@ async def update(self, changed: dict[str, Any] | None = None) -> None: device, err, ) - capture_exception(err) + await async_capture_exception(err) return except Exception as err: # pylint: disable=broad-except _LOGGER.exception( "Unkown error while processing %s: %s", device, err ) - capture_exception(err) + await async_capture_exception(err) continue # Skeep interface diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index cb825189607..6f02e313073 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -42,7 +42,7 @@ from ..jobs.const import JobCondition, JobExecutionLimit from ..jobs.decorator import Job from ..resolution.const import CGROUP_V2_VERSION, ContextType, IssueType, SuggestionType -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .const import ( ENV_TIME, ENV_TOKEN, @@ -606,7 +606,7 @@ async def run(self) -> None: ) except CoreDNSError as err: _LOGGER.warning("Can't update DNS for %s", self.name) - capture_exception(err) + await async_capture_exception(err) # Hardware Access if self.addon.static_devices: @@ -787,7 +787,7 @@ async def stop(self, remove_container: bool = True) -> None: await self.sys_plugins.dns.delete_host(self.addon.hostname) except CoreDNSError as err: _LOGGER.warning("Can't update DNS for %s", self.name) - capture_exception(err) + await async_capture_exception(err) # Hardware if self._hw_listener: diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 44446d53fd8..6c8c843d861 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -42,7 +42,7 @@ from ..jobs.decorator import Job from ..jobs.job_group import JobGroup from ..resolution.const import ContextType, IssueType, SuggestionType -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .const import ContainerState, RestartPolicy from .manager import CommandReturn from .monitor import DockerContainerStateEvent @@ -278,7 +278,7 @@ async def install( f"Can't install {image}:{version!s}: {err}", _LOGGER.error ) from err except (docker.errors.DockerException, requests.RequestException) as err: - capture_exception(err) + await async_capture_exception(err) raise DockerError( f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error ) from err @@ -394,7 +394,7 @@ async def _run(self, **kwargs) -> None: ) except DockerNotFound as err: # If image is missing, capture the exception as this shouldn't happen - capture_exception(err) + await async_capture_exception(err) raise # Store metadata diff --git a/supervisor/hardware/disk.py b/supervisor/hardware/disk.py index 649a63e3733..531559b6c4d 100644 --- a/supervisor/hardware/disk.py +++ b/supervisor/hardware/disk.py @@ -49,17 +49,26 @@ def is_used_by_system(self, device: Device) -> bool: return False def get_disk_total_space(self, path: str | Path) -> float: - """Return total space (GiB) on disk for path.""" + """Return total space (GiB) on disk for path. + + Must be run in executor. + """ total, _, _ = shutil.disk_usage(path) return round(total / (1024.0**3), 1) def get_disk_used_space(self, path: str | Path) -> float: - """Return used space (GiB) on disk for path.""" + """Return used space (GiB) on disk for path. + + Must be run in executor. + """ _, used, _ = shutil.disk_usage(path) return round(used / (1024.0**3), 1) def get_disk_free_space(self, path: str | Path) -> float: - """Return free space (GiB) on disk for path.""" + """Return free space (GiB) on disk for path. + + Must be run in executor. + """ _, _, free = shutil.disk_usage(path) return round(free / (1024.0**3), 1) @@ -113,7 +122,10 @@ def _try_get_emmc_life_time(self, device_name: str) -> float: return life_time_value * 10.0 def get_disk_life_time(self, path: str | Path) -> float: - """Return life time estimate of the underlying SSD drive.""" + """Return life time estimate of the underlying SSD drive. + + Must be run in executor. + """ mount_source = self._get_mount_source(str(path)) if mount_source == "overlay": return None diff --git a/supervisor/homeassistant/core.py b/supervisor/homeassistant/core.py index 5d937f34812..2137a377c1f 100644 --- a/supervisor/homeassistant/core.py +++ b/supervisor/homeassistant/core.py @@ -33,7 +33,7 @@ from ..jobs.job_group import JobGroup from ..resolution.const import ContextType, IssueType from ..utils import convert_to_ascii -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .const import ( LANDINGPAGE, SAFE_MODE_FILENAME, @@ -160,7 +160,7 @@ async def install_landingpage(self) -> None: except (DockerError, JobException): pass except Exception as err: # pylint: disable=broad-except - capture_exception(err) + await async_capture_exception(err) _LOGGER.warning("Failed to install landingpage, retrying after 30sec") await asyncio.sleep(30) @@ -192,7 +192,7 @@ async def install(self) -> None: except (DockerError, JobException): pass except Exception as err: # pylint: disable=broad-except - capture_exception(err) + await async_capture_exception(err) _LOGGER.warning("Error on Home Assistant installation. Retrying in 30sec") await asyncio.sleep(30) @@ -557,7 +557,7 @@ async def _restart_after_problem(self, state: ContainerState): try: await self.start() except HomeAssistantError as err: - capture_exception(err) + await async_capture_exception(err) else: break @@ -569,7 +569,7 @@ async def _restart_after_problem(self, state: ContainerState): except HomeAssistantError as err: attempts = attempts + 1 _LOGGER.error("Watchdog restart of Home Assistant failed!") - capture_exception(err) + await async_capture_exception(err) else: break diff --git a/supervisor/host/info.py b/supervisor/host/info.py index 87c7a041c38..58d6f065651 100644 --- a/supervisor/host/info.py +++ b/supervisor/host/info.py @@ -103,38 +103,38 @@ def boot_timestamp(self) -> int | None: return self.sys_dbus.systemd.boot_timestamp @property - def total_space(self) -> float: + def virtualization(self) -> str | None: + """Return virtualization hypervisor being used.""" + return self.sys_dbus.systemd.virtualization + + async def total_space(self) -> float: """Return total space (GiB) on disk for supervisor data directory.""" - return self.sys_hardware.disk.get_disk_total_space( - self.coresys.config.path_supervisor + return await self.sys_run_in_executor( + self.sys_hardware.disk.get_disk_total_space, + self.coresys.config.path_supervisor, ) - @property - def used_space(self) -> float: + async def used_space(self) -> float: """Return used space (GiB) on disk for supervisor data directory.""" - return self.sys_hardware.disk.get_disk_used_space( - self.coresys.config.path_supervisor + return await self.sys_run_in_executor( + self.sys_hardware.disk.get_disk_used_space, + self.coresys.config.path_supervisor, ) - @property - def free_space(self) -> float: + async def free_space(self) -> float: """Return available space (GiB) on disk for supervisor data directory.""" - return self.sys_hardware.disk.get_disk_free_space( - self.coresys.config.path_supervisor + return await self.sys_run_in_executor( + self.sys_hardware.disk.get_disk_free_space, + self.coresys.config.path_supervisor, ) - @property - def disk_life_time(self) -> float: + async def disk_life_time(self) -> float: """Return the estimated life-time usage (in %) of the SSD storing the data directory.""" - return self.sys_hardware.disk.get_disk_life_time( - self.coresys.config.path_supervisor + return await self.sys_run_in_executor( + self.sys_hardware.disk.get_disk_life_time, + self.coresys.config.path_supervisor, ) - @property - def virtualization(self) -> str | None: - """Return virtualization hypervisor being used.""" - return self.sys_dbus.systemd.virtualization - async def get_dmesg(self) -> bytes: """Return host dmesg output.""" proc = await asyncio.create_subprocess_shell( diff --git a/supervisor/jobs/__init__.py b/supervisor/jobs/__init__.py index ca8c2d07ba6..6aaca8762da 100644 --- a/supervisor/jobs/__init__.py +++ b/supervisor/jobs/__init__.py @@ -20,7 +20,6 @@ from ..homeassistant.const import WSEvent from ..utils.common import FileConfiguration from ..utils.dt import utcnow -from ..utils.sentry import capture_exception from .const import ATTR_IGNORE_CONDITIONS, FILE_CONFIG_JOBS, JobCondition from .validate import SCHEMA_JOBS_CONFIG @@ -191,9 +190,10 @@ def current(self) -> SupervisorJob: """ try: return self.get_job(_CURRENT_JOB.get()) - except (LookupError, JobNotFound) as err: - capture_exception(err) - raise RuntimeError("No job for the current asyncio task!") from None + except (LookupError, JobNotFound): + raise RuntimeError( + "No job for the current asyncio task!", _LOGGER.critical + ) from None @property def is_job(self) -> bool: diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index 5f8934b2193..033cda757bd 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -18,7 +18,7 @@ ) from ..host.const import HostFeature from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from . import SupervisorJob from .const import JobCondition, JobExecutionLimit from .job_group import JobGroup @@ -313,7 +313,7 @@ async def wrapper( except Exception as err: _LOGGER.exception("Unhandled exception: %s", err) job.capture_error() - capture_exception(err) + await async_capture_exception(err) raise JobException() from err finally: self._release_exception_limits() @@ -373,13 +373,14 @@ async def check_conditions( if ( JobCondition.FREE_SPACE in used_conditions - and coresys.sys_host.info.free_space < MINIMUM_FREE_SPACE_THRESHOLD + and (free_space := await coresys.sys_host.info.free_space()) + < MINIMUM_FREE_SPACE_THRESHOLD ): coresys.sys_resolution.create_issue( IssueType.FREE_SPACE, ContextType.SYSTEM ) raise JobConditionException( - f"'{method_name}' blocked from execution, not enough free space ({coresys.sys_host.info.free_space}GB) left on the device" + f"'{method_name}' blocked from execution, not enough free space ({free_space}GB) left on the device" ) if JobCondition.INTERNET_SYSTEM in used_conditions: diff --git a/supervisor/misc/filter.py b/supervisor/misc/filter.py index 796072422c0..614209febaf 100644 --- a/supervisor/misc/filter.py +++ b/supervisor/misc/filter.py @@ -80,7 +80,9 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict: "arch": coresys.arch.default, "board": coresys.os.board, "deployment": coresys.host.info.deployment, - "disk_free_space": coresys.host.info.free_space, + "disk_free_space": coresys.hardware.disk.get_disk_free_space( + coresys.config.path_supervisor + ), "host": coresys.host.info.operating_system, "kernel": coresys.host.info.kernel, "machine": coresys.machine, diff --git a/supervisor/misc/tasks.py b/supervisor/misc/tasks.py index 46b847e3de9..4cc1c4e30f2 100644 --- a/supervisor/misc/tasks.py +++ b/supervisor/misc/tasks.py @@ -19,7 +19,7 @@ from ..jobs.decorator import Job, JobCondition, JobExecutionLimit from ..plugins.const import PLUGIN_UPDATE_CONDITIONS from ..utils.dt import utcnow -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -224,7 +224,7 @@ async def _watchdog_homeassistant_api(self): await self.sys_homeassistant.core.restart() except HomeAssistantError as err: if reanimate_fails == 0 or safe_mode: - capture_exception(err) + await async_capture_exception(err) if safe_mode: _LOGGER.critical( @@ -341,7 +341,7 @@ async def _watchdog_addon_application(self): await (await addon.restart()) except AddonsError as err: _LOGGER.error("%s watchdog reanimation failed with %s", addon.slug, err) - capture_exception(err) + await async_capture_exception(err) finally: self._cache[addon.slug] = 0 diff --git a/supervisor/mounts/manager.py b/supervisor/mounts/manager.py index 2bd54a2d8c2..8289d0389c5 100644 --- a/supervisor/mounts/manager.py +++ b/supervisor/mounts/manager.py @@ -18,7 +18,7 @@ from ..jobs.decorator import Job from ..resolution.const import SuggestionType from ..utils.common import FileConfiguration -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .const import ( ATTR_DEFAULT_BACKUP_MOUNT, ATTR_MOUNTS, @@ -177,7 +177,7 @@ async def _mount_errors_to_issues( if mounts[i].failed_issue in self.sys_resolution.issues: continue if not isinstance(errors[i], MountError): - capture_exception(errors[i]) + await async_capture_exception(errors[i]) self.sys_resolution.add_issue( evolve(mounts[i].failed_issue), diff --git a/supervisor/mounts/mount.py b/supervisor/mounts/mount.py index 61b69b416f1..59b7a766581 100644 --- a/supervisor/mounts/mount.py +++ b/supervisor/mounts/mount.py @@ -40,7 +40,7 @@ ) from ..resolution.const import ContextType, IssueType from ..resolution.data import Issue -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .const import ( ATTR_PATH, ATTR_READ_ONLY, @@ -208,7 +208,7 @@ async def _update_state(self) -> UnitActiveState | None: try: self._state = await self.unit.get_active_state() except DBusError as err: - capture_exception(err) + await async_capture_exception(err) raise MountError( f"Could not get active state of mount due to: {err!s}" ) from err @@ -221,7 +221,7 @@ async def _update_unit(self) -> SystemdUnit | None: self._unit = None self._state = None except DBusError as err: - capture_exception(err) + await async_capture_exception(err) raise MountError(f"Could not get mount unit due to: {err!s}") from err return self.unit diff --git a/supervisor/os/data_disk.py b/supervisor/os/data_disk.py index 40be89d24f6..52cb93b30da 100644 --- a/supervisor/os/data_disk.py +++ b/supervisor/os/data_disk.py @@ -26,7 +26,7 @@ from ..jobs.decorator import Job from ..resolution.checks.disabled_data_disk import CheckDisabledDataDisk from ..resolution.checks.multiple_data_disks import CheckMultipleDataDisks -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .const import ( FILESYSTEM_LABEL_DATA_DISK, FILESYSTEM_LABEL_DISABLED_DATA_DISK, @@ -337,7 +337,7 @@ async def _format_device_with_single_partition( try: await block_device.format(FormatType.GPT) except DBusError as err: - capture_exception(err) + await async_capture_exception(err) raise HassOSDataDiskError( f"Could not format {new_disk.id}: {err!s}", _LOGGER.error ) from err @@ -354,7 +354,7 @@ async def _format_device_with_single_partition( 0, 0, LINUX_DATA_PARTITION_GUID, PARTITION_NAME_EXTERNAL_DATA_DISK ) except DBusError as err: - capture_exception(err) + await async_capture_exception(err) raise HassOSDataDiskError( f"Could not create new data partition: {err!s}", _LOGGER.error ) from err diff --git a/supervisor/os/manager.py b/supervisor/os/manager.py index e894c1fa2ed..4906544e619 100644 --- a/supervisor/os/manager.py +++ b/supervisor/os/manager.py @@ -24,7 +24,7 @@ from ..jobs.const import JobCondition, JobExecutionLimit from ..jobs.decorator import Job from ..resolution.const import UnhealthyReason -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .data_disk import DataDisk _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -385,7 +385,7 @@ async def set_boot_slot(self, boot_name: str) -> None: RaucState.ACTIVE, self.get_slot_name(boot_name) ) except DBusError as err: - capture_exception(err) + await async_capture_exception(err) raise HassOSSlotUpdateError( f"Can't mark {boot_name} as active!", _LOGGER.error ) from err diff --git a/supervisor/plugins/audio.py b/supervisor/plugins/audio.py index 2911e72411d..98f0b3f122b 100644 --- a/supervisor/plugins/audio.py +++ b/supervisor/plugins/audio.py @@ -27,7 +27,7 @@ from ..jobs.decorator import Job from ..resolution.const import UnhealthyReason from ..utils.json import write_json_file -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .base import PluginBase from .const import ( FILE_HASSIO_AUDIO, @@ -163,7 +163,7 @@ async def repair(self) -> None: await self.instance.install(self.version) except DockerError as err: _LOGGER.error("Repair of Audio failed") - capture_exception(err) + await async_capture_exception(err) def pulse_client(self, input_profile=None, output_profile=None) -> str: """Generate an /etc/pulse/client.conf data.""" diff --git a/supervisor/plugins/base.py b/supervisor/plugins/base.py index 1b4b76944bf..b1252817fe6 100644 --- a/supervisor/plugins/base.py +++ b/supervisor/plugins/base.py @@ -15,7 +15,7 @@ from ..docker.monitor import DockerContainerStateEvent from ..exceptions import DockerError, PluginError from ..utils.common import FileConfiguration -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .const import WATCHDOG_MAX_ATTEMPTS, WATCHDOG_RETRY_SECONDS _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -129,7 +129,7 @@ async def _restart_after_problem(self, state: ContainerState): except PluginError as err: attempts = attempts + 1 _LOGGER.error("Watchdog restart of %s plugin failed!", self.slug) - capture_exception(err) + await async_capture_exception(err) else: break diff --git a/supervisor/plugins/cli.py b/supervisor/plugins/cli.py index d5d1d530df6..d6758e46f21 100644 --- a/supervisor/plugins/cli.py +++ b/supervisor/plugins/cli.py @@ -17,7 +17,7 @@ from ..exceptions import CliError, CliJobError, CliUpdateError, DockerError from ..jobs.const import JobExecutionLimit from ..jobs.decorator import Job -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .base import PluginBase from .const import ( FILE_HASSIO_CLI, @@ -114,7 +114,7 @@ async def repair(self) -> None: await self.instance.install(self.version) except DockerError as err: _LOGGER.error("Repair of HA cli failed") - capture_exception(err) + await async_capture_exception(err) @Job( name="plugin_cli_restart_after_problem", diff --git a/supervisor/plugins/dns.py b/supervisor/plugins/dns.py index 3664ef32def..ca8a58c5257 100644 --- a/supervisor/plugins/dns.py +++ b/supervisor/plugins/dns.py @@ -33,7 +33,7 @@ from ..jobs.decorator import Job from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason from ..utils.json import write_json_file -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from ..validate import dns_url from .base import PluginBase from .const import ( @@ -410,7 +410,7 @@ async def repair(self) -> None: await self.instance.install(self.version) except DockerError as err: _LOGGER.error("Repair of CoreDNS failed") - capture_exception(err) + await async_capture_exception(err) def _write_resolv(self, resolv_conf: Path) -> None: """Update/Write resolv.conf file.""" diff --git a/supervisor/plugins/manager.py b/supervisor/plugins/manager.py index 4efc0b9ff24..987e32aa91c 100644 --- a/supervisor/plugins/manager.py +++ b/supervisor/plugins/manager.py @@ -7,7 +7,7 @@ from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import HassioError from ..resolution.const import ContextType, IssueType, SuggestionType -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .audio import PluginAudio from .base import PluginBase from .cli import PluginCli @@ -80,7 +80,7 @@ async def load(self) -> None: reference=plugin.slug, suggestions=[SuggestionType.EXECUTE_REPAIR], ) - capture_exception(err) + await async_capture_exception(err) # Exit if supervisor out of date. Plugins can't update until then if self.sys_supervisor.need_update: @@ -114,7 +114,7 @@ async def load(self) -> None: ) except Exception as err: # pylint: disable=broad-except _LOGGER.warning("Can't update plugin %s: %s", plugin.slug, err) - capture_exception(err) + await async_capture_exception(err) async def repair(self) -> None: """Repair Supervisor plugins.""" @@ -132,4 +132,4 @@ async def shutdown(self) -> None: await plugin.stop() except Exception as err: # pylint: disable=broad-except _LOGGER.warning("Can't stop plugin %s: %s", plugin.slug, err) - capture_exception(err) + await async_capture_exception(err) diff --git a/supervisor/plugins/multicast.py b/supervisor/plugins/multicast.py index ebcd3debeb5..9e0c22ac562 100644 --- a/supervisor/plugins/multicast.py +++ b/supervisor/plugins/multicast.py @@ -19,7 +19,7 @@ ) from ..jobs.const import JobExecutionLimit from ..jobs.decorator import Job -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .base import PluginBase from .const import ( FILE_HASSIO_MULTICAST, @@ -109,7 +109,7 @@ async def repair(self) -> None: await self.instance.install(self.version) except DockerError as err: _LOGGER.error("Repair of Multicast failed") - capture_exception(err) + await async_capture_exception(err) @Job( name="plugin_multicast_restart_after_problem", diff --git a/supervisor/plugins/observer.py b/supervisor/plugins/observer.py index c2246d0cb76..1d8b7fe76ad 100644 --- a/supervisor/plugins/observer.py +++ b/supervisor/plugins/observer.py @@ -22,7 +22,7 @@ ) from ..jobs.const import JobExecutionLimit from ..jobs.decorator import Job -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .base import PluginBase from .const import ( FILE_HASSIO_OBSERVER, @@ -121,7 +121,7 @@ async def repair(self) -> None: await self.instance.install(self.version) except DockerError as err: _LOGGER.error("Repair of HA observer failed") - capture_exception(err) + await async_capture_exception(err) @Job( name="plugin_observer_restart_after_problem", diff --git a/supervisor/resolution/check.py b/supervisor/resolution/check.py index 019b354e27a..fd476613b0e 100644 --- a/supervisor/resolution/check.py +++ b/supervisor/resolution/check.py @@ -7,7 +7,7 @@ from ..const import ATTR_CHECKS from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ResolutionNotFound -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .checks.base import CheckBase from .validate import get_valid_modules @@ -64,6 +64,6 @@ async def check_system(self) -> None: await check() except Exception as err: # pylint: disable=broad-except _LOGGER.error("Error during processing %s: %s", check.issue, err) - capture_exception(err) + await async_capture_exception(err) _LOGGER.info("System checks complete") diff --git a/supervisor/resolution/checks/dns_server.py b/supervisor/resolution/checks/dns_server.py index 792b89bf40a..55f377e7266 100644 --- a/supervisor/resolution/checks/dns_server.py +++ b/supervisor/resolution/checks/dns_server.py @@ -10,7 +10,7 @@ from ...coresys import CoreSys from ...jobs.const import JobCondition, JobExecutionLimit from ...jobs.decorator import Job -from ...utils.sentry import capture_exception +from ...utils.sentry import async_capture_exception from ..const import DNS_CHECK_HOST, ContextType, IssueType from .base import CheckBase @@ -42,7 +42,7 @@ async def run_check(self) -> None: ContextType.DNS_SERVER, reference=dns_servers[i], ) - capture_exception(results[i]) + await async_capture_exception(results[i]) @Job(name="check_dns_server_approve", conditions=[JobCondition.INTERNET_SYSTEM]) async def approve_check(self, reference: str | None = None) -> bool: diff --git a/supervisor/resolution/checks/dns_server_ipv6.py b/supervisor/resolution/checks/dns_server_ipv6.py index ae589b40f79..ab3d7bb1a58 100644 --- a/supervisor/resolution/checks/dns_server_ipv6.py +++ b/supervisor/resolution/checks/dns_server_ipv6.py @@ -10,7 +10,7 @@ from ...coresys import CoreSys from ...jobs.const import JobCondition, JobExecutionLimit from ...jobs.decorator import Job -from ...utils.sentry import capture_exception +from ...utils.sentry import async_capture_exception from ..const import DNS_CHECK_HOST, DNS_ERROR_NO_DATA, ContextType, IssueType from .base import CheckBase @@ -47,7 +47,7 @@ async def run_check(self) -> None: ContextType.DNS_SERVER, reference=dns_servers[i], ) - capture_exception(results[i]) + await async_capture_exception(results[i]) @Job( name="check_dns_server_ipv6_approve", conditions=[JobCondition.INTERNET_SYSTEM] diff --git a/supervisor/resolution/checks/free_space.py b/supervisor/resolution/checks/free_space.py index 6fb6343d785..4b676384138 100644 --- a/supervisor/resolution/checks/free_space.py +++ b/supervisor/resolution/checks/free_space.py @@ -23,7 +23,7 @@ class CheckFreeSpace(CheckBase): async def run_check(self) -> None: """Run check if not affected by issue.""" - if self.sys_host.info.free_space > MINIMUM_FREE_SPACE_THRESHOLD: + if await self.sys_host.info.free_space() > MINIMUM_FREE_SPACE_THRESHOLD: return suggestions: list[SuggestionType] = [] @@ -45,7 +45,7 @@ async def run_check(self) -> None: async def approve_check(self, reference: str | None = None) -> bool: """Approve check if it is affected by issue.""" - if self.sys_host.info.free_space > MINIMUM_FREE_SPACE_THRESHOLD: + if await self.sys_host.info.free_space() > MINIMUM_FREE_SPACE_THRESHOLD: return False return True diff --git a/supervisor/resolution/evaluate.py b/supervisor/resolution/evaluate.py index 80eeee484f3..8929ec2696f 100644 --- a/supervisor/resolution/evaluate.py +++ b/supervisor/resolution/evaluate.py @@ -5,7 +5,7 @@ from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ResolutionNotFound -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .const import UnhealthyReason, UnsupportedReason from .evaluations.base import EvaluateBase from .validate import get_valid_modules @@ -64,7 +64,7 @@ async def evaluate_system(self) -> None: _LOGGER.warning( "Error during processing %s: %s", evaluation.reason, err ) - capture_exception(err) + await async_capture_exception(err) if any(reason in self.sys_resolution.unsupported for reason in UNHEALTHY): self.sys_resolution.unhealthy = UnhealthyReason.DOCKER diff --git a/supervisor/resolution/fixup.py b/supervisor/resolution/fixup.py index c929246c5f2..cddc80fecfd 100644 --- a/supervisor/resolution/fixup.py +++ b/supervisor/resolution/fixup.py @@ -6,7 +6,7 @@ from ..coresys import CoreSys, CoreSysAttributes from ..jobs.const import JobCondition from ..jobs.decorator import Job -from ..utils.sentry import capture_exception +from ..utils.sentry import async_capture_exception from .data import Issue, Suggestion from .fixups.base import FixupBase from .validate import get_valid_modules @@ -55,7 +55,7 @@ async def run_autofix(self) -> None: await fix() except Exception as err: # pylint: disable=broad-except _LOGGER.warning("Error during processing %s: %s", fix.suggestion, err) - capture_exception(err) + await async_capture_exception(err) _LOGGER.info("System autofix complete") diff --git a/supervisor/resolution/notify.py b/supervisor/resolution/notify.py index 294d29d79fb..1bd91b97cce 100644 --- a/supervisor/resolution/notify.py +++ b/supervisor/resolution/notify.py @@ -36,7 +36,7 @@ async def issue_notifications(self): messages.append( { "title": "Available space is less than 1GB!", - "message": f"Available space is {self.sys_host.info.free_space}GB, see https://www.home-assistant.io/more-info/free-space for more information.", + "message": f"Available space is {await self.sys_host.info.free_space()}GB, see https://www.home-assistant.io/more-info/free-space for more information.", "notification_id": "supervisor_issue_free_space", } ) diff --git a/supervisor/supervisor.py b/supervisor/supervisor.py index 0b8a6527431..4e0d01b96e2 100644 --- a/supervisor/supervisor.py +++ b/supervisor/supervisor.py @@ -36,7 +36,7 @@ from .jobs.decorator import Job from .resolution.const import ContextType, IssueType, UnhealthyReason from .utils.codenotary import calc_checksum -from .utils.sentry import capture_exception +from .utils.sentry import async_capture_exception _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -219,7 +219,7 @@ async def update(self, version: AwesomeVersion | None = None) -> None: self.sys_resolution.create_issue( IssueType.UPDATE_FAILED, ContextType.SUPERVISOR ) - capture_exception(err) + await async_capture_exception(err) raise SupervisorUpdateError( f"Update of Supervisor failed: {err!s}", _LOGGER.critical ) from err diff --git a/supervisor/utils/dbus.py b/supervisor/utils/dbus.py index b24628d654d..a5bc4d10258 100644 --- a/supervisor/utils/dbus.py +++ b/supervisor/utils/dbus.py @@ -35,7 +35,7 @@ DBusTimeoutError, HassioNotSupportedError, ) -from .sentry import capture_exception +from .sentry import async_capture_exception _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -124,7 +124,7 @@ async def call_dbus( ) raise DBus.from_dbus_error(err) from None except Exception as err: # pylint: disable=broad-except - capture_exception(err) + await async_capture_exception(err) raise DBusFatalError(str(err)) from err def _add_interfaces(self): diff --git a/supervisor/utils/log_format.py b/supervisor/utils/log_format.py index c73dbe3bf4e..503f8d6ef74 100644 --- a/supervisor/utils/log_format.py +++ b/supervisor/utils/log_format.py @@ -3,8 +3,6 @@ import logging import re -from .sentry import capture_exception - _LOGGER: logging.Logger = logging.getLogger(__name__) RE_BIND_FAILED = re.compile( @@ -13,13 +11,11 @@ def format_message(message: str) -> str: - """Return a formated message if it's known.""" - try: - match = RE_BIND_FAILED.match(message) - if match: - return f"Port '{match.group(1)}' is already in use by something else on the host." - except TypeError as err: - _LOGGER.error("The type of message is not a string - %s", err) - capture_exception(err) + """Return a formatted message if it's known.""" + match = RE_BIND_FAILED.match(message) + if match: + return ( + f"Port '{match.group(1)}' is already in use by something else on the host." + ) return message diff --git a/supervisor/utils/sentry.py b/supervisor/utils/sentry.py index da6fa01906e..aae6118ed09 100644 --- a/supervisor/utils/sentry.py +++ b/supervisor/utils/sentry.py @@ -1,5 +1,6 @@ """Utilities for sentry.""" +import asyncio from functools import partial import logging from typing import Any @@ -46,19 +47,43 @@ def init_sentry(coresys: CoreSys) -> None: def capture_event(event: dict[str, Any], only_once: str | None = None): - """Capture an event and send to sentry.""" + """Capture an event and send to sentry. + + Must be called in executor. + """ if sentry_sdk.is_initialized(): if only_once and only_once not in only_once_events: only_once_events.add(only_once) sentry_sdk.capture_event(event) +async def async_capture_event(event: dict[str, Any], only_once: str | None = None): + """Capture an event and send to sentry. + + Safe to call from event loop. + """ + await asyncio.get_running_loop().run_in_executor( + None, capture_event, event, only_once + ) + + def capture_exception(err: Exception) -> None: - """Capture an exception and send to sentry.""" + """Capture an exception and send to sentry. + + Must be called in executor. + """ if sentry_sdk.is_initialized(): sentry_sdk.capture_exception(err) +async def async_capture_exception(err: Exception) -> None: + """Capture an exception and send to sentry. + + Safe to call in event loop. + """ + await asyncio.get_running_loop().run_in_executor(None, capture_exception, err) + + def close_sentry() -> None: """Close the current sentry client. diff --git a/tests/api/test_supervisor.py b/tests/api/test_supervisor.py index 06735137c26..330818aaca3 100644 --- a/tests/api/test_supervisor.py +++ b/tests/api/test_supervisor.py @@ -216,7 +216,7 @@ async def test_api_supervisor_fallback_log_capture( "No systemd-journal-gatewayd Unix socket available!" ) - with patch("supervisor.api.capture_exception") as capture_exception: + with patch("supervisor.api.async_capture_exception") as capture_exception: await api_client.get("/supervisor/logs") capture_exception.assert_not_called() @@ -224,7 +224,7 @@ async def test_api_supervisor_fallback_log_capture( journald_logs.side_effect = HassioError("Something bad happened!") - with patch("supervisor.api.capture_exception") as capture_exception: + with patch("supervisor.api.async_capture_exception") as capture_exception: await api_client.get("/supervisor/logs") capture_exception.assert_called_once() diff --git a/tests/homeassistant/test_home_assistant_watchdog.py b/tests/homeassistant/test_home_assistant_watchdog.py index ff5e117c5e1..9208c3aaf0a 100644 --- a/tests/homeassistant/test_home_assistant_watchdog.py +++ b/tests/homeassistant/test_home_assistant_watchdog.py @@ -141,7 +141,7 @@ async def test_home_assistant_watchdog_rebuild_on_failure(coresys: CoreSys) -> N time=1, ), ) - await asyncio.sleep(0) + await asyncio.sleep(0.1) start.assert_called_once() rebuild.assert_called_once() diff --git a/tests/host/test_info.py b/tests/host/test_info.py index 7a25b4c4445..9d83981833d 100644 --- a/tests/host/test_info.py +++ b/tests/host/test_info.py @@ -5,10 +5,10 @@ from supervisor.host.info import InfoCenter -def test_host_free_space(coresys): +async def test_host_free_space(coresys): """Test host free space.""" info = InfoCenter(coresys) with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))): - free = info.free_space + free = await info.free_space() assert free == 2.0 diff --git a/tests/utils/test_log_format.py b/tests/utils/test_log_format.py index f0daf8d4cf0..be1f90462eb 100644 --- a/tests/utils/test_log_format.py +++ b/tests/utils/test_log_format.py @@ -19,9 +19,3 @@ def test_format_message_port_alternative(): format_message(message) == "Port '80' is already in use by something else on the host." ) - - -def test_exeption(): - """Tests the exception handling.""" - message = b"byte" - assert format_message(message) == message