From 20b53f599693e3d3920140da7e9089209a5e18b7 Mon Sep 17 00:00:00 2001 From: "Hencken, Timo (415)" Date: Thu, 20 Feb 2025 12:35:43 +0100 Subject: [PATCH 01/10] fixed date formatting for get_entity_histories --- homeassistant_api/rawbaseclient.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/homeassistant_api/rawbaseclient.py b/homeassistant_api/rawbaseclient.py index 6e506983..05db6d3c 100644 --- a/homeassistant_api/rawbaseclient.py +++ b/homeassistant_api/rawbaseclient.py @@ -73,18 +73,33 @@ def prepare_get_entity_histories_params( end_timestamp: Optional[datetime] = None, significant_changes_only: bool = False, ) -> Tuple[Dict[str, Optional[str]], str]: - """Pre-logic for `Client.get_entity_histories` and `Client.async_get_entity_histories`.""" + """ + Pre-logic for `Client.get_entity_histories` and `Client.async_get_entity_histories`. + + Ensure timestamps + * use second resolution + * are timezone-aware + * are URL-encoded (as construct_params(params) is used instead of request's default parameter encoding) + """ params: Dict[str, Optional[str]] = {} if entities is not None: params["filter_entity_id"] = ",".join([ent.entity_id for ent in entities]) if end_timestamp is not None: - params["end_time"] = ( - end_timestamp.isoformat() - ) # Params are automatically URL encoded + end_timestamp = end_timestamp.replace(microsecond=0) + timedelta(seconds=1) + if end_timestamp.tzinfo is None: + end_timestamp = end_timestamp.astimezone() + end_timestamp = end_timestamp.isoformat() + end_timestamp = quote_plus(end_timestamp) + params["end_time"] = end_timestamp if significant_changes_only: params["significant_changes_only"] = None if start_timestamp is not None: - url = join("history/period/", start_timestamp.isoformat()) + start_timestamp = start_timestamp.replace(microsecond=0) + if start_timestamp.tzinfo is None: + start_timestamp = start_timestamp.astimezone() + start_timestamp = start_timestamp.isoformat() + start_timestamp = quote_plus(start_timestamp) + url = join("history/period/", start_timestamp) else: url = "history/period" return params, url From 6e126488aa89994f893009594902840d38bebb81 Mon Sep 17 00:00:00 2001 From: "Hencken, Timo (415)" Date: Thu, 20 Feb 2025 15:15:03 +0100 Subject: [PATCH 02/10] added missing imports --- homeassistant_api/rawbaseclient.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant_api/rawbaseclient.py b/homeassistant_api/rawbaseclient.py index 05db6d3c..8dc34333 100644 --- a/homeassistant_api/rawbaseclient.py +++ b/homeassistant_api/rawbaseclient.py @@ -1,8 +1,9 @@ """Module for parent RawWrapper class""" -from datetime import datetime +from datetime import datetime, timedelta from posixpath import join from typing import Any, Dict, Iterable, Optional, Tuple, Union +from urllib.parse import quote_plus from .models import Entity From 725eefb0756935317df60cdac161132181fb530c Mon Sep 17 00:00:00 2001 From: "Hencken, Timo (415)" Date: Thu, 20 Feb 2025 15:22:56 +0100 Subject: [PATCH 03/10] satisfy mypy --- homeassistant_api/rawbaseclient.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant_api/rawbaseclient.py b/homeassistant_api/rawbaseclient.py index 8dc34333..e4e1185f 100644 --- a/homeassistant_api/rawbaseclient.py +++ b/homeassistant_api/rawbaseclient.py @@ -89,18 +89,18 @@ def prepare_get_entity_histories_params( end_timestamp = end_timestamp.replace(microsecond=0) + timedelta(seconds=1) if end_timestamp.tzinfo is None: end_timestamp = end_timestamp.astimezone() - end_timestamp = end_timestamp.isoformat() - end_timestamp = quote_plus(end_timestamp) - params["end_time"] = end_timestamp + end_time = end_timestamp.isoformat() + end_time = quote_plus(end_time) + params["end_time"] = end_time if significant_changes_only: params["significant_changes_only"] = None if start_timestamp is not None: start_timestamp = start_timestamp.replace(microsecond=0) if start_timestamp.tzinfo is None: start_timestamp = start_timestamp.astimezone() - start_timestamp = start_timestamp.isoformat() - start_timestamp = quote_plus(start_timestamp) - url = join("history/period/", start_timestamp) + start_time = start_timestamp.isoformat() + start_time = quote_plus(start_time) + url = join("history/period/", start_time) else: url = "history/period" return params, url From a3abf84e80c9d82a03e956358ab1c21858db1c0e Mon Sep 17 00:00:00 2001 From: Nate Date: Fri, 21 Feb 2025 17:57:02 -0600 Subject: [PATCH 04/10] Don't double url-encode timestamps + buff rst docstring --- homeassistant_api/rawbaseclient.py | 36 ++++++++++++++++-------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/homeassistant_api/rawbaseclient.py b/homeassistant_api/rawbaseclient.py index e4e1185f..5ac30a04 100644 --- a/homeassistant_api/rawbaseclient.py +++ b/homeassistant_api/rawbaseclient.py @@ -3,7 +3,6 @@ from datetime import datetime, timedelta from posixpath import join from typing import Any, Dict, Iterable, Optional, Tuple, Union -from urllib.parse import quote_plus from .models import Entity @@ -63,7 +62,13 @@ def prepare_headers( @staticmethod def construct_params(params: Dict[str, Optional[str]]) -> str: - """Custom method for constructing non-standard query strings""" + """ + Custom method for constructing non-standard query strings. + + For keys with corresponding None values, the query string will be key only (i.e. :code:`?key1&key2`). + For keys with corresponding non-None values, the query string will be key-value pairs (i.e. :code:`?key1=value1&key2=value2`). + To have an empty value use an empty string :code:`""` (i.e. :code:`?key1=&key2=value2`). + """ return "&".join([k if v is None else f"{k}={v}" for k, v in params.items()]) @staticmethod @@ -75,34 +80,31 @@ def prepare_get_entity_histories_params( significant_changes_only: bool = False, ) -> Tuple[Dict[str, Optional[str]], str]: """ - Pre-logic for `Client.get_entity_histories` and `Client.async_get_entity_histories`. + Pre-logic for :py:meth:`Client.get_entity_histories` and :py:meth:`Client.async_get_entity_histories`. Ensure timestamps - * use second resolution + + * use second resolution (microseconds are truncated) * are timezone-aware - * are URL-encoded (as construct_params(params) is used instead of request's default parameter encoding) + * are URL-encoded (as :py:meth:`construct_params` is used instead of request's default parameter encoding) """ params: Dict[str, Optional[str]] = {} if entities is not None: params["filter_entity_id"] = ",".join([ent.entity_id for ent in entities]) - if end_timestamp is not None: - end_timestamp = end_timestamp.replace(microsecond=0) + timedelta(seconds=1) - if end_timestamp.tzinfo is None: - end_timestamp = end_timestamp.astimezone() - end_time = end_timestamp.isoformat() - end_time = quote_plus(end_time) - params["end_time"] = end_time - if significant_changes_only: - params["significant_changes_only"] = None if start_timestamp is not None: start_timestamp = start_timestamp.replace(microsecond=0) if start_timestamp.tzinfo is None: start_timestamp = start_timestamp.astimezone() - start_time = start_timestamp.isoformat() - start_time = quote_plus(start_time) - url = join("history/period/", start_time) + url = join("history/period/", start_timestamp.isoformat()) else: url = "history/period" + if end_timestamp is not None: + end_timestamp = end_timestamp.replace(microsecond=0) + timedelta(seconds=1) + if end_timestamp.tzinfo is None: + end_timestamp = end_timestamp.astimezone() + params["end_time"] = end_timestamp.isoformat() + if significant_changes_only: + params["significant_changes_only"] = None return params, url @staticmethod From 102bb8c19a363e9982b8db0eb9d5c58fa117b948 Mon Sep 17 00:00:00 2001 From: Nate Date: Fri, 21 Feb 2025 17:57:39 -0600 Subject: [PATCH 05/10] Lint --- homeassistant_api/websocket.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant_api/websocket.py b/homeassistant_api/websocket.py index 384155c5..81eac53a 100644 --- a/homeassistant_api/websocket.py +++ b/homeassistant_api/websocket.py @@ -21,11 +21,11 @@ class WebsocketClient(RawWebsocketClient): """ - + The main class for interactign with the Home Assistant WebSocket API client. Here's a quick example of how to use the :py:class:`WebsocketClient` class: - + .. code-block:: python from homeassistant_api import WebsocketClient @@ -66,7 +66,7 @@ def get_rendered_template(self, template: str) -> str: def get_config(self) -> dict[str, Any]: """ Get the Home Assistant configuration. - + Sends command :code:`{"type": "get_config", ...}`. """ return cast( @@ -80,7 +80,7 @@ def get_config(self) -> dict[str, Any]: def get_states(self) -> Tuple[State, ...]: """ Get a list of states. - + Sends command :code:`{"type": "get_states", ...}`. """ return tuple( @@ -170,7 +170,7 @@ def get_domains(self) -> dict[str, Domain]: Get a list of services that Home Assistant offers (organized into a dictionary of service domains). For example, the service :code:`light.turn_on` would be in the domain :code:`light`. - + Sends command :code:`{"type": "get_services", ...}`. """ resp = self.recv(self.send("get_services")) @@ -203,7 +203,7 @@ def trigger_service( ) -> None: """ Trigger a service (that doesn't return a response). - + Sends command :code:`{"type": "call_service", ...}`. """ params = { @@ -236,7 +236,7 @@ def trigger_service_with_response( ) -> dict[str, Any]: """ Trigger a service (that returns a response) and return the response. - + Sends command :code:`{"type": "call_service", ...}`. """ params = { @@ -261,7 +261,7 @@ def listen_events( Listen for all events of a certain type. For example, to listen for all events of type `test_event`: - + .. code-block:: python with ws_client.listen_events("test_event") as events: @@ -275,7 +275,7 @@ def listen_events( def _subscribe_events(self, event_type: Optional[str]) -> int: """ Subscribe to all events of a certain type. - + Sends command :code:`{"type": "subscribe_events", ...}`. """ @@ -292,15 +292,15 @@ def listen_trigger( For example, in Home Assistant Automations we can subscribe to a state trigger for a light entity with YAML: - .. code-block:: yaml - + .. code-block:: yaml + triggers: # ... - trigger: state entity_id: light.kitchen To subscribe to that same state trigger with :py:class:`WebsocketClient` instead - + .. code-block:: python with ws_client.listen_trigger("state", entity_id="light.kitchen") as trigger: @@ -309,7 +309,7 @@ def listen_trigger( if : break # exiting the context manager unsubscribes from the trigger - + Woohoo! We can now listen to triggers in Python code! """ subscription = self._subscribe_trigger(trigger, **trigger_fields) @@ -325,7 +325,7 @@ def listen_trigger( def _subscribe_trigger(self, trigger: str, **trigger_fields) -> int: """ Return the subscription id of the trigger we subscribe to. - + Sends command :code:`{"type": "subscribe_trigger", ...}`. """ return self.recv( @@ -351,7 +351,7 @@ def _wait_for( def _unsubscribe(self, subcription_id: int) -> None: """ Unsubscribe from all events of a certain type. - + Sends command :code:`{"type": "unsubscribe_events", ...}`. """ resp = self.recv(self.send("unsubscribe_events", subscription=subcription_id)) @@ -361,7 +361,7 @@ def _unsubscribe(self, subcription_id: int) -> None: def fire_event(self, event_type: str, **event_data) -> Context: """ Fire an event. - + Sends command :code:`{"type": "fire_event", ...}`. """ params: dict[str, Any] = {"event_type": event_type} From 3999fd4d9b86769bd8b9f86de7cb7bb3bceb1db3 Mon Sep 17 00:00:00 2001 From: Nate Date: Fri, 21 Feb 2025 17:58:15 -0600 Subject: [PATCH 06/10] Test changes --- tests/conftest.py | 2 ++ tests/test_endpoints.py | 20 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c9ea1995..3381d640 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,8 @@ from homeassistant_api import Client, WebsocketClient +logging.basicConfig(level=logging.INFO) + TIMEOUT = 300 diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index cbc1b01b..4197e873 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -44,7 +44,11 @@ def test_get_logbook_entries(cached_client: Client) -> None: async def test_async_get_logbook_entries(async_cached_client: Client) -> None: """Tests the `GET /api/logbook/` endpoint.""" - async for entry in async_cached_client.async_get_logbook_entries(): + async for entry in async_cached_client.async_get_logbook_entries( + filter_entities="sun.sun", + start_timestamp=datetime(2020, 1, 1), + end_timestamp=datetime.now(), + ): assert entry @@ -64,12 +68,18 @@ def test_get_entity_histories(cached_client: Client) -> None: assert sun is not None for history in cached_client.get_entity_histories( (sun,), - end_timestamp=datetime(2023, 1, 1), + end_timestamp=datetime.now(), # test for microsecond truncation start_timestamp=datetime(2020, 1, 1), significant_changes_only=True, ): for state in history.states: assert isinstance(state, State) + break + else: + raise AssertionError("No states in entity history found.") + break + else: + raise AssertionError("No history found.") async def test_async_get_entity_histories(async_cached_client: Client) -> None: @@ -79,6 +89,12 @@ async def test_async_get_entity_histories(async_cached_client: Client) -> None: async for history in async_cached_client.async_get_entity_histories((sun,)): for state in history.states: assert isinstance(state, State) + break + else: + raise AssertionError("No states in entity history found.") + break + else: + raise AssertionError("No history found.") def test_get_rendered_template(cached_client: Client) -> None: From b50460c485ce9f743b76aa62d60735cd73f06418 Mon Sep 17 00:00:00 2001 From: Nate Date: Fri, 21 Feb 2025 18:48:38 -0600 Subject: [PATCH 07/10] Make request error more helpful --- homeassistant_api/errors.py | 7 +++++++ homeassistant_api/processing.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant_api/errors.py b/homeassistant_api/errors.py index f3fe493f..1e0a34a3 100644 --- a/homeassistant_api/errors.py +++ b/homeassistant_api/errors.py @@ -10,6 +10,13 @@ class HomeassistantAPIError(Exception): class RequestError(HomeassistantAPIError): """Error raised when an issue occurs when requesting to Homeassistant.""" + def __init__(self, data: str, url: str) -> None: + if data is None: + super().__init__(f"An error occurred while making the request to {url!r}") + else: + super().__init__( + f"An error occurred while making the request to {url!r} with data: {data!r}" + ) class RequestTimeoutError(RequestError): """Error raised when a request times out.""" diff --git a/homeassistant_api/processing.py b/homeassistant_api/processing.py index 08baa947..ade160c4 100644 --- a/homeassistant_api/processing.py +++ b/homeassistant_api/processing.py @@ -88,7 +88,7 @@ def process(self) -> Any: if status_code in (200, 201): return self.process_content(async_=async_) if status_code == 400: - raise RequestError(content) + raise RequestError(content, url=self._response.url) # type: ignore if status_code == 401: raise UnauthorizedError() if status_code == 404: From c60b0bd8aa74e9e9a671ae0e59d923019983239b Mon Sep 17 00:00:00 2001 From: Nate Date: Fri, 21 Feb 2025 19:11:21 -0600 Subject: [PATCH 08/10] urlencode timestamps, but don't encode the query string again --- homeassistant_api/errors.py | 2 +- homeassistant_api/rawasyncclient.py | 4 +++- homeassistant_api/rawbaseclient.py | 3 ++- homeassistant_api/rawclient.py | 4 +++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant_api/errors.py b/homeassistant_api/errors.py index 1e0a34a3..e7d32739 100644 --- a/homeassistant_api/errors.py +++ b/homeassistant_api/errors.py @@ -10,7 +10,7 @@ class HomeassistantAPIError(Exception): class RequestError(HomeassistantAPIError): """Error raised when an issue occurs when requesting to Homeassistant.""" - def __init__(self, data: str, url: str) -> None: + def __init__(self, data: str, /, url: str) -> None: if data is None: super().__init__(f"An error occurred while making the request to {url!r}") else: diff --git a/homeassistant_api/rawasyncclient.py b/homeassistant_api/rawasyncclient.py index 778e753b..6bb1a6e1 100644 --- a/homeassistant_api/rawasyncclient.py +++ b/homeassistant_api/rawasyncclient.py @@ -92,6 +92,8 @@ async def __aexit__(self, _, __, ___): async def async_request( self, path: str, + *, + params: str = "", # should be a string of query parameters from construct_params() method: str = "GET", headers: Optional[Dict[str, str]] = None, **kwargs, @@ -103,7 +105,7 @@ async def async_request( return await self.async_response_logic( await self.async_cache_session.request( method, - self.endpoint(path), + self.endpoint(path) + f"?{params}" * bool(params), headers=self.prepare_headers(headers), **kwargs, ) diff --git a/homeassistant_api/rawbaseclient.py b/homeassistant_api/rawbaseclient.py index 5ac30a04..5625c2bf 100644 --- a/homeassistant_api/rawbaseclient.py +++ b/homeassistant_api/rawbaseclient.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from posixpath import join from typing import Any, Dict, Iterable, Optional, Tuple, Union +from urllib.parse import quote_plus, urlencode from .models import Entity @@ -69,7 +70,7 @@ def construct_params(params: Dict[str, Optional[str]]) -> str: For keys with corresponding non-None values, the query string will be key-value pairs (i.e. :code:`?key1=value1&key2=value2`). To have an empty value use an empty string :code:`""` (i.e. :code:`?key1=&key2=value2`). """ - return "&".join([k if v is None else f"{k}={v}" for k, v in params.items()]) + return "&".join([k if v is None else f"{k}={quote_plus(v)}" for k, v in params.items()]) @staticmethod def prepare_get_entity_histories_params( diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 4b366e21..53215c16 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -86,6 +86,8 @@ def __exit__(self, _, __, ___) -> None: def request( self, path: str, + *, + params: str = "", # should be a string of query parameters from construct_params() method="GET", headers: Optional[Dict[str, str]] = None, decode_bytes: bool = True, @@ -99,7 +101,7 @@ def request( if self.cache_session: resp = self.cache_session.request( method, - self.endpoint(path), + self.endpoint(path) + f"?{params}" * bool(params), headers=self.prepare_headers(headers), **kwargs, ) From 1ddfc57a6a3d117375c07aeae866aaae94af7013 Mon Sep 17 00:00:00 2001 From: Nate Date: Fri, 21 Feb 2025 19:29:04 -0600 Subject: [PATCH 09/10] Remove silly unused import --- homeassistant_api/rawbaseclient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant_api/rawbaseclient.py b/homeassistant_api/rawbaseclient.py index 5625c2bf..d5957699 100644 --- a/homeassistant_api/rawbaseclient.py +++ b/homeassistant_api/rawbaseclient.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from posixpath import join from typing import Any, Dict, Iterable, Optional, Tuple, Union -from urllib.parse import quote_plus, urlencode +from urllib.parse import quote_plus from .models import Entity From d52908b99f51fdcaec2db42e764757cb45842015 Mon Sep 17 00:00:00 2001 From: Nate Date: Fri, 21 Feb 2025 21:17:27 -0600 Subject: [PATCH 10/10] Silly type hinting --- homeassistant_api/errors.py | 16 ++++++++++++++-- homeassistant_api/rawasyncclient.py | 7 +++++-- homeassistant_api/rawbaseclient.py | 4 +++- homeassistant_api/rawclient.py | 7 +++++-- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/homeassistant_api/errors.py b/homeassistant_api/errors.py index e7d32739..01136260 100644 --- a/homeassistant_api/errors.py +++ b/homeassistant_api/errors.py @@ -10,17 +10,29 @@ class HomeassistantAPIError(Exception): class RequestError(HomeassistantAPIError): """Error raised when an issue occurs when requesting to Homeassistant.""" - def __init__(self, data: str, /, url: str) -> None: - if data is None: + def __init__( + self, data: Optional[str], /, url: str, message: Optional[str] = None + ) -> None: + if message is not None: + super().__init__( + message + + f" {url!r}" + + (f" with data: {data!r}" if data is not None else "") + ) + elif data is None: super().__init__(f"An error occurred while making the request to {url!r}") else: super().__init__( f"An error occurred while making the request to {url!r} with data: {data!r}" ) + class RequestTimeoutError(RequestError): """Error raised when a request times out.""" + def __init__(self, message: str, url: str) -> None: + super().__init__(None, url, message) + class ResponseError(HomeassistantAPIError): """Error raised when an issue occurs in a response from Homeassistant.""" diff --git a/homeassistant_api/rawasyncclient.py b/homeassistant_api/rawasyncclient.py index 6bb1a6e1..516e5453 100644 --- a/homeassistant_api/rawasyncclient.py +++ b/homeassistant_api/rawasyncclient.py @@ -112,7 +112,8 @@ async def async_request( ) except asyncio.exceptions.TimeoutError as err: raise RequestTimeoutError( - f'Home Assistant did not respond in time (timeout: {kwargs.get("timeout", 300)} sec)' + f'Home Assistant did not respond in time (timeout: {kwargs.get("timeout", 300)} sec)', + self.endpoint(path) + f"?{params}" * bool(params), ) from err @staticmethod @@ -145,7 +146,9 @@ async def async_get_logbook_entries( :code:`GET /api/logbook/` """ params, url = self.prepare_get_logbook_entry_params(*args, **kwargs) - data = await self.async_request(url, params=params) + data = await self.async_request( + url, params=self.construct_params(cast(Dict[str, Optional[str]], params)) + ) for entry in data: yield LogbookEntry.model_validate(entry) diff --git a/homeassistant_api/rawbaseclient.py b/homeassistant_api/rawbaseclient.py index d5957699..29465358 100644 --- a/homeassistant_api/rawbaseclient.py +++ b/homeassistant_api/rawbaseclient.py @@ -70,7 +70,9 @@ def construct_params(params: Dict[str, Optional[str]]) -> str: For keys with corresponding non-None values, the query string will be key-value pairs (i.e. :code:`?key1=value1&key2=value2`). To have an empty value use an empty string :code:`""` (i.e. :code:`?key1=&key2=value2`). """ - return "&".join([k if v is None else f"{k}={quote_plus(v)}" for k, v in params.items()]) + return "&".join( + [k if v is None else f"{k}={quote_plus(v)}" for k, v in params.items()] + ) @staticmethod def prepare_get_entity_histories_params( diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 53215c16..14e19b4e 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -107,7 +107,8 @@ def request( ) except requests.exceptions.Timeout as err: raise RequestTimeoutError( - f'Home Assistant did not respond in time (timeout: {kwargs.get("timeout", 300)} sec)' + f'Home Assistant did not respond in time (timeout: {kwargs.get("timeout", 300)} sec)', + url=self.endpoint(path) + f"?{params}" * bool(params), ) from err return self.response_logic(response=resp, decode_bytes=decode_bytes) @@ -141,7 +142,9 @@ def get_logbook_entries( :code:`GET /api/logbook/` """ params, url = self.prepare_get_logbook_entry_params(*args, **kwargs) - data = self.request(url, params=params) + data = self.request( + url, params=self.construct_params(cast(Dict[str, Optional[str]], params)) + ) for entry in data: yield LogbookEntry.model_validate(entry)