From 2cd3eecade9daa6939a8c8e56ebca5745267cabe Mon Sep 17 00:00:00 2001 From: Jared Van Bortel Date: Fri, 7 Nov 2025 19:03:30 -0500 Subject: [PATCH 01/12] only clean query parameters --- appdaemon/plugins/hass/hassplugin.py | 6 ++++-- appdaemon/utils.py | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index b47f7ddeb..35fd93e68 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -455,7 +455,9 @@ async def http_method( **kwargs (optional): Zero or more keyword arguments. These get used as the data for the method, as appropriate. """ - kwargs = utils.clean_http_kwargs(kwargs) + if method.lower() in ("get", "delete"): + kwargs = utils.clean_http_kwargs(kwargs) + url = utils.make_endpoint(f"{self.config.ha_url!s}", endpoint) try: @@ -471,7 +473,7 @@ async def http_method( case "post": http_method = functools.partial(self.session.post, json=kwargs) case "delete": - http_method = functools.partial(self.session.delete, json=kwargs) + http_method = functools.partial(self.session.delete, params=kwargs) case _: raise ValueError(f"Invalid method: {method}") diff --git a/appdaemon/utils.py b/appdaemon/utils.py index 9e3552295..5c192e398 100644 --- a/appdaemon/utils.py +++ b/appdaemon/utils.py @@ -862,7 +862,12 @@ def dt_to_str(dt: datetime, tz: tzinfo | None = None, *, round: bool = False) -> def convert_json(data, **kwargs): - return json.dumps(data, default=str, **kwargs) + def fallback_serializer(obj): + if isinstance(obj, datetime): + return obj.isoformat() + return str(obj) + + return json.dumps(data, default=fallback_serializer, **kwargs) def get_object_size(obj, seen=None): From 31528b4df0a89454b608fb565e4acfdd5e83d18c Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:21:33 -0600 Subject: [PATCH 02/12] using urlib for making http endpoints, fixes delete endpoint --- appdaemon/plugins/hass/hassplugin.py | 5 ++--- appdaemon/utils.py | 13 ++++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index 35fd93e68..685bae72d 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -458,7 +458,7 @@ async def http_method( if method.lower() in ("get", "delete"): kwargs = utils.clean_http_kwargs(kwargs) - url = utils.make_endpoint(f"{self.config.ha_url!s}", endpoint) + url = utils.make_endpoint(self.config.ha_url, endpoint) try: self.update_perf( @@ -886,8 +886,7 @@ async def set_plugin_state( @utils.warning_decorator(error_text=f"Error setting state for {entity_id}") async def safe_set_state(self: "HassPlugin"): - api_url = self.config.get_entity_api(entity_id) - return await self.http_method("post", api_url, state=state, attributes=attributes) + return await self.http_method("post", f'/api/states/{entity_id}', state=state, attributes=attributes) return await safe_set_state(self) diff --git a/appdaemon/utils.py b/appdaemon/utils.py index 5c192e398..fe324f79f 100644 --- a/appdaemon/utils.py +++ b/appdaemon/utils.py @@ -24,13 +24,14 @@ from pathlib import Path from time import perf_counter from typing import TYPE_CHECKING, Any, Callable, Coroutine, Literal, ParamSpec, Protocol, TypeVar +from urllib import parse import dateutil.parser import tomli import tomli_w import yaml from astral.location import Location -from pydantic import BaseModel, ValidationError +from pydantic import AnyHttpUrl, BaseModel, ValidationError from pytz import BaseTzInfo from appdaemon.parse import parse_datetime @@ -1172,13 +1173,11 @@ def clean_http_kwargs(val: Any) -> Any: return pruned -def make_endpoint(base: str, endpoint: str) -> str: +def make_endpoint(base: str | AnyHttpUrl, endpoint: str) -> str: """Formats a URL appropriately with slashes""" - if not endpoint.startswith(base): - result = f"{base}/{endpoint.strip('/')}" - else: - result = endpoint - return result.strip("/") + parsed = parse.urlparse(str(base)) + result = parsed._replace(path=endpoint) + return parse.urlunparse(result) def unwrapped(func: Callable) -> Callable: From c9011b3a74dd30dc64df82b7a5b549f2e28880da Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:30:14 -0600 Subject: [PATCH 03/12] added to history --- docs/HISTORY.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 7ac94e93d..c3d282675 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -5,6 +5,7 @@ **Features** - Added some basic test for persistent namespaces +- Using urlib to create endpoints from URLs **Fixes** @@ -13,6 +14,8 @@ - Fix for connecting to Home Assistant with https - Fix for persistent namespaces in Python 3.12 - Better error handling for receiving huge websocket messages in the Hass plugin +- Fix for HTTP method parameters - contributed by [cebtenzzre](https://github.com/cebtenzzre) +- Fix for entity deletion **Breaking Changes** From 8bd112ee05ad0aab45be7dac6cbaeb15a7d83e0f Mon Sep 17 00:00:00 2001 From: Jared Van Bortel Date: Sun, 16 Nov 2025 01:05:42 -0500 Subject: [PATCH 04/12] fix HASS url building --- appdaemon/models/config/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index 2da1ca1c5..84e040fdd 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -118,11 +118,11 @@ def custom_validator(self): @property def websocket_url(self) -> str: - return f"{self.ha_url!s}api/websocket" + return f"{self.ha_url!s}/api/websocket" @property def states_api(self) -> str: - return f"{self.ha_url!s}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}" From 95ef73ffb4c29cd0acdb5a2d0a449c00e533d1e6 Mon Sep 17 00:00:00 2001 From: Jared Van Bortel Date: Sun, 30 Nov 2025 14:29:22 -0500 Subject: [PATCH 05/12] use yarl.URL and handle automatic trailing slash --- appdaemon/models/config/plugin.py | 20 +++++++++++++------- appdaemon/plugins/hass/hassplugin.py | 7 ++++--- appdaemon/utils.py | 9 --------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index 84e040fdd..79325ea82 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -5,6 +5,7 @@ from pydantic import AnyHttpUrl, BaseModel, BeforeValidator, Field, SecretBytes, SecretStr, field_validator, model_validator from typing_extensions import deprecated +from yarl import URL from .common import CoercedPath, ParsedTimedelta @@ -86,7 +87,7 @@ class StartupConditions(BaseModel): class HASSConfig(PluginConfig, extra="forbid"): - ha_url: AnyHttpUrl = Field(default="http://supervisor/core", validate_default=True) # pyright: ignore[reportAssignmentType] + ha_url: URL = Field(default="http://supervisor/core", validate_default=True) 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 @@ -108,6 +109,11 @@ 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_url", mode="before") + @classmethod + def validate_ha_url(cls, v: Any) -> URL: + return URL(str(AnyHttpUrl(v)).rstrip('/') + '/') + @model_validator(mode="after") def custom_validator(self): if self.token.get_secret_value() is None: @@ -117,15 +123,15 @@ def custom_validator(self): return self @property - def websocket_url(self) -> str: - return f"{self.ha_url!s}/api/websocket" + def websocket_url(self) -> URL: + return self.ha_url / "api/websocket" @property - def states_api(self) -> str: - return f"{self.ha_url!s}/api/states" + def states_api(self) -> URL: + return self.ha_url / "api/states" - def get_entity_api(self, entity_id: str) -> str: - return f"{self.states_api}/{entity_id}" + def get_entity_api(self, entity_id: str) -> URL: + return self.states_api / entity_id @property def auth_json(self) -> dict: diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index b47f7ddeb..3a7f46352 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -12,6 +12,7 @@ from datetime import datetime, timedelta from time import perf_counter from typing import Any, Literal, Optional +from yarl import URL import aiohttp from aiohttp import ClientResponse, ClientResponseError, RequestInfo, WSMsgType, WebSocketError @@ -441,7 +442,7 @@ async def websocket_send_json( async def http_method( self, method: Literal["get", "post", "delete"], - endpoint: str, + endpoint: str | URL, timeout: str | int | float | timedelta | None = 10, **kwargs: Any, ) -> str | dict[str, Any] | list[Any] | aiohttp.ClientResponseError | None: @@ -456,11 +457,11 @@ async def http_method( appropriate. """ kwargs = utils.clean_http_kwargs(kwargs) - url = utils.make_endpoint(f"{self.config.ha_url!s}", endpoint) + url = self.config.ha_url.join(URL(endpoint)) try: self.update_perf( - bytes_sent=len(url) + len(json.dumps(kwargs).encode("utf-8")), + bytes_sent=len(str(url)) + len(json.dumps(kwargs).encode("utf-8")), requests_sent=1, ) diff --git a/appdaemon/utils.py b/appdaemon/utils.py index 9e3552295..e6095b5a5 100644 --- a/appdaemon/utils.py +++ b/appdaemon/utils.py @@ -1167,15 +1167,6 @@ def clean_http_kwargs(val: Any) -> Any: return pruned -def make_endpoint(base: str, endpoint: str) -> str: - """Formats a URL appropriately with slashes""" - if not endpoint.startswith(base): - result = f"{base}/{endpoint.strip('/')}" - else: - result = endpoint - return result.strip("/") - - def unwrapped(func: Callable) -> Callable: while hasattr(func, "__wrapped__"): func = func.__wrapped__ From fa42e4a3026d435f18660270b21f080b088acb04 Mon Sep 17 00:00:00 2001 From: Jared Van Bortel Date: Sun, 30 Nov 2025 14:39:02 -0500 Subject: [PATCH 06/12] fix PydanticSchemaGenerationError We need a serializer for all fields in the model. --- appdaemon/models/config/plugin.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index 79325ea82..3bc255694 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -3,14 +3,31 @@ from ssl import _SSLMethod from typing import Annotated, Any, Literal -from pydantic import AnyHttpUrl, BaseModel, BeforeValidator, Field, SecretBytes, SecretStr, field_validator, model_validator +from pydantic import ( + AnyHttpUrl, + BaseModel, + BeforeValidator, + Field, + PlainSerializer, + SecretBytes, + SecretStr, + field_validator, + model_validator, +) from typing_extensions import deprecated from yarl import URL - from .common import CoercedPath, ParsedTimedelta +def _validate_url(v: Any) -> URL: + """Validate and convert to yarl.URL using Pydantic's AnyHttpUrl validator.""" + return URL(str(AnyHttpUrl(v)).rstrip('/') + '/') + + +ValidatedURL = Annotated[URL, BeforeValidator(_validate_url), PlainSerializer(str, return_type=str)] + + class PluginConfig(BaseModel, extra="allow"): type: Annotated[str, BeforeValidator(lambda s: s.lower())] name: str @@ -87,7 +104,7 @@ class StartupConditions(BaseModel): class HASSConfig(PluginConfig, extra="forbid"): - ha_url: URL = Field(default="http://supervisor/core", validate_default=True) + ha_url: ValidatedURL = Field(default="http://supervisor/core", validate_default=True) 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 @@ -109,11 +126,6 @@ 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_url", mode="before") - @classmethod - def validate_ha_url(cls, v: Any) -> URL: - return URL(str(AnyHttpUrl(v)).rstrip('/') + '/') - @model_validator(mode="after") def custom_validator(self): if self.token.get_secret_value() is None: From ae5f6faf54ede6a610deaad5fd63550bbc68b59e Mon Sep 17 00:00:00 2001 From: Jared Van Bortel Date: Sun, 30 Nov 2025 14:47:19 -0500 Subject: [PATCH 07/12] just use a property --- appdaemon/models/config/plugin.py | 30 ++++++++-------------------- appdaemon/plugins/hass/hassplugin.py | 2 +- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index 3bc255694..0ac2ed432 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -3,31 +3,13 @@ from ssl import _SSLMethod from typing import Annotated, Any, Literal -from pydantic import ( - AnyHttpUrl, - BaseModel, - BeforeValidator, - Field, - PlainSerializer, - SecretBytes, - SecretStr, - field_validator, - model_validator, -) +from pydantic import AnyHttpUrl, BaseModel, BeforeValidator, Field, SecretBytes, SecretStr, field_validator, model_validator from typing_extensions import deprecated from yarl import URL from .common import CoercedPath, ParsedTimedelta -def _validate_url(v: Any) -> URL: - """Validate and convert to yarl.URL using Pydantic's AnyHttpUrl validator.""" - return URL(str(AnyHttpUrl(v)).rstrip('/') + '/') - - -ValidatedURL = Annotated[URL, BeforeValidator(_validate_url), PlainSerializer(str, return_type=str)] - - class PluginConfig(BaseModel, extra="allow"): type: Annotated[str, BeforeValidator(lambda s: s.lower())] name: str @@ -104,7 +86,7 @@ class StartupConditions(BaseModel): class HASSConfig(PluginConfig, extra="forbid"): - ha_url: ValidatedURL = Field(default="http://supervisor/core", validate_default=True) + ha_url: AnyHttpUrl = Field(default="http://supervisor/core", validate_default=True) 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 @@ -136,11 +118,11 @@ def custom_validator(self): @property def websocket_url(self) -> URL: - return self.ha_url / "api/websocket" + return self.ha_url_yarl / "api/websocket" @property def states_api(self) -> URL: - return self.ha_url / "api/states" + return self.ha_url_yarl / "api/states" def get_entity_api(self, entity_id: str) -> URL: return self.states_api / entity_id @@ -161,6 +143,10 @@ def auth_headers(self) -> dict: return {"x-ha-access": self.ha_key} raise ValueError("Home Assistant token not set") + @property + def ha_url_yarl(self) -> URL: + return URL(str(self.ha_url).rstrip('/') + '/') + class MQTTConfig(PluginConfig): name: str diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index 3a7f46352..a8240a281 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -457,7 +457,7 @@ async def http_method( appropriate. """ kwargs = utils.clean_http_kwargs(kwargs) - url = self.config.ha_url.join(URL(endpoint)) + url = self.config.ha_url_yarl.join(URL(endpoint)) try: self.update_perf( From 3828ece22f41a3400b64c2f4764f57db874b772e Mon Sep 17 00:00:00 2001 From: Jared Van Bortel Date: Sun, 30 Nov 2025 15:04:02 -0500 Subject: [PATCH 08/12] avoid urljoin We prefix paths like `/api/states` with a slash even though they're relative in practice, not absolute. It's simpler to always append to the path and lstrip the leading slash from the endpoint. --- appdaemon/models/config/plugin.py | 7 ------- appdaemon/plugins/hass/hassplugin.py | 8 +++----- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index 0ac2ed432..b2da594a7 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -120,13 +120,6 @@ def custom_validator(self): def websocket_url(self) -> URL: return self.ha_url_yarl / "api/websocket" - @property - def states_api(self) -> URL: - return self.ha_url_yarl / "api/states" - - def get_entity_api(self, entity_id: str) -> URL: - return self.states_api / entity_id - @property def auth_json(self) -> dict: if self.token is not None: diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index a8240a281..3ab19a0dd 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -12,7 +12,6 @@ from datetime import datetime, timedelta from time import perf_counter from typing import Any, Literal, Optional -from yarl import URL import aiohttp from aiohttp import ClientResponse, ClientResponseError, RequestInfo, WSMsgType, WebSocketError @@ -442,7 +441,7 @@ async def websocket_send_json( async def http_method( self, method: Literal["get", "post", "delete"], - endpoint: str | URL, + endpoint: str, timeout: str | int | float | timedelta | None = 10, **kwargs: Any, ) -> str | dict[str, Any] | list[Any] | aiohttp.ClientResponseError | None: @@ -457,7 +456,7 @@ async def http_method( appropriate. """ kwargs = utils.clean_http_kwargs(kwargs) - url = self.config.ha_url_yarl.join(URL(endpoint)) + url = self.config.ha_url_yarl / endpoint.lstrip("/") try: self.update_perf( @@ -885,8 +884,7 @@ async def set_plugin_state( @utils.warning_decorator(error_text=f"Error setting state for {entity_id}") async def safe_set_state(self: "HassPlugin"): - api_url = self.config.get_entity_api(entity_id) - return await self.http_method("post", api_url, state=state, attributes=attributes) + return await self.http_method("post", f"/api/states/{entity_id}", state=state, attributes=attributes) return await safe_set_state(self) From 881f348131dadee22a8e05ab98cb09cd1b0e3c16 Mon Sep 17 00:00:00 2001 From: Jared Van Bortel Date: Sun, 30 Nov 2025 15:17:29 -0500 Subject: [PATCH 09/12] simplify --- appdaemon/models/config/plugin.py | 8 ++------ appdaemon/plugins/hass/hassplugin.py | 3 ++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index b2da594a7..b8ebd1775 100644 --- a/appdaemon/models/config/plugin.py +++ b/appdaemon/models/config/plugin.py @@ -86,7 +86,7 @@ class StartupConditions(BaseModel): class HASSConfig(PluginConfig, extra="forbid"): - ha_url: AnyHttpUrl = Field(default="http://supervisor/core", validate_default=True) + ha_url: Annotated[str, BeforeValidator(lambda u: str(AnyHttpUrl(u)))] = Field(default="http://supervisor/core", validate_default=True) 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 @@ -118,7 +118,7 @@ def custom_validator(self): @property def websocket_url(self) -> URL: - return self.ha_url_yarl / "api/websocket" + return URL(self.ha_url) / "api/websocket" @property def auth_json(self) -> dict: @@ -136,10 +136,6 @@ def auth_headers(self) -> dict: return {"x-ha-access": self.ha_key} raise ValueError("Home Assistant token not set") - @property - def ha_url_yarl(self) -> URL: - return URL(str(self.ha_url).rstrip('/') + '/') - class MQTTConfig(PluginConfig): name: str diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index 3ab19a0dd..04cdfa775 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -16,6 +16,7 @@ import aiohttp from aiohttp import ClientResponse, ClientResponseError, RequestInfo, WSMsgType, WebSocketError from pydantic import BaseModel +from yarl import URL import appdaemon.utils as utils from appdaemon.appdaemon import AppDaemon @@ -456,7 +457,7 @@ async def http_method( appropriate. """ kwargs = utils.clean_http_kwargs(kwargs) - url = self.config.ha_url_yarl / endpoint.lstrip("/") + url = URL(self.config.ha_url) / endpoint.lstrip("/") try: self.update_perf( From 9c4dad4b087e8a6f0dc311c39ea0f3168ced179e Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:16:01 -0600 Subject: [PATCH 10/12] keeping the native yarl.URL object with arbitrary_types_allowed --- appdaemon/models/config/plugin.py | 12 +++++++++--- appdaemon/plugins/hass/hassplugin.py | 5 ++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/appdaemon/models/config/plugin.py b/appdaemon/models/config/plugin.py index b8ebd1775..93aa66cf1 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 AnyHttpUrl, BaseModel, BeforeValidator, Field, SecretBytes, SecretStr, field_validator, model_validator +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, PlainSerializer, SecretBytes, SecretStr, field_validator, model_validator from typing_extensions import deprecated from yarl import URL @@ -86,7 +86,11 @@ class StartupConditions(BaseModel): class HASSConfig(PluginConfig, extra="forbid"): - ha_url: Annotated[str, BeforeValidator(lambda u: str(AnyHttpUrl(u)))] = Field(default="http://supervisor/core", validate_default=True) + ha_url: Annotated[ + URL, + BeforeValidator(URL), + PlainSerializer(str), + ] = 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 @@ -108,6 +112,8 @@ 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""" + model_config = ConfigDict(arbitrary_types_allowed=True) + @model_validator(mode="after") def custom_validator(self): if self.token.get_secret_value() is None: @@ -118,7 +124,7 @@ def custom_validator(self): @property def websocket_url(self) -> URL: - return URL(self.ha_url) / "api/websocket" + return self.ha_url / "api/websocket" @property def auth_json(self) -> dict: diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index 04cdfa775..1b67cc97f 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -14,9 +14,8 @@ from typing import Any, Literal, Optional import aiohttp -from aiohttp import ClientResponse, ClientResponseError, RequestInfo, WSMsgType, WebSocketError +from aiohttp import ClientResponse, ClientResponseError, RequestInfo, WebSocketError, WSMsgType from pydantic import BaseModel -from yarl import URL import appdaemon.utils as utils from appdaemon.appdaemon import AppDaemon @@ -457,7 +456,7 @@ async def http_method( appropriate. """ kwargs = utils.clean_http_kwargs(kwargs) - url = URL(self.config.ha_url) / endpoint.lstrip("/") + url = self.config.ha_url / endpoint.lstrip("/") try: self.update_perf( From dbaa49665b525190ba86e73ad14d0d966eb65eb8 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:22:52 -0600 Subject: [PATCH 11/12] pruned make_endpoint --- appdaemon/utils.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/appdaemon/utils.py b/appdaemon/utils.py index fe324f79f..9f8d89f31 100644 --- a/appdaemon/utils.py +++ b/appdaemon/utils.py @@ -1173,13 +1173,6 @@ def clean_http_kwargs(val: Any) -> Any: return pruned -def make_endpoint(base: str | AnyHttpUrl, endpoint: str) -> str: - """Formats a URL appropriately with slashes""" - parsed = parse.urlparse(str(base)) - result = parsed._replace(path=endpoint) - return parse.urlunparse(result) - - def unwrapped(func: Callable) -> Callable: while hasattr(func, "__wrapped__"): func = func.__wrapped__ From f573f4d92186e60a6cc152604b2457c9c68cfd3f Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:25:13 -0600 Subject: [PATCH 12/12] pruned unused imports --- appdaemon/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/appdaemon/utils.py b/appdaemon/utils.py index 9f8d89f31..c8c46c67a 100644 --- a/appdaemon/utils.py +++ b/appdaemon/utils.py @@ -24,14 +24,13 @@ from pathlib import Path from time import perf_counter from typing import TYPE_CHECKING, Any, Callable, Coroutine, Literal, ParamSpec, Protocol, TypeVar -from urllib import parse import dateutil.parser import tomli import tomli_w import yaml from astral.location import Location -from pydantic import AnyHttpUrl, BaseModel, ValidationError +from pydantic import BaseModel, ValidationError from pytz import BaseTzInfo from appdaemon.parse import parse_datetime