diff --git a/appdaemon/exceptions.py b/appdaemon/exceptions.py index 86b09ff80..680e8b7e7 100644 --- a/appdaemon/exceptions.py +++ b/appdaemon/exceptions.py @@ -1,6 +1,5 @@ """ -Exceptions used by appdaemon - +Custom exceptions used by AppDaemon and helper functions to format them in the logs. """ import asyncio @@ -20,6 +19,7 @@ from aiohttp.client_exceptions import ClientConnectorError, ConnectionTimeoutError from pydantic import ValidationError +from pytz import UnknownTimeZoneError if TYPE_CHECKING: from .appdaemon import AppDaemon @@ -79,19 +79,11 @@ def user_exception_block(logger: Logger, exception: Exception, app_dir: Path | N case AssertionError(): logger.error(f"{indent}{exc.__class__.__name__}: {exc}") continue + case UnknownTimeZoneError() if exc == chain[0]: + logger.error(f"{indent}{exc.__class__.__name__}: {exc}") + logger.error(f"{indent} The specified time zone is not recognized. Check your 'time_zone' setting.") case ValidationError(): - for error in exc.errors(): - match error: - case {"type": "missing", "msg": msg, "loc": loc}: - app_name = loc[0] - field = loc[-1] - logger.error(f"{indent}App '{app_name}' has an assertion error in field '{field}': {msg}") - case {"type": "assertion_error", "msg": msg, "loc": loc}: - app_name = loc[0] - field = loc[-1] - logger.error(f"{indent}Assertion error in app '{app_name}' field '{field}': {msg}") - case _: - pass + validation_block(chain[0], exc, logger, indent) case AppDaemonException(): assert app_dir is not None, "app_dir is required to format exception block" for i, line in enumerate(str(exc).splitlines()): @@ -140,6 +132,39 @@ def user_exception_block(logger: Logger, exception: Exception, app_dir: Path | N logger.error("=" * width) +def validation_block(root: BaseException, exc: ValidationError, logger: Logger, indent: str = " ") -> None: + """Generate a user-friendly block of text for a ValidationError.""" + for error in exc.errors(): + match error: + case {"msg": str(msg), "type": str(type_), "loc": loc}: + match root: + case BadAppConfigFile(): + app_name, *_, field = loc + if type_ == "missing" and field in ("module", "class"): + logger.error(f"{indent}App config error in '{app_name}', missing field '{field}'") + # There's a bunch of other types of validation errors that could come up here, but + # most of them are just confusing to the user, so we skip them. + case _: + # This is an error with appdaemon config, not app config + field_name = '.'.join(map(str, loc)) + input_ = error.get("input") + match type_: + case "missing": + logger.error(f"{indent}Missing required field: {field_name}") + case "extra_forbidden": + logger.error(f"{indent}Unknown field: {field_name}") + case "float_parsing" | "int_parsing": + logger.error(f"{indent}Invalid value for {field_name}: {input_}") + case "value_error": + logger.error(f"{indent}{msg}") + case "url_parsing" | "url_scheme": + logger.error(f"{indent}Invalid URL for {field_name}: {input_}") + case _: + logger.error(f"{indent}'{type_}' error from {loc}") + case _: + logger.error(f"{indent}{error}") + + def unexpected_block(logger: Logger, exception: Exception): logger.error("=" * 75) logger.error(f"Unexpected error: {exception}") diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index bf1580e09..2da1ca1c5 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -3,7 +3,7 @@ from ssl import _SSLMethod from typing import Annotated, Any, Literal -from pydantic import BaseModel, BeforeValidator, Field, SecretBytes, SecretStr, field_validator, model_validator +from pydantic import AnyHttpUrl, BaseModel, BeforeValidator, Field, SecretBytes, SecretStr, field_validator, model_validator from typing_extensions import deprecated @@ -86,8 +86,8 @@ class StartupConditions(BaseModel): class HASSConfig(PluginConfig, extra="forbid"): - ha_url: str = "http://supervisor/core" - token: SecretStr + ha_url: AnyHttpUrl = Field(default="http://supervisor/core", validate_default=True) # pyright: ignore[reportAssignmentType] + token: SecretStr = Field(default_factory=lambda: SecretStr(os.environ.get("SUPERVISOR_TOKEN"))) # pyright: ignore[reportArgumentType] ha_key: Annotated[SecretStr, deprecated("'ha_key' is deprecated. Please use long lived tokens instead")] | None = None appdaemon_startup_conditions: StartupConditions | None = None """Startup conditions that apply only when AppDaemon first starts.""" @@ -108,33 +108,21 @@ class HASSConfig(PluginConfig, extra="forbid"): config_sleep_time: ParsedTimedelta = timedelta(seconds=60) """The sleep time in the background task that updates the config metadata every once in a while""" - @field_validator("ha_key", mode="after") - @classmethod - def validate_ha_key(cls, v: Any): - if v is None: - return os.environ.get("SUPERVISOR_TOKEN") - else: - return v - - @field_validator("ha_url", mode="after") - @classmethod - def validate_ha_url(cls, v: str): - return v.rstrip("/") - @model_validator(mode="after") def custom_validator(self): - assert "token" in self.model_fields_set or "ha_key" in self.model_fields_set, ( - "Either 'token' or 'ha_key' must be set for the Home Assistant plugin" - ) + if self.token.get_secret_value() is None: + raise ValueError( + "Home Assistant token must be set either via 'token' field or 'SUPERVISOR_TOKEN' env variable" + ) return self @property def websocket_url(self) -> str: - return f"{self.ha_url}/api/websocket" + return f"{self.ha_url!s}api/websocket" @property def states_api(self) -> str: - return f"{self.ha_url}/api/states" + return f"{self.ha_url!s}api/states" def get_entity_api(self, entity_id: str) -> str: return f"{self.states_api}/{entity_id}" diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index 8ea662fe6..b47f7ddeb 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -456,7 +456,7 @@ async def http_method( appropriate. """ kwargs = utils.clean_http_kwargs(kwargs) - url = utils.make_endpoint(self.config.ha_url, endpoint) + url = utils.make_endpoint(f"{self.config.ha_url!s}", endpoint) try: self.update_perf(