diff --git a/.gitignore b/.gitignore index 6ad19a3d..bc4adcbf 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ venv/ .breakpoints # Informal testing -main.py +main*.py # Cache files *.sqlite @@ -88,6 +88,7 @@ instance/ # Scrapy stuff: .scrapy + # Sphinx documentation docs/_build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4217510d..b99dbd07 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: additional_dependencies: - types-requests - types-simplejson - rev: "v0.931" + rev: "v1.17.1" - repo: https://github.com/pre-commit/pre-commit-hooks hooks: - id: trailing-whitespace diff --git a/compose.yml b/compose.yml index 2a7d627c..2422f9cc 100644 --- a/compose.yml +++ b/compose.yml @@ -19,7 +19,7 @@ services: HOMEASSISTANTAPI_URL: http://server:8123/api HOMEASSISTANTAPI_WS_URL: ws://server:8123/api/websocket HOMEASSISTANTAPI_TOKEN: ${HOMEASSISTANTAPI_TOKEN} - DELAY: 60 + DELAY: 10 networks: default: diff --git a/docs/conf.py b/docs/conf.py index 45daf466..bb47cc19 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,7 +27,9 @@ # The full version, including alpha/beta/rc tags with open("../pyproject.toml") as f: pyproject = f.read() - release = version = re.search('version = "(.+?)"', pyproject).group(1) + search_result = re.search('version = "(.+?)"', pyproject) + assert search_result is not None + release = version = search_result.group(1) # -- General configuration --------------------------------------------------- @@ -46,6 +48,8 @@ "sphinx.ext.autosectionlabel", ] +autodoc_pydantic_model_show_json = False + resource_links = { "repo": "https://github.com/GrandMoff100/HomeassistantAPI/", "issues": "https://github.com/GrandMoff100/HomeassistantAPI/issues", diff --git a/entrypoint.sh b/entrypoint.sh index 810078c3..f0260a25 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,3 +1,5 @@ . .venv/bin/activate +echo "Waiting $DELAY seconds for Home Assistant to start..." python -c "import time; time.sleep($DELAY)" +echo "Finished waiting $DELAY seconds. Starting tests..." pytest tests --disable-warnings --cov --cov-report xml:coverage/coverage.xml diff --git a/homeassistant_api/models/base.py b/homeassistant_api/models/base.py index e003683d..4cf455a3 100644 --- a/homeassistant_api/models/base.py +++ b/homeassistant_api/models/base.py @@ -6,6 +6,11 @@ from pydantic import BaseModel as PydanticBaseModel from pydantic import ConfigDict, PlainSerializer +__all__ = ( + "BaseModel", + "DatetimeIsoField", +) + DatetimeIsoField = Annotated[ datetime, PlainSerializer(lambda x: x.isoformat(), return_type=str, when_used="json"), @@ -18,4 +23,6 @@ class BaseModel(PydanticBaseModel): model_config = ConfigDict( arbitrary_types_allowed=True, validate_assignment=True, + extra="forbid", + protected_namespaces=(), ) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index 82d68441..214e7eac 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -1,12 +1,26 @@ """File for Service and Domain data models""" +from __future__ import annotations + import gc import inspect -from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Tuple, Union, cast +from enum import Enum +from typing import ( + TYPE_CHECKING, + Any, + Coroutine, + Dict, + List, + Optional, + Tuple, + Union, + cast, +) from pydantic import Field from homeassistant_api.errors import RequestError +from homeassistant_api.utils import JSONType from .base import BaseModel from .states import State @@ -42,13 +56,13 @@ def __init__( @classmethod def from_json( - cls, json: Dict[str, Any], client: Union["Client", "WebsocketClient"] + cls, json: Dict[str, JSONType], client: Union["Client", "WebsocketClient"] ) -> "Domain": """Constructs Domain and Service models from json data.""" if "domain" not in json or "services" not in json: raise ValueError("Missing services or domain attribute in json argument.") domain = cls(domain_id=cast(str, json.get("domain")), _client=client) - services = json.get("services") + services = cast(dict[str, dict[str, JSONType]], json.get("services")) assert isinstance(services, dict) for service_id, data in services.items(): domain._add_service(service_id, **data) @@ -84,14 +98,478 @@ def __getattr__(self, attr: str): raise e from err +# Sources: +# https://developers.home-assistant.io/docs/dev_101_services/ +# https://www.home-assistant.io/docs/blueprint/selectors/#date-selector +# https://github.com/home-assistant/frontend/blob/dev/src/data/selector.ts +# https://github.com/home-assistant/home-assistant-js-websocket/blob/master/lib/types.ts + + +# Helpers +class ServiceFieldSelectorEntityFilter(BaseModel): + integration: Optional[str] = None + domain: Optional[Union[List[str], str]] = None + device_class: Optional[Union[List[str], str]] = None + supported_features: Optional[Union[List[int], int]] = None + + +class ServiceFieldSelectorDeviceFilter(BaseModel): + integration: Optional[str] = None + manufacturer: Optional[str] = None + model: Optional[str] = None + model_id: Optional[str] = None + + +class CropOptions(BaseModel): + round: bool + type: Optional[str] = None # "image/jpeg" / "image/png" + quality: Optional[Union[int, float]] = None + aspectRatio: Optional[Union[int, float]] = None + + +class SelectBoxOptionImage(BaseModel): + src: str + src_dark: Optional[str] = None + flip_rtl: Optional[bool] = None + + +class ServiceFieldSelectorNumberMode(str, Enum): + BOX = "box" + SLIDER = "slider" + + +class ServiceFieldSelectorSelectMode(str, Enum): + LIST = "list" + DROPDOWN = "dropdown" + BOX = "box" + + +class ServiceFieldSelectorQRCodeErrorCorrectionLevel(str, Enum): + LOW = "low" + MEDIUM = "medium" + QUARTILE = "quartile" + HIGH = "high" + + +class ServiceFieldSelectorTextType(str, Enum): + NUMBER = "number" + TEXT = "text" + SEARCH = "search" + TEL = "tel" + URL = "url" + EMAIL = "email" + PASSWORD = "password" + DATE = "date" + MONTH = "month" + WEEK = "week" + TIME = "time" + DATETIME_LOCAL = "datetime-local" + COLOR = "color" + + +# Selectors +class ServiceFieldSelectorAction(BaseModel): + optionsInSidebar: Optional[bool] = None + + +class ServiceFieldSelectorAddon(BaseModel): + name: Optional[str] = None + slug: Optional[str] = None + + +class ServiceFieldSelectorArea(BaseModel): + entity: Optional[ + Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter] + ] = None + device: Optional[ + Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter] + ] = None + multiple: Optional[bool] = None + + +class ServiceFieldSelectorAreasDisplay(BaseModel): + pass + + +class ServiceFieldSelectorAttribute(BaseModel): + entity_id: Optional[Union[List[str], str]] = None + hide_attributes: Optional[List[str]] = None + + +class ServiceFieldSelectorAssistPipeline(BaseModel): + include_last_used: Optional[bool] = None + + +class ServiceFieldSelectorBackground(BaseModel): + original: Optional[bool] = None + crop: Optional[CropOptions] = None + + +class ServiceFieldSelectorBackupLocation(BaseModel): + pass + + +class ServiceFieldSelectorBoolean(BaseModel): + pass + + +class ServiceFieldSelectorButtonToggle(BaseModel): + options: List[Union[str, ServiceFieldSelectorSelectOption]] + translation_key: Optional[str] = None + sort: Optional[bool] = None + + +class ServiceFieldSelectorColorRGB(BaseModel): + pass + + +class ServiceFieldSelectorColorTemp(BaseModel): + unit: Optional[str] = None + min: Optional[Union[int, float]] = None + max: Optional[Union[int, float]] = None + min_mireds: Optional[Union[int, float]] = None + max_mireds: Optional[Union[int, float]] = None + + +class ServiceFieldSelectorCondition(BaseModel): + optionsInSidebar: Optional[bool] = None + + +class ServiceFieldSelectorConfigEntry(BaseModel): + integration: Optional[str] = None + + +class ServiceFieldSelectorConstant(BaseModel): + label: Optional[str] = None + value: Union[str, int, float, bool] + translation_key: Optional[str] = None + + +class ServiceFieldSelectorConversationAgent(BaseModel): + language: Optional[str] = None # filtering by language not supported + + +class ServiceFieldSelectorCountry(BaseModel): + countries: List[str] + no_sort: Optional[bool] = None + + +class ServiceFieldSelectorDate(BaseModel): + pass + + +class ServiceFieldSelectorDateTime(BaseModel): + pass + + +class ServiceFieldSelectorDevice(BaseModel): + entity: Optional[ + Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter] + ] = None + filter: Optional[ + Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter] + ] = None + multiple: Optional[bool] = None + + +class ServiceFieldSelectorDeviceLegacy(ServiceFieldSelectorDevice): + integration: Optional[str] = None + manufacturer: Optional[str] = None + model: Optional[str] = None + + +class ServiceFieldSelectorDuration(BaseModel): + enable_day: Optional[bool] = None + enable_millisecond: Optional[bool] = None + + +class ServiceFieldSelectorEntity(BaseModel): + multiple: Optional[bool] = None + include_entities: Optional[List[str]] = None + exclude_entities: Optional[List[str]] = None + filter: Optional[ + Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter] + ] = None + reorder: Optional[bool] = None + + +class ServiceFieldSelectorEntityLegacy(ServiceFieldSelectorEntity): + integration: Optional[str] = None + domain: Optional[Union[List[str], str]] = None + device_class: Optional[Union[List[str], str]] = None + + +class ServiceFieldSelectorFloor(BaseModel): + entity: Optional[ + Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter] + ] = None + device: Optional[ + Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter] + ] = None + multiple: Optional[bool] = None + + +class ServiceFieldSelectorFile(BaseModel): + accept: str + + +class ServiceFieldSelectorIcon(BaseModel): + placeholder: Optional[str] = None + fallbackPath: Optional[str] = None + + +class ServiceFieldSelectorImage(BaseModel): + original: Optional[bool] = None + crop: Optional[CropOptions] = None + + +class ServiceFieldSelectorLabel(BaseModel): + multiple: Optional[bool] = None + + +class ServiceFieldSelectorLanguage(BaseModel): + languages: Optional[List[str]] = None + native_name: Optional[bool] = None + no_sort: Optional[bool] = None + + +class ServiceFieldSelectorLocation(BaseModel): + radius: Optional[bool] = None + radius_readonly: Optional[bool] = None + icon: Optional[str] = None + + +class ServiceFieldSelectorMedia(BaseModel): + accept: Optional[List[str]] = None + + +class ServiceFieldSelectorNavigation(BaseModel): + pass + + +class ServiceFieldSelectorNumber(BaseModel): + min: Optional[Union[int, float]] = None + max: Optional[Union[int, float]] = None + step: Optional[Union[Union[int, float], str]] = None + unit_of_measurement: Optional[str] = None + mode: Optional[ServiceFieldSelectorNumberMode] = None + slider_ticks: Optional[bool] = None + translation_key: Optional[str] = None + + +class ServiceFieldSelectorObjectField(BaseModel): + selector: ServiceFieldSelector + label: Optional[str] = None + required: Optional[bool] = None + + +class ServiceFieldSelectorObject(BaseModel): + label_field: Optional[str] = None + description_field: Optional[str] = None + translation_key: Optional[str] = None + fields: Optional[Dict[str, ServiceFieldSelectorObjectField]] = None + multiple: Optional[bool] = None + + +class ServiceFieldSelectorQRCode(BaseModel): + data: str + scale: Optional[Union[int, float]] = None + error_correction_level: Optional[ServiceFieldSelectorQRCodeErrorCorrectionLevel] = ( + None + ) + center_image: Optional[str] = None + + +class ServiceFieldSelectorSelectOption(BaseModel): + label: str + value: Any + description: Optional[str] = None + image: Optional[Union[str, SelectBoxOptionImage]] = None + disable: Optional[bool] = None + + +class ServiceFieldSelectorSelect(BaseModel): + multiple: Optional[bool] = None + custom_value: Optional[bool] = None + mode: Optional[ServiceFieldSelectorSelectMode] = None + options: List[Union[str, ServiceFieldSelectorSelectOption]] + translation_key: Optional[str] = None + sort: Optional[bool] = None + reorder: Optional[bool] = None + box_max_columns: Optional[int] = None + + +class ServiceFieldSelectorSelector(BaseModel): + pass + + +class ServiceFieldSelectorStateOption(BaseModel): + label: str + value: Any + + +class ServiceFieldSelectorState(BaseModel): + extra_options: Optional[List[ServiceFieldSelectorStateOption]] = None + entity_id: Optional[Union[str, List[str]]] = None + attribute: Optional[str] = None + hide_states: Optional[List[str]] = None + multiple: Optional[bool] = None + + +class ServiceFieldSelectorStatistic(BaseModel): + device_class: Optional[str] = None + multiple: Optional[bool] = None + + +class ServiceFieldSelectorTarget(BaseModel): + entity: Optional[ + Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter] + ] = None + device: Optional[ + Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter] + ] = None + + +class ServiceFieldSelectorTemplate(BaseModel): + pass + + +class ServiceFieldSelectorSTT(BaseModel): + language: Optional[str] = None + + +class ServiceFieldSelectorText(BaseModel): + multiline: Optional[bool] = None + type: Optional[ServiceFieldSelectorTextType] = None + prefix: Optional[str] = None + suffix: Optional[str] = None + autocomplete: Optional[str] = None + multiple: Optional[bool] = None + + +class ServiceFieldSelectorTheme(BaseModel): + include_default: Optional[bool] = None + + +class ServiceFieldSelectorTime(BaseModel): + no_second: Optional[bool] = None + + +class ServiceFieldSelectorTrigger(BaseModel): + pass + + +class ServiceFieldSelectorTTS(BaseModel): + language: Optional[str] = None + + +class ServiceFieldSelectorTTSVoice(BaseModel): + engineId: Optional[str] = None + language: Optional[str] = None + + +class ServiceFieldSelectorUIAction(BaseModel): + pass + + +class ServiceFieldSelectorUIColor(BaseModel): + default_color: Optional[str] = None + include_none: Optional[bool] = None + include_state: Optional[bool] = None + + +class ServiceFieldSelectorUIStateContext(BaseModel): + entity_id: Optional[str] = None + allow_name: Optional[bool] = None + + +class ServiceFieldSelector(BaseModel): + action: Optional[ServiceFieldSelectorAction] = None + addon: Optional[ServiceFieldSelectorAddon] = None + area: Optional[ServiceFieldSelectorArea] = None + areas_display: Optional[ServiceFieldSelectorAreasDisplay] = None + attribute: Optional[ServiceFieldSelectorAttribute] = None + assist_pipeline: Optional[ServiceFieldSelectorAssistPipeline] = None + backup_location: Optional[ServiceFieldSelectorBackupLocation] = None + background: Optional[ServiceFieldSelectorBackground] = None + boolean: Optional[ServiceFieldSelectorBoolean] = None + button_toggle: Optional[ServiceFieldSelectorButtonToggle] = None + color_rgb: Optional[ServiceFieldSelectorColorRGB] = None + color_temp: Optional[ServiceFieldSelectorColorTemp] = None + condition: Optional[ServiceFieldSelectorCondition] = None + config_entry: Optional[ServiceFieldSelectorConfigEntry] = None + constant: Optional[ServiceFieldSelectorConstant] = None + conversation_agent: Optional[ServiceFieldSelectorConversationAgent] = None + country: Optional[ServiceFieldSelectorCountry] = None + date: Optional[ServiceFieldSelectorDate] = None + datetime: Optional[ServiceFieldSelectorDateTime] = None + device: Optional[ + Union[ServiceFieldSelectorDevice, ServiceFieldSelectorDeviceLegacy] + ] = None + duration: Optional[ServiceFieldSelectorDuration] = None + entity: Optional[ + Union[ServiceFieldSelectorEntity, ServiceFieldSelectorEntityLegacy] + ] = None + floor: Optional[ServiceFieldSelectorFloor] = None + file: Optional[ServiceFieldSelectorFile] = None + icon: Optional[ServiceFieldSelectorIcon] = None + image: Optional[ServiceFieldSelectorImage] = None + label: Optional[ServiceFieldSelectorLabel] = None + language: Optional[ServiceFieldSelectorLanguage] = None + location: Optional[ServiceFieldSelectorLocation] = None + media: Optional[ServiceFieldSelectorMedia] = None + navigation: Optional[ServiceFieldSelectorNavigation] = None + number: Optional[ServiceFieldSelectorNumber] = None + object: Optional[ServiceFieldSelectorObject] = None + qr_code: Optional[ServiceFieldSelectorQRCode] = None + select: Optional[ServiceFieldSelectorSelect] = None + selector: Optional[ServiceFieldSelectorSelector] = None + state: Optional[ServiceFieldSelectorState] = None + statistic: Optional[ServiceFieldSelectorStatistic] = None + target: Optional[ServiceFieldSelectorTarget] = None + template: Optional[ServiceFieldSelectorTemplate] = None + stt: Optional[ServiceFieldSelectorSTT] = None + text: Optional[ServiceFieldSelectorText] = None + theme: Optional[ServiceFieldSelectorTheme] = None + time: Optional[ServiceFieldSelectorTime] = None + trigger: Optional[ServiceFieldSelectorTrigger] = None + tts: Optional[ServiceFieldSelectorTTS] = None + tts_voice: Optional[ServiceFieldSelectorTTSVoice] = None + ui_action: Optional[ServiceFieldSelectorUIAction] = None + ui_color: Optional[ServiceFieldSelectorUIColor] = None + ui_state_content: Optional[ServiceFieldSelectorUIStateContext] = None + + +# Service bases + + +class ServiceFieldFilter(BaseModel): + supported_features: Optional[Union[List[int], int]] = ( + None # Bitset (any needs to be supported [or all within specified list]) + ) + attribute: Optional[Dict[str, Union[List[str], str]]] = None + + class ServiceField(BaseModel): """Model for service parameters/fields.""" description: Optional[str] = None - example: Any = None - selector: Optional[Dict[str, Any]] = None + example: Optional[JSONType] = None + default: Optional[JSONType] = None name: Optional[str] = None required: Optional[bool] = None + advanced: Optional[bool] = None + selector: Optional[ServiceFieldSelector] = None + filter: Optional[ServiceFieldFilter] = None + + +class ServiceFieldCollection(BaseModel): + collapsed: Optional[bool] = None + fields: Dict[str, ServiceField] + + +class ServiceResponse(BaseModel): + optional: Optional[bool] = None class Service(BaseModel): @@ -99,19 +577,19 @@ class Service(BaseModel): service_id: str domain: Domain = Field(exclude=True, repr=False) - name: Optional[str] = None + name: str description: Optional[str] = None - fields: Optional[Dict[str, ServiceField]] = None + fields: Optional[Dict[str, Union[ServiceField, ServiceFieldCollection]]] = None + target: Optional[ServiceFieldSelectorTarget] = None + response: Optional[ServiceResponse] = None - def trigger(self, entity_id: Optional[str] = None, **service_data) -> Union[ + def trigger(self, **service_data) -> Union[ Tuple[State, ...], - Tuple[Tuple[State, ...], Dict[str, Any]], - dict[str, Any], + Tuple[Tuple[State, ...], dict[str, JSONType]], + dict[str, JSONType], None, ]: """Triggers the service associated with this object.""" - if entity_id is not None: - service_data["entity_id"] = entity_id try: return self.domain._client.trigger_service_with_response( self.domain.domain_id, @@ -126,12 +604,9 @@ def trigger(self, entity_id: Optional[str] = None, **service_data) -> Union[ ) async def async_trigger( - self, entity_id: Optional[str] = None, **service_data - ) -> Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]]: + self, **service_data + ) -> Union[Tuple[State, ...], Tuple[Tuple[State, ...], dict[str, JSONType]]]: """Triggers the service associated with this object.""" - if entity_id is not None: - service_data["entity_id"] = entity_id - from homeassistant_api import WebsocketClient # prevent circular import if isinstance(self.domain._client, WebsocketClient): @@ -151,15 +626,17 @@ async def async_trigger( **service_data, ) - def __call__(self, entity_id: Optional[str] = None, **service_data) -> Union[ + def __call__(self, **service_data) -> Union[ Union[ Tuple[State, ...], - Tuple[Tuple[State, ...], Dict[str, Any]], - dict[str, Any], + Tuple[Tuple[State, ...], dict[str, JSONType]], + dict[str, JSONType], None, ], Coroutine[ - Any, Any, Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]] + Any, + Any, + Union[Tuple[State, ...], Tuple[Tuple[State, ...], dict[str, JSONType]]], ], ]: """ @@ -171,7 +648,7 @@ def __call__(self, entity_id: Optional[str] = None, **service_data) -> Union[ if inspect.iscoroutinefunction( caller := gc.get_referrers(parent_frame.f_code)[0] ) or inspect.iscoroutine(caller): - return self.async_trigger(entity_id=entity_id, **service_data) + return self.async_trigger(**service_data) except IndexError: # pragma: no cover pass - return self.trigger(entity_id=entity_id, **service_data) + return self.trigger(**service_data) diff --git a/homeassistant_api/models/events.py b/homeassistant_api/models/events.py index 61de6da3..3007b1dc 100644 --- a/homeassistant_api/models/events.py +++ b/homeassistant_api/models/events.py @@ -1,9 +1,11 @@ """Event Model File""" -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import TYPE_CHECKING, Optional from pydantic import Field +from homeassistant_api.utils import JSONType + from .base import BaseModel if TYPE_CHECKING: @@ -38,6 +40,6 @@ async def async_fire(self, **event_data) -> str: return await self._client.async_fire_event(self.event, **event_data) @classmethod - def from_json(cls, json: Dict[str, Any], client: "Client") -> "Event": + def from_json(cls, json: dict[str, JSONType], client: "Client") -> "Event": """Constructs Event model from json data""" return cls(**json, _client=client) diff --git a/homeassistant_api/models/states.py b/homeassistant_api/models/states.py index e5f8c272..0af16763 100644 --- a/homeassistant_api/models/states.py +++ b/homeassistant_api/models/states.py @@ -1,11 +1,17 @@ """Module for the Entity State model.""" -from datetime import datetime -from typing import Any, Dict, Optional +from datetime import datetime, timezone +from typing import Optional from pydantic import Field -from .base import BaseModel, DatetimeIsoField +from homeassistant_api.models.base import BaseModel, DatetimeIsoField +from homeassistant_api.utils import JSONType + +__all__ = ( + "Context", + "State", +) class Context(BaseModel): @@ -25,7 +31,7 @@ class Context(BaseModel): ) @classmethod - def from_json(cls, json: Dict[str, Any]) -> "Context": + def from_json(cls, json: dict[str, JSONType]) -> "Context": """Constructs Context model from json data""" return cls.model_validate(json) @@ -37,21 +43,26 @@ class State(BaseModel): state: str = Field( ..., description="The string representation of the state of the entity." ) - attributes: Dict[str, Any] = Field( + attributes: dict[str, JSONType] = Field( {}, description="A dictionary of extra attributes of the state." ) last_changed: DatetimeIsoField = Field( - default_factory=datetime.utcnow, + default_factory=lambda: datetime.now(timezone.utc), description="The last time the state was changed.", ) last_updated: Optional[DatetimeIsoField] = Field( - default_factory=datetime.utcnow, description="The last time the state updated." + default_factory=lambda: datetime.now(timezone.utc), + description="The last time the state updated.", + ) + last_reported: Optional[DatetimeIsoField] = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="The last time the state was reported to the server. Only used by some integrations.", ) context: Optional[Context] = Field( None, description="Provides information about the context of the state." ) @classmethod - def from_json(cls, json: Dict[str, Any]) -> "State": + def from_json(cls, json: dict[str, JSONType]) -> "State": """Constructs State model from json data""" return cls.model_validate(json) diff --git a/homeassistant_api/models/websocket.py b/homeassistant_api/models/websocket.py index 7aae4b38..0ff1833a 100644 --- a/homeassistant_api/models/websocket.py +++ b/homeassistant_api/models/websocket.py @@ -2,8 +2,10 @@ from typing import Any, Literal, Optional, Union -from .base import BaseModel -from .states import Context, DatetimeIsoField +from homeassistant_api.utils import JSONType + +from .base import BaseModel, DatetimeIsoField +from .states import Context __all__ = ( "AuthRequired", @@ -43,6 +45,9 @@ class PingResponse(BaseModel): class Error(BaseModel): code: str message: str + translation_key: str + translation_placeholders: dict[str, str] + translation_domain: str class ErrorResponse(BaseModel): @@ -67,7 +72,7 @@ class FiredEvent(BaseModel): """A model to parse the `event` key of fired event websocket responses.""" event_type: str - data: dict[str, Any] + data: dict[str, JSONType] origin: Literal["LOCAL", "REMOTE"] # REMOTE if another API client or webhook fired the event @@ -79,14 +84,14 @@ class FiredEvent(BaseModel): class TemplateEvent(BaseModel): result: str - listeners: dict[str, Any] + listeners: dict[str, JSONType] class FiredTrigger(BaseModel): """A model to parse the `trigger` key of fired event websocket responses.""" context: Optional[Context] - variables: dict[str, Any] + variables: dict[str, JSONType] class EventResponse(BaseModel): diff --git a/homeassistant_api/processing.py b/homeassistant_api/processing.py index ade160c4..1e430c71 100644 --- a/homeassistant_api/processing.py +++ b/homeassistant_api/processing.py @@ -11,7 +11,7 @@ from requests import Response from requests_cache.models.response import CachedResponse -from .errors import ( +from homeassistant_api.errors import ( EndpointNotFoundError, InternalServerError, MalformedDataError, @@ -21,6 +21,7 @@ UnauthorizedError, UnexpectedStatusCodeError, ) +from homeassistant_api.utils import JSONType logger = logging.getLogger(__name__) @@ -59,7 +60,7 @@ def process_content(self, *, async_: bool = False) -> Any: calls the processor with the response. """ - mimetype_header = self._response.headers.get( # type: ignore [arg-type] + mimetype_header = self._response.headers.get( "content-type", "text/plain", ) @@ -82,9 +83,12 @@ def process(self) -> Any: elif isinstance(self._response, (Response, CachedResponse)): status_code = self._response.status_code content = self._response.content + else: + raise TypeError( + f"Unsupported response type: {type(self._response).__name__}" + ) if self._decode_bytes and isinstance(content, bytes): content = content.decode() - if status_code in (200, 201): return self.process_content(async_=async_) if status_code == 400: @@ -106,10 +110,10 @@ def process(self) -> Any: # List of default processors @Processing.processor("application/json") # type: ignore[arg-type] -def process_json(response: ResponseType) -> dict[str, Any]: +def process_json(response: ResponseType) -> dict[str, JSONType]: """Returns the json dict content of the response.""" try: - return cast(dict[str, Any], response.json()) + return cast(dict[str, JSONType], response.json()) except (json.JSONDecodeError, simplejson.JSONDecodeError) as err: raise MalformedDataError( f"Home Assistant responded with non-json response: {repr(response.text)}" @@ -117,17 +121,17 @@ def process_json(response: ResponseType) -> dict[str, Any]: @Processing.processor("text/plain") # type: ignore[arg-type] -@Processing.processor("application/octet-stream") +@Processing.processor("application/octet-stream") # type: ignore[arg-type] def process_text(response: ResponseType) -> str: """Returns the plaintext of the reponse.""" return response.text @Processing.processor("application/json") # type: ignore[arg-type] -async def async_process_json(response: AsyncResponseType) -> dict[str, Any]: +async def async_process_json(response: AsyncResponseType) -> dict[str, JSONType]: """Returns the json dict content of the response.""" try: - return cast(dict[str, Any], await response.json()) + return cast(dict[str, JSONType], await response.json()) except (json.JSONDecodeError, simplejson.JSONDecodeError) as err: raise MalformedDataError( f"Home Assistant responded with non-json response: {repr(await response.text())}" @@ -135,7 +139,7 @@ async def async_process_json(response: AsyncResponseType) -> dict[str, Any]: @Processing.processor("text/plain") # type: ignore[arg-type] -@Processing.processor("application/octet-stream") +@Processing.processor("application/octet-stream") # type: ignore[arg-type] async def async_process_text(response: AsyncResponseType) -> str: """Returns the plaintext of the reponse.""" return await response.text() diff --git a/homeassistant_api/rawasyncclient.py b/homeassistant_api/rawasyncclient.py index 516e5453..0d6e0f8f 100644 --- a/homeassistant_api/rawasyncclient.py +++ b/homeassistant_api/rawasyncclient.py @@ -21,13 +21,13 @@ ) import aiohttp -import aiohttp_client_cache +import aiohttp_client_cache.session from .errors import BadTemplateError, RequestError, RequestTimeoutError from .models import Domain, Entity, Event, Group, History, LogbookEntry, State from .processing import AsyncResponseType, Processing from .rawbaseclient import RawBaseClient -from .utils import prepare_entity_id +from .utils import JSONType, prepare_entity_id if TYPE_CHECKING: from homeassistant_api import Client @@ -47,14 +47,14 @@ class RawAsyncClient(RawBaseClient): """ # pylint: disable=line-too-long async_cache_session: Union[ - aiohttp_client_cache.CachedSession, aiohttp.ClientSession + aiohttp_client_cache.session.CachedSession, aiohttp.ClientSession ] def __init__( self, *args, async_cache_session: Union[ - aiohttp_client_cache.CachedSession, + aiohttp_client_cache.session.CachedSession, Literal[False], Literal[None], ] = None, # Explicitly disable cache with async_cache_session=False @@ -129,12 +129,12 @@ async def async_get_error_log(self) -> str: """ return cast(str, await self.async_request("error_log")) - async def async_get_config(self) -> Dict[str, Any]: + async def async_get_config(self) -> dict[str, JSONType]: """ Returns the yaml configuration of homeassistant. :code:`GET /api/config` """ - return cast(Dict[str, Any], await self.async_request("config")) + return cast(dict[str, JSONType], await self.async_request("config")) async def async_get_logbook_entries( self, @@ -272,7 +272,7 @@ async def async_get_domains(self) -> Dict[str, Domain]: data = await self.async_request("services") domains = map( lambda json: Domain.from_json(json, client=cast(Client, self)), - cast(Tuple[Dict[str, Any], ...], data), + cast(Tuple[dict[str, JSONType], ...], data), ) return {domain.domain_id: domain for domain in domains} @@ -288,7 +288,7 @@ async def async_trigger_service( self, domain: str, service: str, - **service_data: Union[Dict[str, Any], List[Any], str], + **service_data: Union[dict[str, JSONType], List[Any], str], ) -> Tuple[State, ...]: """ Tells Home Assistant to trigger a service, returns all states changed while in the process of being called. @@ -305,8 +305,8 @@ async def async_trigger_service_with_response( self, domain: str, service: str, - **service_data: Union[Dict[str, Any], List[Any], str], - ) -> tuple[tuple[State, ...], dict[str, Any]]: + **service_data: Union[dict[str, JSONType], List[Any], str], + ) -> tuple[tuple[State, ...], dict[str, JSONType]]: """ Tells Home Assistant to trigger a service, returns the response from the service call. :code:`POST /api/services//` @@ -314,7 +314,7 @@ async def async_trigger_service_with_response( Returns a list of the states changed and the response from the service call. """ data = cast( - dict[str, Any], + dict[str, dict[str, JSONType]], await self.async_request( join("services", domain, service) + "?return_response", method="POST", @@ -383,7 +383,7 @@ async def async_get_events(self) -> Tuple[Event, ...]: return tuple( map( lambda json: Event.from_json(json, client=cast(Client, self)), - cast(List[Dict[str, Any]], data), + cast(List[dict[str, JSONType]], data), ) ) diff --git a/homeassistant_api/rawbaseclient.py b/homeassistant_api/rawbaseclient.py index c49d1fa4..a8a40d1c 100644 --- a/homeassistant_api/rawbaseclient.py +++ b/homeassistant_api/rawbaseclient.py @@ -2,9 +2,11 @@ from datetime import datetime, timedelta from posixpath import join -from typing import Any, Dict, Iterable, Optional, Tuple, Union +from typing import Dict, Iterable, Mapping, Optional, Tuple, Union from urllib.parse import quote_plus +from homeassistant_api.utils import JSONType + from .models import Entity @@ -13,20 +15,20 @@ class RawBaseClient: api_url: str token: str - global_request_kwargs: Dict[str, Any] + global_request_kwargs: dict[str, JSONType] def __init__( self, api_url: str, token: str, *, - global_request_kwargs: Optional[Dict[str, str]] = None, + global_request_kwargs: Optional[Mapping[str, str]] = None, ) -> None: if global_request_kwargs is None: global_request_kwargs = {} self.api_url = api_url self.token = token.strip() - self.global_request_kwargs = global_request_kwargs + self.global_request_kwargs = dict(global_request_kwargs) if not api_url.endswith("/"): self.api_url += "/" @@ -136,8 +138,14 @@ def prepare_get_logbook_entry_params( # Parameters are already URL encoded automatically. params.update(end_time=end_timestamp) if start_timestamp is not None: - if isinstance(start_timestamp, datetime): - url = join("logbook/", start_timestamp.isoformat()) + url = join( + "logbook/", + ( + start_timestamp.isoformat() + if isinstance(start_timestamp, datetime) + else start_timestamp + ), + ) else: url = "logbook" return params, url diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 14e19b4e..e78a3288 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -22,12 +22,19 @@ import requests import requests_cache -from homeassistant_api.utils import prepare_entity_id - -from .errors import BadTemplateError, RequestError, RequestTimeoutError -from .models import Domain, Entity, Event, Group, History, LogbookEntry, State -from .processing import Processing, ResponseType -from .rawbaseclient import RawBaseClient +from homeassistant_api.errors import BadTemplateError, RequestError, RequestTimeoutError +from homeassistant_api.models import ( + Domain, + Entity, + Event, + Group, + History, + LogbookEntry, + State, +) +from homeassistant_api.processing import Processing, ResponseType +from homeassistant_api.rawbaseclient import RawBaseClient +from homeassistant_api.utils import JSONType, prepare_entity_id if TYPE_CHECKING: from homeassistant_api import Client @@ -65,7 +72,7 @@ def __init__( if cache_session is False: self.cache_session = requests.Session() elif cache_session is None: - self.cache_session = requests_cache.CachedSession( # type: ignore[attr-defined] + self.cache_session = requests_cache.CachedSession( cache_name="default_cache", backend="memory", expire_after=300, @@ -98,13 +105,12 @@ def request( if self.global_request_kwargs is not None: kwargs.update(self.global_request_kwargs) logger.debug("%s request to %s", method, self.endpoint(path)) - if self.cache_session: - resp = self.cache_session.request( - method, - self.endpoint(path) + f"?{params}" * bool(params), - headers=self.prepare_headers(headers), - **kwargs, - ) + resp = self.cache_session.request( + method, + self.endpoint(path) + f"?{params}" * bool(params), + headers=self.prepare_headers(headers), + **kwargs, + ) except requests.exceptions.Timeout as err: raise RequestTimeoutError( f'Home Assistant did not respond in time (timeout: {kwargs.get("timeout", 300)} sec)', @@ -125,12 +131,12 @@ def get_error_log(self) -> str: """ return cast(str, self.request("error_log")) - def get_config(self) -> Dict[str, Any]: + def get_config(self) -> dict[str, JSONType]: """ Returns the yaml configuration of homeassistant. :code:`GET /api/config` """ - return cast(Dict[str, Any], self.request("config")) + return cast(dict[str, JSONType], self.request("config")) def get_logbook_entries( self, @@ -201,7 +207,7 @@ def check_api_config(self) -> bool: :code:`POST /api/config/core/check_config` """ res = cast( - Dict[str, Any], self.request("config/core/check_config", method="POST") + dict[str, str], self.request("config/core/check_config", method="POST") ) valid = {"valid": True, "invalid": False}.get(res["result"], False) return valid @@ -212,7 +218,7 @@ def check_api_running(self) -> bool: :code:`GET /api/` """ res = self.request("") - return cast(Dict[str, Any], res).get("message") == "API running." + return cast(dict[str, JSONType], res).get("message") == "API running." # Entity methods def get_entities(self) -> Dict[str, Group]: @@ -270,7 +276,7 @@ def get_domains(self) -> Dict[str, Domain]: data = self.request("services") domains = map( lambda json: Domain.from_json(json, client=cast(Client, self)), - cast(Tuple[Dict[str, Any], ...], data), + cast(Tuple[dict[str, JSONType], ...], data), ) return {domain.domain_id: domain for domain in domains} @@ -296,14 +302,14 @@ def trigger_service( method="POST", json=service_data, ) - return tuple(map(State.from_json, cast(List[Dict[str, Any]], data))) + return tuple(map(State.from_json, cast(List[dict[str, JSONType]], data))) def trigger_service_with_response( self, domain: str, service: str, **service_data, - ) -> tuple[tuple[State, ...], dict[str, Any]]: + ) -> tuple[tuple[State, ...], dict[str, JSONType]]: """ Tells Home Assistant to trigger a service, returns the response from the service call. :code:`POST /api/services//` @@ -311,7 +317,7 @@ def trigger_service_with_response( Returns a list of the states changed and the response from the service call. """ data = cast( - dict[str, Any], + dict[str, dict[str, JSONType]], self.request( join("services", domain, service) + "?return_response", method="POST", @@ -344,7 +350,7 @@ def get_state( # pylint: disable=duplicate-code entity_id=entity_id, ) data = self.request(join("states", entity_id)) - return State.from_json(cast(Dict[str, Any], data)) + return State.from_json(cast(dict[str, JSONType], data)) def set_state( # pylint: disable=duplicate-code self, @@ -360,7 +366,7 @@ def set_state( # pylint: disable=duplicate-code method="POST", json=json.loads(state.model_dump_json()), ) - return State.from_json(cast(Dict[str, Any], data)) + return State.from_json(cast(dict[str, JSONType], data)) def get_states(self) -> Tuple[State, ...]: """ @@ -368,7 +374,7 @@ def get_states(self) -> Tuple[State, ...]: :code:`GET /api/states` """ data = self.request("states") - states = map(State.from_json, cast(List[Dict[str, Any]], data)) + states = map(State.from_json, cast(List[dict[str, JSONType]], data)) return tuple(states) # Event methods @@ -381,7 +387,7 @@ def get_events(self) -> Tuple[Event, ...]: return tuple( map( lambda json: Event.from_json(json, client=cast(Client, self)), - cast(List[Dict[str, Any]], data), + cast(List[dict[str, JSONType]], data), ) ) @@ -405,7 +411,7 @@ def fire_event(self, event_type: str, **event_data) -> Optional[str]: method="POST", json=event_data, ) - return cast(dict[str, Any], data).get("message") + return cast(dict[str, str], data).get("message") def get_components(self) -> Tuple[str, ...]: """ diff --git a/homeassistant_api/rawwebsocket.py b/homeassistant_api/rawwebsocket.py index 30c7d399..f353b19b 100644 --- a/homeassistant_api/rawwebsocket.py +++ b/homeassistant_api/rawwebsocket.py @@ -21,6 +21,7 @@ PingResponse, ResultResponse, ) +from homeassistant_api.utils import JSONType logger = logging.getLogger(__name__) @@ -60,6 +61,8 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): + if not self._conn: + raise ReceivingError("Connection is not open!") self._conn.__exit__(exc_type, exc_value, traceback) self._conn = None @@ -68,20 +71,20 @@ def _request_id(self) -> int: self._id_counter += 1 return self._id_counter - def _send(self, data: dict[str, Any]) -> None: + def _send(self, data: dict[str, JSONType]) -> None: """Send a message to the websocket server.""" logger.debug(f"Sending message: {data}") if self._conn is None: raise ReceivingError("Connection is not open!") self._conn.send(json.dumps(data)) - def _recv(self) -> dict[str, Any]: + def _recv(self) -> dict[str, JSONType]: """Receive a message from the websocket server.""" if self._conn is None: raise ReceivingError("Connection is not open!") _bytes = self._conn.recv() logger.debug("Received message: %s", _bytes) - return json.loads(_bytes) + return cast(dict[str, JSONType], json.loads(_bytes)) def send(self, type: str, include_id: bool = True, **data: Any) -> int: """ @@ -91,11 +94,12 @@ def send(self, type: str, include_id: bool = True, **data: Any) -> int: """ if include_id: # auth messages don't have an id data["id"] = self._request_id() - data["type"] = type + data["type"] = type self._send(data) if "id" in data: + assert isinstance(data["id"], int) if data["type"] == "ping": self._ping_responses[data["id"]] = PingResponse( start=time.perf_counter_ns(), @@ -108,7 +112,7 @@ def send(self, type: str, include_id: bool = True, **data: Any) -> int: return data["id"] return -1 # non-command messages don't have an id - def check_success(self, data: dict[str, Any]) -> None: + def check_success(self, data: dict[str, JSONType]) -> None: """Check if a command message was successful.""" try: error_resp = ErrorResponse.model_validate(data) @@ -116,7 +120,7 @@ def check_success(self, data: dict[str, Any]) -> None: except ValidationError: pass - def handle_recv(self, data: dict[str, Any]) -> None: + def handle_recv(self, data: dict[str, JSONType]) -> None: """Handle a received message.""" if "id" not in data: raise ReceivingError( @@ -125,16 +129,21 @@ def handle_recv(self, data: dict[str, Any]) -> None: self.check_success(data) self.parse_response(data) - def parse_response(self, data: dict[str, Any]) -> None: + def parse_response(self, data: dict[str, JSONType]) -> None: + data_id = cast(int, data["id"]) if data.get("type") == "pong": logger.info("Received pong message") - self._ping_responses[data["id"]].end = time.perf_counter_ns() + self._ping_responses[data_id].end = time.perf_counter_ns() elif data.get("type") == "result": logger.info("Received result message") - self._result_responses[data["id"]] = ResultResponse.model_validate(data) + if data.get("success"): + self._result_responses[data_id] = ResultResponse.model_validate(data) + else: + error_resp = ErrorResponse.model_validate(data) + raise RequestError(error_resp.error.code, error_resp.error.message) elif data.get("type") == "event": logger.info("Received event message %s", data["event"]) - self._event_responses[data["id"]].append(EventResponse.model_validate(data)) + self._event_responses[data_id].append(EventResponse.model_validate(data)) else: raise ReceivingError(f"Received unexpected message type: {data}") diff --git a/homeassistant_api/utils.py b/homeassistant_api/utils.py index ff2947ce..7e625482 100644 --- a/homeassistant_api/utils.py +++ b/homeassistant_api/utils.py @@ -1,5 +1,15 @@ +import os import re -from typing import Optional +from typing import TYPE_CHECKING, Optional, Union # noqa: F401 + +from typing_extensions import TypeAliasType + +if TYPE_CHECKING or os.getenv("DOCUMENTATION_MODE") != "true": + JSONType = TypeAliasType( + "JSONType", "Optional[Union[int, float, str, bool, list[JSONType], dict[str, JSONType]]]" + ) +else: + JSONType = type("JSONType", (object,), {}) def format_entity_id(entity_id: str) -> str: diff --git a/homeassistant_api/websocket.py b/homeassistant_api/websocket.py index 81eac53a..a2cfd564 100644 --- a/homeassistant_api/websocket.py +++ b/homeassistant_api/websocket.py @@ -1,7 +1,7 @@ import contextlib import logging import urllib.parse as urlparse -from typing import Any, Dict, Generator, Optional, Tuple, Union, cast +from typing import Dict, Generator, Optional, Tuple, Union, cast from homeassistant_api.models import Domain, Entity, Group, State from homeassistant_api.models.states import Context @@ -12,7 +12,7 @@ ResultResponse, TemplateEvent, ) -from homeassistant_api.utils import prepare_entity_id +from homeassistant_api.utils import JSONType, prepare_entity_id from .rawwebsocket import RawWebsocketClient @@ -63,14 +63,14 @@ def get_rendered_template(self, template: str) -> str: self._unsubscribe(id) return cast(TemplateEvent, cast(EventResponse, second).event).result - def get_config(self) -> dict[str, Any]: + def get_config(self) -> dict[str, JSONType]: """ Get the Home Assistant configuration. Sends command :code:`{"type": "get_config", ...}`. """ return cast( - dict[str, Any], + dict[str, JSONType], cast( ResultResponse, self.recv(self.send("get_config")), @@ -86,7 +86,7 @@ def get_states(self) -> Tuple[State, ...]: return tuple( State.from_json(state) for state in cast( - list[dict[str, Any]], + list[dict[str, JSONType]], cast(ResultResponse, self.recv(self.send("get_states"))).result, ) ) @@ -179,7 +179,7 @@ def get_domains(self) -> dict[str, Domain]: {"domain": item[0], "services": item[1]}, client=self, ), - cast(dict[str, Any], cast(ResultResponse, resp).result).items(), + cast(dict[str, JSONType], cast(ResultResponse, resp).result).items(), ) return {domain.domain_id: domain for domain in domains} @@ -221,7 +221,7 @@ def trigger_service( assert ( cast( - dict[str, Any], + dict[str, JSONType], cast(ResultResponse, data).result, ).get("response") is None @@ -233,7 +233,7 @@ def trigger_service_with_response( service: str, entity_id: Optional[str] = None, **service_data, - ) -> dict[str, Any]: + ) -> dict[str, JSONType]: """ Trigger a service (that returns a response) and return the response. @@ -250,7 +250,9 @@ def trigger_service_with_response( data = self.recv(self.send("call_service", include_id=True, **params)) - return cast(dict[str, Any], cast(ResultResponse, data).result)["response"] + return cast(dict[str, dict[str, JSONType]], cast(ResultResponse, data).result)[ + "response" + ] @contextlib.contextmanager def listen_events( @@ -285,7 +287,7 @@ def _subscribe_events(self, event_type: Optional[str]) -> int: @contextlib.contextmanager def listen_trigger( self, trigger: str, **trigger_fields - ) -> Generator[Generator[dict[str, Any], None, None], None, None]: + ) -> Generator[Generator[dict[str, JSONType], None, None], None, None]: """ Listen to a Home Assistant trigger. Allows additional trigger keyword parameters with :code:`**kwargs` (i.e. passing :code:`tag_id=...` for NFC tag triggers). @@ -364,12 +366,12 @@ def fire_event(self, event_type: str, **event_data) -> Context: Sends command :code:`{"type": "fire_event", ...}`. """ - params: dict[str, Any] = {"event_type": event_type} + params: dict[str, JSONType] = {"event_type": event_type} if event_data: params["event_data"] = event_data return Context.from_json( cast( - dict[str, dict[str, Any]], + dict[str, dict[str, JSONType]], cast( ResultResponse, self.recv(self.send("fire_event", include_id=True, **params)), diff --git a/poetry.lock b/poetry.lock index 7ce8961f..1cf1d6d6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -119,7 +119,7 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] +speedups = ["Brotli", "aiodns (>=3.3.0)", "brotlicffi"] [[package]] name = "aiohttp-client-cache" @@ -250,12 +250,12 @@ files = [ ] [package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "autodoc-pydantic" @@ -316,8 +316,8 @@ typing-extensions = {version = ">=4.1.0,<4.6.3 || >4.6.3", markers = "python_ver bson = ["pymongo (>=4.4.0)"] cbor2 = ["cbor2 (>=5.4.6)"] msgpack = ["msgpack (>=1.0.5)"] -msgspec = ["msgspec (>=0.18.5) ; implementation_name == \"cpython\""] -orjson = ["orjson (>=3.9.2) ; implementation_name == \"cpython\""] +msgspec = ["msgspec (>=0.18.5)"] +orjson = ["orjson (>=3.9.2)"] pyyaml = ["pyyaml (>=6.0)"] tomlkit = ["tomlkit (>=0.11.8)"] ujson = ["ujson (>=5.7.0)"] @@ -545,7 +545,7 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +toml = ["tomli"] [[package]] name = "distlib" @@ -602,7 +602,7 @@ files = [ [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] -typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] +typing = ["typing-extensions (>=4.12.2)"] [[package]] name = "frozenlist" @@ -750,12 +750,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -977,48 +977,61 @@ typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} [[package]] name = "mypy" -version = "1.11.2" +version = "1.17.1" description = "Optional static typing for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["styling"] files = [ - {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, - {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, - {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, - {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, - {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, - {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, - {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, - {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, - {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, - {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, - {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, - {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, - {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, - {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, - {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, - {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, - {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, - {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, - {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, - {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, - {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, - {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, - {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, - {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, - {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, - {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, - {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, + {file = "mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972"}, + {file = "mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7"}, + {file = "mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df"}, + {file = "mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390"}, + {file = "mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94"}, + {file = "mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b"}, + {file = "mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58"}, + {file = "mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5"}, + {file = "mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd"}, + {file = "mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b"}, + {file = "mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5"}, + {file = "mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b"}, + {file = "mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb"}, + {file = "mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403"}, + {file = "mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056"}, + {file = "mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341"}, + {file = "mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb"}, + {file = "mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19"}, + {file = "mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7"}, + {file = "mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81"}, + {file = "mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6"}, + {file = "mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849"}, + {file = "mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14"}, + {file = "mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a"}, + {file = "mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733"}, + {file = "mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd"}, + {file = "mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0"}, + {file = "mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a"}, + {file = "mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91"}, + {file = "mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed"}, + {file = "mypy-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5d1092694f166a7e56c805caaf794e0585cabdbf1df36911c414e4e9abb62ae9"}, + {file = "mypy-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79d44f9bfb004941ebb0abe8eff6504223a9c1ac51ef967d1263c6572bbebc99"}, + {file = "mypy-1.17.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b01586eed696ec905e61bd2568f48740f7ac4a45b3a468e6423a03d3788a51a8"}, + {file = "mypy-1.17.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43808d9476c36b927fbcd0b0255ce75efe1b68a080154a38ae68a7e62de8f0f8"}, + {file = "mypy-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:feb8cc32d319edd5859da2cc084493b3e2ce5e49a946377663cc90f6c15fb259"}, + {file = "mypy-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7598cf74c3e16539d4e2f0b8d8c318e00041553d83d4861f87c7a72e95ac24d"}, + {file = "mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9"}, + {file = "mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01"}, ] [package.dependencies] -mypy-extensions = ">=1.0.0" +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.6.0" +typing_extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] reports = ["lxml"] @@ -1059,6 +1072,18 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["styling"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + [[package]] name = "platformdirs" version = "4.3.6" @@ -1578,7 +1603,7 @@ urllib3 = ">=1.25.5" [package.extras] all = ["boto3 (>=1.15)", "botocore (>=1.18)", "itsdangerous (>=2.0)", "pymongo (>=3)", "pyyaml (>=5.4)", "redis (>=3)", "ujson (>=4.0)"] bson = ["bson (>=0.5)"] -docs = ["furo (>=2021.9.8)", "linkify-it-py (>=1.0.1,<2.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx (==4.3.0)", "sphinx-autodoc-typehints (>=1.11,<2.0)", "sphinx-automodapi (>=0.13,<0.15)", "sphinx-copybutton (>=0.3,<0.5)", "sphinx-inline-tabs (>=2022.1.2b11) ; python_version >= \"3.8\"", "sphinx-notfound-page (>=0.8)", "sphinx-panels (>=0.6,<0.7)", "sphinxcontrib-apidoc (>=0.3,<0.4)"] +docs = ["furo (>=2021.9.8)", "linkify-it-py (>=1.0.1,<2.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx (==4.3.0)", "sphinx-autodoc-typehints (>=1.11,<2.0)", "sphinx-automodapi (>=0.13,<0.15)", "sphinx-copybutton (>=0.3,<0.5)", "sphinx-inline-tabs (>=2022.1.2b11)", "sphinx-notfound-page (>=0.8)", "sphinx-panels (>=0.6,<0.7)", "sphinxcontrib-apidoc (>=0.3,<0.4)"] dynamodb = ["boto3 (>=1.15)", "botocore (>=1.18)"] json = ["ujson (>=4.0)"] mongodb = ["pymongo (>=3)"] @@ -2058,7 +2083,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -2082,7 +2107,7 @@ platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [[package]] name = "websockets" @@ -2274,11 +2299,11 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] diff --git a/tests/test_client.py b/tests/test_client.py index 105db369..22c3a6d2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,6 @@ import os -import aiohttp_client_cache +import aiohttp_client_cache.session import requests_cache from homeassistant_api import Client, WebsocketClient @@ -28,7 +28,7 @@ async def test_custom_async_cached_session() -> None: async with Client( os.environ["HOMEASSISTANTAPI_URL"], os.environ["HOMEASSISTANTAPI_TOKEN"], - async_cache_session=aiohttp_client_cache.CachedSession( + async_cache_session=aiohttp_client_cache.session.CachedSession( cache=aiohttp_client_cache.SQLiteBackend( cache_name="test_custom_async_cached_session.sqlite", expire_after=10,