From 6749864c4a1ec978e6c01ce0425b40d5434e4f3a Mon Sep 17 00:00:00 2001 From: Brook Date: Tue, 24 Mar 2026 15:04:21 -0700 Subject: [PATCH 1/7] support for device custom property history --- foxglove/client/api.py | 171 +++++++++++++++++-- tests/test_device_custom_property_history.py | 136 +++++++++++++++ 2 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 tests/test_device_custom_property_history.py diff --git a/foxglove/client/api.py b/foxglove/client/api.py index 69213a2..eb0ae55 100644 --- a/foxglove/client/api.py +++ b/foxglove/client/api.py @@ -651,12 +651,9 @@ def get_device( :param project_id: Project to retrieve the device from. Required for multi-project organizations. """ - if device_name and device_id: - raise RuntimeError("device_id and device_name are mutually exclusive") - if device_name is None and device_id is None: - raise RuntimeError("device_id or device_name must be provided") + identifier = _device_identifier(device_id, device_name) response = self.__session.get( - self.__url__(f"/v1/devices/{device_name or device_id}"), + self.__url__(f"/v1/devices/{urlquote(identifier, safe='')}"), params={"projectId": project_id} if project_id is not None else None, ) @@ -728,13 +725,10 @@ def update_device( :param project_id: Project to retrieve the device from. Required for multi-project organizations. """ - if device_name and device_id: - raise RuntimeError("device_id and device_name are mutually exclusive") - if device_name is None and device_id is None: - raise RuntimeError("device_id or device_name must be provided") + identifier = _device_identifier(device_id, device_name) response = self.__session.patch( - self.__url__(f"/v1/devices/{device_name or device_id}"), + self.__url__(f"/v1/devices/{urlquote(identifier, safe='')}"), params={"projectId": project_id} if project_id is not None else None, json=without_nulls({"name": new_name, "properties": properties}), ) @@ -758,12 +752,9 @@ def delete_device( :param project_id: Project to delete the device from. Required for multi-project organizations. """ - if device_name and device_id: - raise RuntimeError("device_id and device_name are mutually exclusive") - if device_name is None and device_id is None: - raise RuntimeError("device_id or device_name must be provided") + identifier = _device_identifier(device_id, device_name) response = self.__session.delete( - self.__url__(f"/v1/devices/{device_name or device_id}"), + self.__url__(f"/v1/devices/{urlquote(identifier, safe='')}"), params={"projectId": project_id} if project_id is not None else None, ) json_or_raise(response) @@ -1333,6 +1324,133 @@ def delete_session( return json_or_raise(response) + def get_device_custom_property_history( + self, + *, + device_id: Optional[str] = None, + device_name: Optional[str] = None, + project_id: Optional[str] = None, + id: str, + ): + """ + Fetches a single device custom property history record. + + device_id: The ID of the device to retrieve the history record from. + Use this or name. + device_name: The name of the device to retrieve the history record from. + Use this or device_id. + project_id: Project to retrieve the history record from. + Required for multi-project organizations. + id: The ID of the history record to fetch. + """ + identifier = _device_identifier(device_id, device_name) + + response = self.__session.get( + self.__url__( + f"/v1/devices/{urlquote(identifier, safe='')}/property-history/" + f"{urlquote(id, safe='')}" + ), + params=without_nulls({"projectId": project_id}), + ) + return _device_custom_property_history_dict(json_or_raise(response)) + + def get_device_custom_property_history_records( + self, + *, + device_id: Optional[str] = None, + device_name: Optional[str] = None, + project_id: Optional[str] = None, + key: Optional[str] = None, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): + """ + Lists device custom property history records. + + device_id: The ID of the device to retrieve history records from. + Use this or name. + device_name: The name of the device to retrieve history records from. + Use this or id. + project_id: Project to retrieve history records from. + Required for multi-project organizations. + key: Optional property key to filter by. + start: Optionally include records active at or after this time. + end: Optionally include records active before this time. + limit: Optionally limit the number of history records returned. + offset: Optionally offset the history records by this many records. + """ + identifier = _device_identifier(device_id, device_name) + + params = { + "projectId": project_id, + "key": key, + "start": start.astimezone().isoformat() if start else None, + "end": end.astimezone().isoformat() if end else None, + "limit": limit, + "offset": offset, + } + + response = self.__session.get( + self.__url__( + f"/v1/devices/{urlquote(identifier, safe='')}/property-history" + ), + params={k: v for k, v in params.items() if v is not None}, + ) + return [ + _device_custom_property_history_dict(r) for r in json_or_raise(response) + ] + + def update_device_custom_property_history( + self, + *, + device_id: Optional[str] = None, + device_name: Optional[str] = None, + project_id: Optional[str] = None, + key: str, + value: Optional[Union[str, bool, float, int, List[str]]] = None, + start: datetime.datetime, + end: datetime.datetime, + ): + """ + Updates device custom property history over a time range. + + The request is treated as an assertion of truth for the given range, + so existing records may be split, trimmed, or deleted as needed. + + device_id: The ID of the device to update history for. + Use this or name. + device_name: The name of the device to update history for. + Use this or device_id. + project_id: Project to update history in. + Required for multi-project organizations. + key: The property key to update. + value: The value to apply over the given time range. + When omitted, the value will be treated as explicitly unset for that range. + start: Inclusive start of the property's effective time range. + end: Exclusive end of the property's effective time range. + """ + identifier = _device_identifier(device_id, device_name) + + params: Dict[str, Any] = { + "projectId": project_id, + "key": key, + "value": value, + "start": start.astimezone().isoformat(), + "end": end.astimezone().isoformat(), + } + params["deviceId" if device_id is not None else "deviceName"] = identifier + + response = self.__session.post( + self.__url__("/v1/actions/devices/update-device-property-history"), + json=without_nulls(params), + ) + # This endpoint returns 204 No Content on success (no body) + if response.status_code == 204: + return None + return json_or_raise(response) + def _session_identifier(session_id: Optional[str], session_key: Optional[str]) -> str: if session_id is not None and session_key is not None: @@ -1345,6 +1463,17 @@ def _session_identifier(session_id: Optional[str], session_key: Optional[str]) - return identifier +def _device_identifier(device_id: Optional[str], device_name: Optional[str]) -> str: + if device_id is not None and device_name is not None: + raise RuntimeError("device_id and device_name are mutually exclusive") + if device_id is None and device_name is None: + raise RuntimeError("device_id or device_name must be provided") + + identifier = device_id if device_id is not None else device_name + assert identifier is not None, "one of device_id or device_name must be provided" + return identifier + + def _event_dict(json_event): return { "id": json_event["id"], @@ -1386,4 +1515,16 @@ def _session_dict(session): } +def _device_custom_property_history_dict(property_history): + end = property_history.get("end") + return { + "id": property_history["id"], + "device_id": property_history["deviceId"], + "key": property_history["key"], + "value": property_history["value"], + "start": arrow.get(property_history["start"]).datetime, + "end": arrow.get(end).datetime if end else None, + } + + __all__ = ["Client", "CompressionFormat", "FoxgloveException", "OutputFormat"] diff --git a/tests/test_device_custom_property_history.py b/tests/test_device_custom_property_history.py new file mode 100644 index 0000000..c0aa11b --- /dev/null +++ b/tests/test_device_custom_property_history.py @@ -0,0 +1,136 @@ +import json +from datetime import datetime +from urllib.parse import parse_qs, quote, urlparse + +import pytest +import responses +from faker import Faker + +from foxglove.client import Client + +from .api_url import api_url + +fake = Faker() + + +@responses.activate +def test_get_device_custom_property_history_quotes_path_and_passes_project_id(): + device_name = "Device / Name" + property_history_id = "dcph/id" + now = datetime.now() + responses.add( + responses.GET, + api_url( + f"/v1/devices/{quote(device_name, safe='')}/property-history/" + f"{quote(property_history_id, safe='')}" + ), + json={ + "id": property_history_id, + "deviceId": fake.uuid4(), + "key": "env", + "value": "prod", + "start": now.isoformat(), + "end": now.isoformat(), + }, + ) + + client = Client("test") + response = client.get_device_custom_property_history( + device_name=device_name, + project_id="project-id", + id=property_history_id, + ) + + assert response["id"] == property_history_id + assert parse_qs(urlparse(responses.calls[0].request.url).query) == { + "projectId": ["project-id"] + } + + +def test_get_device_custom_property_history_rejects_multiple_device_selectors(): + client = Client("test") + + with pytest.raises(RuntimeError) as exception: + client.get_device_custom_property_history( + device_id="device-id", + device_name="device-name", + id="history-id", + ) + + assert str(exception.value) == "device_id and device_name are mutually exclusive" + + +@responses.activate +def test_get_device_custom_property_history_records_uses_path_selector_only(): + device_name = "Device / Name" + responses.add( + responses.GET, + api_url(f"/v1/devices/{quote(device_name, safe='')}/property-history"), + json=[], + ) + + client = Client("test") + client.get_device_custom_property_history_records( + device_name=device_name, + project_id="project-id", + key="env", + limit=5, + offset=10, + ) + + assert parse_qs(urlparse(responses.calls[0].request.url).query) == { + "key": ["env"], + "limit": ["5"], + "offset": ["10"], + "projectId": ["project-id"], + } + + +@responses.activate +def test_update_device_custom_property_history_omits_value_for_clear(): + responses.add( + responses.POST, + api_url("/v1/actions/devices/update-device-property-history"), + status=204, + body="", + ) + + client = Client("test") + client.update_device_custom_property_history( + device_name="Device / Name", + project_id="project-id", + key="env", + start=datetime.now(), + end=datetime.now(), + ) + + request_body = json.loads(responses.calls[0].request.body) + assert request_body["deviceName"] == "Device / Name" + assert request_body["projectId"] == "project-id" + assert request_body["key"] == "env" + assert "value" not in request_body + assert "deviceId" not in request_body + + +@responses.activate +def test_update_device_custom_property_history_supports_array_values(): + responses.add( + responses.POST, + api_url("/v1/actions/devices/update-device-property-history"), + status=204, + body="", + ) + + client = Client("test") + client.update_device_custom_property_history( + device_id="device-id", + key="labels", + value=["one", "two"], + start=datetime.now(), + end=datetime.now(), + ) + + request_body = json.loads(responses.calls[0].request.body) + assert request_body["deviceId"] == "device-id" + assert request_body["value"] == ["one", "two"] + assert "deviceName" not in request_body From b57e8afa42c49415e7cdb9b56db11191fccb99cb Mon Sep 17 00:00:00 2001 From: Brook Date: Tue, 24 Mar 2026 15:19:37 -0700 Subject: [PATCH 2/7] responding to pr comments --- foxglove/client/api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/foxglove/client/api.py b/foxglove/client/api.py index eb0ae55..e5214f1 100644 --- a/foxglove/client/api.py +++ b/foxglove/client/api.py @@ -1336,7 +1336,7 @@ def get_device_custom_property_history( Fetches a single device custom property history record. device_id: The ID of the device to retrieve the history record from. - Use this or name. + Use this or device_name. device_name: The name of the device to retrieve the history record from. Use this or device_id. project_id: Project to retrieve the history record from. @@ -1370,9 +1370,9 @@ def get_device_custom_property_history_records( Lists device custom property history records. device_id: The ID of the device to retrieve history records from. - Use this or name. + Use this or device_name. device_name: The name of the device to retrieve history records from. - Use this or id. + Use this or device_id. project_id: Project to retrieve history records from. Required for multi-project organizations. key: Optional property key to filter by. @@ -1420,7 +1420,7 @@ def update_device_custom_property_history( so existing records may be split, trimmed, or deleted as needed. device_id: The ID of the device to update history for. - Use this or name. + Use this or device_name. device_name: The name of the device to update history for. Use this or device_id. project_id: Project to update history in. From 99a4da63b1c3bee7d820378bae35a68699c714a3 Mon Sep 17 00:00:00 2001 From: Brook Date: Wed, 25 Mar 2026 11:21:42 -0700 Subject: [PATCH 3/7] rename endpoints from history to interval --- foxglove/client/api.py | 61 +++++++++---------- ...t_device_custom_property_time_interval.py} | 49 ++++++++------- 2 files changed, 56 insertions(+), 54 deletions(-) rename tests/{test_device_custom_property_history.py => test_device_custom_property_time_interval.py} (66%) diff --git a/foxglove/client/api.py b/foxglove/client/api.py index e5214f1..41dbc94 100644 --- a/foxglove/client/api.py +++ b/foxglove/client/api.py @@ -1324,7 +1324,7 @@ def delete_session( return json_or_raise(response) - def get_device_custom_property_history( + def get_device_custom_property_time_interval( self, *, device_id: Optional[str] = None, @@ -1333,28 +1333,27 @@ def get_device_custom_property_history( id: str, ): """ - Fetches a single device custom property history record. + Fetches a single device custom propety time intervalrecord. - device_id: The ID of the device to retrieve the history record from. + device_id: The ID of the device to retrieve the time interval record from. Use this or device_name. - device_name: The name of the device to retrieve the history record from. + device_name: The name of the device to retrieve the time interval record from. Use this or device_id. - project_id: Project to retrieve the history record from. - Required for multi-project organizations. - id: The ID of the history record to fetch. + project_id: Project associated with the device. Required for multi-project organizations. + id: The ID of the time interval record to fetch. """ identifier = _device_identifier(device_id, device_name) response = self.__session.get( self.__url__( - f"/v1/devices/{urlquote(identifier, safe='')}/property-history/" + f"/v1/devices/{urlquote(identifier, safe='')}/property-time-intervals/" f"{urlquote(id, safe='')}" ), params=without_nulls({"projectId": project_id}), ) - return _device_custom_property_history_dict(json_or_raise(response)) + return _device_custom_property_time_interval_dict(json_or_raise(response)) - def get_device_custom_property_history_records( + def get_device_custom_property_time_intervals( self, *, device_id: Optional[str] = None, @@ -1367,19 +1366,18 @@ def get_device_custom_property_history_records( offset: Optional[int] = None, ): """ - Lists device custom property history records. + Lists device custom property time intervals. - device_id: The ID of the device to retrieve history records from. + device_id: The ID of the device to retrieve time intervals from. Use this or device_name. - device_name: The name of the device to retrieve history records from. + device_name: The name of the device to retrieve time intervals from. Use this or device_id. - project_id: Project to retrieve history records from. - Required for multi-project organizations. + project_id: Project associated with the device. Required for multi-project organizations. key: Optional property key to filter by. - start: Optionally include records active at or after this time. - end: Optionally include records active before this time. - limit: Optionally limit the number of history records returned. - offset: Optionally offset the history records by this many records. + start: Optionally include intervals active at or after this time. + end: Optionally include intervals active before this time. + limit: Optionally limit the number of time intervals returned. + offset: Optionally offset the time intervals by this many intervals. """ identifier = _device_identifier(device_id, device_name) @@ -1394,15 +1392,16 @@ def get_device_custom_property_history_records( response = self.__session.get( self.__url__( - f"/v1/devices/{urlquote(identifier, safe='')}/property-history" + f"/v1/devices/{urlquote(identifier, safe='')}/property-time-intervals" ), params={k: v for k, v in params.items() if v is not None}, ) return [ - _device_custom_property_history_dict(r) for r in json_or_raise(response) + _device_custom_property_time_interval_dict(r) + for r in json_or_raise(response) ] - def update_device_custom_property_history( + def update_device_custom_property_time_intervals( self, *, device_id: Optional[str] = None, @@ -1414,17 +1413,16 @@ def update_device_custom_property_history( end: datetime.datetime, ): """ - Updates device custom property history over a time range. + Updates device custom property time intervals over a time range. The request is treated as an assertion of truth for the given range, - so existing records may be split, trimmed, or deleted as needed. + so existing intervals may be split, trimmed, or deleted as needed. - device_id: The ID of the device to update history for. + device_id: The ID of the device to update time intervals for. Use this or device_name. - device_name: The name of the device to update history for. + device_name: The name of the device to update time intervals for. Use this or device_id. - project_id: Project to update history in. - Required for multi-project organizations. + project_id: Project associated with the device. Required for multi-project organizations. key: The property key to update. value: The value to apply over the given time range. When omitted, the value will be treated as explicitly unset for that range. @@ -1440,10 +1438,11 @@ def update_device_custom_property_history( "start": start.astimezone().isoformat(), "end": end.astimezone().isoformat(), } - params["deviceId" if device_id is not None else "deviceName"] = identifier response = self.__session.post( - self.__url__("/v1/actions/devices/update-device-property-history"), + self.__url__( + f"/v1/actions/devices/{urlquote(identifier, safe='')}/update-property-time-interval" + ), json=without_nulls(params), ) # This endpoint returns 204 No Content on success (no body) @@ -1515,7 +1514,7 @@ def _session_dict(session): } -def _device_custom_property_history_dict(property_history): +def _device_custom_property_time_interval_dict(property_history): end = property_history.get("end") return { "id": property_history["id"], diff --git a/tests/test_device_custom_property_history.py b/tests/test_device_custom_property_time_interval.py similarity index 66% rename from tests/test_device_custom_property_history.py rename to tests/test_device_custom_property_time_interval.py index c0aa11b..79bbd98 100644 --- a/tests/test_device_custom_property_history.py +++ b/tests/test_device_custom_property_time_interval.py @@ -14,18 +14,18 @@ @responses.activate -def test_get_device_custom_property_history_quotes_path_and_passes_project_id(): +def test_get_device_custom_property_time_interval_quotes_path_and_passes_project_id(): device_name = "Device / Name" - property_history_id = "dcph/id" + time_interval_id = "dcph/id" now = datetime.now() responses.add( responses.GET, api_url( - f"/v1/devices/{quote(device_name, safe='')}/property-history/" - f"{quote(property_history_id, safe='')}" + f"/v1/devices/{quote(device_name, safe='')}/property-time-intervals/" + f"{quote(time_interval_id, safe='')}" ), json={ - "id": property_history_id, + "id": time_interval_id, "deviceId": fake.uuid4(), "key": "env", "value": "prod", @@ -35,42 +35,42 @@ def test_get_device_custom_property_history_quotes_path_and_passes_project_id(): ) client = Client("test") - response = client.get_device_custom_property_history( + response = client.get_device_custom_property_time_interval( device_name=device_name, project_id="project-id", - id=property_history_id, + id=time_interval_id, ) - assert response["id"] == property_history_id + assert response["id"] == time_interval_id assert parse_qs(urlparse(responses.calls[0].request.url).query) == { "projectId": ["project-id"] } -def test_get_device_custom_property_history_rejects_multiple_device_selectors(): +def test_get_device_custom_property_time_interval_rejects_multiple_device_selectors(): client = Client("test") with pytest.raises(RuntimeError) as exception: - client.get_device_custom_property_history( + client.get_device_custom_property_time_interval( device_id="device-id", device_name="device-name", - id="history-id", + id="time-interval-id", ) assert str(exception.value) == "device_id and device_name are mutually exclusive" @responses.activate -def test_get_device_custom_property_history_records_uses_path_selector_only(): +def test_get_device_custom_property_time_intervals_uses_path_selector_only(): device_name = "Device / Name" responses.add( responses.GET, - api_url(f"/v1/devices/{quote(device_name, safe='')}/property-history"), + api_url(f"/v1/devices/{quote(device_name, safe='')}/property-time-intervals"), json=[], ) client = Client("test") - client.get_device_custom_property_history_records( + client.get_device_custom_property_time_intervals( device_name=device_name, project_id="project-id", key="env", @@ -87,17 +87,20 @@ def test_get_device_custom_property_history_records_uses_path_selector_only(): @responses.activate -def test_update_device_custom_property_history_omits_value_for_clear(): +def test_update_device_custom_property_time_intervals_omits_value_for_clear(): + device_name = "Device / Name" responses.add( responses.POST, - api_url("/v1/actions/devices/update-device-property-history"), + api_url( + f"/v1/actions/devices/{quote(device_name, safe='')}/update-property-time-interval" + ), status=204, body="", ) client = Client("test") - client.update_device_custom_property_history( - device_name="Device / Name", + client.update_device_custom_property_time_intervals( + device_name=device_name, project_id="project-id", key="env", start=datetime.now(), @@ -105,24 +108,24 @@ def test_update_device_custom_property_history_omits_value_for_clear(): ) request_body = json.loads(responses.calls[0].request.body) - assert request_body["deviceName"] == "Device / Name" assert request_body["projectId"] == "project-id" assert request_body["key"] == "env" assert "value" not in request_body + assert "deviceName" not in request_body assert "deviceId" not in request_body @responses.activate -def test_update_device_custom_property_history_supports_array_values(): +def test_update_device_custom_property_time_intervals_supports_array_values(): responses.add( responses.POST, - api_url("/v1/actions/devices/update-device-property-history"), + api_url("/v1/actions/devices/device-id/update-property-time-interval"), status=204, body="", ) client = Client("test") - client.update_device_custom_property_history( + client.update_device_custom_property_time_intervals( device_id="device-id", key="labels", value=["one", "two"], @@ -131,6 +134,6 @@ def test_update_device_custom_property_history_supports_array_values(): ) request_body = json.loads(responses.calls[0].request.body) - assert request_body["deviceId"] == "device-id" assert request_body["value"] == ["one", "two"] assert "deviceName" not in request_body + assert "deviceId" not in request_body From e1916d6006f76a4989ede0cb89cdddfce814a2aa Mon Sep 17 00:00:00 2001 From: Brook Date: Wed, 25 Mar 2026 13:28:19 -0700 Subject: [PATCH 4/7] pr feedback fixes --- foxglove/client/api.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/foxglove/client/api.py b/foxglove/client/api.py index 41dbc94..7b5aa1d 100644 --- a/foxglove/client/api.py +++ b/foxglove/client/api.py @@ -1333,7 +1333,7 @@ def get_device_custom_property_time_interval( id: str, ): """ - Fetches a single device custom propety time intervalrecord. + Fetches a single device custom property time interval record. device_id: The ID of the device to retrieve the time interval record from. Use this or device_name. @@ -1514,14 +1514,14 @@ def _session_dict(session): } -def _device_custom_property_time_interval_dict(property_history): - end = property_history.get("end") +def _device_custom_property_time_interval_dict(interval): + end = interval.get("end") return { - "id": property_history["id"], - "device_id": property_history["deviceId"], - "key": property_history["key"], - "value": property_history["value"], - "start": arrow.get(property_history["start"]).datetime, + "id": interval["id"], + "device_id": interval["deviceId"], + "key": interval["key"], + "value": interval["value"], + "start": arrow.get(interval["start"]).datetime, "end": arrow.get(end).datetime if end else None, } From 8d106b5486470580e459a0060fc45ce9ec776e27 Mon Sep 17 00:00:00 2001 From: Brook Date: Wed, 25 Mar 2026 14:54:14 -0700 Subject: [PATCH 5/7] support multikey --- foxglove/client/api.py | 17 ++++++++++++--- ...st_device_custom_property_time_interval.py | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/foxglove/client/api.py b/foxglove/client/api.py index 7b5aa1d..861f8f6 100644 --- a/foxglove/client/api.py +++ b/foxglove/client/api.py @@ -74,6 +74,17 @@ def bool_query_param(val: bool) -> Optional[str]: return str(val).lower() if val is not None else None +def comma_separated_query_param(val: Optional[Union[str, List[str]]]) -> Optional[str]: + """ + Serialize a string or list of strings to a comma-separated query parameter. + """ + if val is None: + return None + if isinstance(val, list): + return ",".join(val) if val else None + return val + + def without_nulls(params: Dict[str, Union[T, None]]) -> Dict[str, T]: """ Filter out `None` values from params @@ -1359,7 +1370,7 @@ def get_device_custom_property_time_intervals( device_id: Optional[str] = None, device_name: Optional[str] = None, project_id: Optional[str] = None, - key: Optional[str] = None, + key: Optional[Union[str, List[str]]] = None, start: Optional[datetime.datetime] = None, end: Optional[datetime.datetime] = None, limit: Optional[int] = None, @@ -1373,7 +1384,7 @@ def get_device_custom_property_time_intervals( device_name: The name of the device to retrieve time intervals from. Use this or device_id. project_id: Project associated with the device. Required for multi-project organizations. - key: Optional property key to filter by. + key: Optional property key or keys to filter by. start: Optionally include intervals active at or after this time. end: Optionally include intervals active before this time. limit: Optionally limit the number of time intervals returned. @@ -1383,7 +1394,7 @@ def get_device_custom_property_time_intervals( params = { "projectId": project_id, - "key": key, + "key": comma_separated_query_param(key), "start": start.astimezone().isoformat() if start else None, "end": end.astimezone().isoformat() if end else None, "limit": limit, diff --git a/tests/test_device_custom_property_time_interval.py b/tests/test_device_custom_property_time_interval.py index 79bbd98..1c1764c 100644 --- a/tests/test_device_custom_property_time_interval.py +++ b/tests/test_device_custom_property_time_interval.py @@ -86,6 +86,27 @@ def test_get_device_custom_property_time_intervals_uses_path_selector_only(): } +@responses.activate +def test_get_device_custom_property_time_intervals_supports_multiple_keys(): + responses.add( + responses.GET, + api_url("/v1/devices/device-id/property-time-intervals"), + json=[], + ) + + client = Client("test") + client.get_device_custom_property_time_intervals( + device_id="device-id", + project_id="project-id", + key=["env", "region"], + ) + + assert parse_qs(urlparse(responses.calls[0].request.url).query) == { + "key": ["env,region"], + "projectId": ["project-id"], + } + + @responses.activate def test_update_device_custom_property_time_intervals_omits_value_for_clear(): device_name = "Device / Name" From 15bccd239979ed5609ce3da7e20ec4cf8faed7c0 Mon Sep 17 00:00:00 2001 From: Brook Date: Wed, 25 Mar 2026 15:05:44 -0700 Subject: [PATCH 6/7] rename method for clarity --- foxglove/client/api.py | 2 +- tests/test_device_custom_property_time_interval.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/foxglove/client/api.py b/foxglove/client/api.py index 861f8f6..168f43a 100644 --- a/foxglove/client/api.py +++ b/foxglove/client/api.py @@ -1412,7 +1412,7 @@ def get_device_custom_property_time_intervals( for r in json_or_raise(response) ] - def update_device_custom_property_time_intervals( + def update_device_custom_property_time_interval( self, *, device_id: Optional[str] = None, diff --git a/tests/test_device_custom_property_time_interval.py b/tests/test_device_custom_property_time_interval.py index 1c1764c..5ed8d90 100644 --- a/tests/test_device_custom_property_time_interval.py +++ b/tests/test_device_custom_property_time_interval.py @@ -120,7 +120,7 @@ def test_update_device_custom_property_time_intervals_omits_value_for_clear(): ) client = Client("test") - client.update_device_custom_property_time_intervals( + client.update_device_custom_property_time_interval( device_name=device_name, project_id="project-id", key="env", @@ -146,7 +146,7 @@ def test_update_device_custom_property_time_intervals_supports_array_values(): ) client = Client("test") - client.update_device_custom_property_time_intervals( + client.update_device_custom_property_time_interval( device_id="device-id", key="labels", value=["one", "two"], From 109741477e16dd7bb095ff7462de57ad6062b562 Mon Sep 17 00:00:00 2001 From: Brook Date: Wed, 25 Mar 2026 15:43:44 -0700 Subject: [PATCH 7/7] align update interval test names Keep the update property time interval tests consistent with the singular client method name so the renamed API surface is less confusing in review. Made-with: Cursor --- tests/test_device_custom_property_time_interval.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_device_custom_property_time_interval.py b/tests/test_device_custom_property_time_interval.py index 5ed8d90..9d6ec09 100644 --- a/tests/test_device_custom_property_time_interval.py +++ b/tests/test_device_custom_property_time_interval.py @@ -108,7 +108,7 @@ def test_get_device_custom_property_time_intervals_supports_multiple_keys(): @responses.activate -def test_update_device_custom_property_time_intervals_omits_value_for_clear(): +def test_update_device_custom_property_time_interval_omits_value_for_clear(): device_name = "Device / Name" responses.add( responses.POST, @@ -137,7 +137,7 @@ def test_update_device_custom_property_time_intervals_omits_value_for_clear(): @responses.activate -def test_update_device_custom_property_time_intervals_supports_array_values(): +def test_update_device_custom_property_time_interval_supports_array_values(): responses.add( responses.POST, api_url("/v1/actions/devices/device-id/update-property-time-interval"),