diff --git a/foxglove_data_platform/client.py b/foxglove_data_platform/client.py index 248626a..c020a6d 100644 --- a/foxglove_data_platform/client.py +++ b/foxglove_data_platform/client.py @@ -3,8 +3,7 @@ from enum import Enum from io import BytesIO import json -import warnings -from typing import IO, Any, Dict, List, Optional, Union +from typing import IO, Any, Dict, List, Optional, TypeVar, Union import base64 import arrow @@ -31,6 +30,9 @@ def decoder(message_content: bytes): DEFAULT_DECODER_FACTORIES: List[DecoderFactory] = [_JsonDecoderFactory()] +T = TypeVar("T") + + try: from mcap_ros1.decoder import DecoderFactory as Ros1DecoderFactory @@ -70,6 +72,13 @@ def bool_query_param(val: bool) -> Optional[str]: return str(val).lower() if val is not None else None +def without_nulls(params: Dict[str, Union[T, None]]) -> Dict[str, T]: + """ + Filter out `None` values from params + """ + return {key: val for key, val in params.items() if val is not None} + + class FoxgloveException(Exception): pass @@ -446,6 +455,7 @@ def get_device( return { "id": device["id"], "name": device["name"], + "properties": device["properties"] if "properties" in device else None, } def get_devices(self): @@ -463,6 +473,7 @@ def get_devices(self): { "id": d["id"], "name": d["name"], + "properties": d["properties"] if "properties" in d else None, } for d in json ] @@ -471,20 +482,57 @@ def create_device( self, *, name: str, - serial_number: Optional[str] = None, + properties: Optional[Dict[str, Union[str, bool, float, int]]] = None, ): """ Creates a new device. - name: The name of the devicee. - serial_number: DEPRECATED: a serial number for the device. This argument has no effect. + :param name: The name of the device. + :param properties: Optional custom properties for the device. + Each key must be defined as a custom property for your organization, + and each value must be of the appropriate type """ - if serial_number is not None: - warnings.warn( - "serial number argument is deprecated and will be removed in the next release" - ) response = requests.post( - self.__url__("/v1/devices"), headers=self.__headers, json={"name": name} + self.__url__("/v1/devices"), + headers=self.__headers, + json=without_nulls({"name": name, "properties": properties}), + ) + + device = json_or_raise(response) + + return { + "id": device["id"], + "name": device["name"], + "properties": device["properties"] if "properties" in device else None, + } + + def update_device( + self, + *, + device_id: Optional[str] = None, + device_name: Optional[str] = None, + new_name: Optional[str] = None, + properties: Optional[Dict[str, Union[str, bool, float, int]]] = None, + ): + """ + Updates a device. + + :param device_id: The id of the device to retrieve. + :param device_name: The name of the device to retrieve. + :param new_name: Optional new name to assign to the device. + :param properties: Optional custom properties to add to or edit on the device. + Each key must be defined as a custom property for your organization + and each value must be of the appropriate type. + """ + 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") + + response = requests.patch( + self.__url__(f"/v1/devices/{device_name or device_id}"), + headers=self.__headers, + json=without_nulls({"name": new_name, "properties": properties}), ) device = json_or_raise(response) @@ -492,6 +540,7 @@ def create_device( return { "id": device["id"], "name": device["name"], + "properties": device["properties"] if "properties" in device else None, } def delete_device( diff --git a/setup.cfg b/setup.cfg index c7ccb70..f43213e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = foxglove-data-platform -version = 0.9.0 +version = 0.10.0 description = Client library for Foxglove Data Platform. long_description = file: README.md long_description_content_type = text/markdown diff --git a/tests/test_devices.py b/tests/test_devices.py index fedfeee..1e3cebd 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -3,6 +3,7 @@ import responses from faker import Faker from foxglove_data_platform.client import Client +from responses.matchers import json_params_matcher from .api_url import api_url @@ -16,6 +17,7 @@ def test_create_device(): responses.add( responses.POST, api_url("/v1/devices"), + match=[json_params_matcher({"name": name}, strict_match=True)], json={ "id": id, "name": name, @@ -26,6 +28,31 @@ def test_create_device(): assert device["name"] == name +@responses.activate +def test_create_device_with_properties(): + id = fake.uuid4() + name = "name" + properties = {"sn": 1} + responses.add( + responses.POST, + api_url("/v1/devices"), + match=[ + json_params_matcher( + {"name": name, "properties": properties}, strict_match=True + ) + ], + json={ + "id": id, + "name": name, + "properties": properties, + }, + ) + client = Client("test") + device = client.create_device(name=name, properties=properties) + assert device["name"] == name + assert device["properties"] == properties + + @responses.activate def test_get_device(): id = fake.uuid4() @@ -36,8 +63,6 @@ def test_get_device(): json={ "id": id, "name": fake.sentence(2), - "createdAt": datetime.now().isoformat(), - "updatedAt": datetime.now().isoformat(), }, ) client = Client("test") @@ -50,12 +75,11 @@ def test_get_device(): json={ "id": id, "name": fake.sentence(2), - "createdAt": datetime.now().isoformat(), - "updatedAt": datetime.now().isoformat(), }, ) device = client.get_device(device_name=name) assert device["id"] == id + assert device["properties"] is None @responses.activate @@ -68,8 +92,6 @@ def test_get_devices(): { "id": id, "name": fake.sentence(2), - "createdAt": datetime.now().isoformat(), - "updatedAt": datetime.now().isoformat(), } ], ) @@ -77,6 +99,7 @@ def test_get_devices(): devices = client.get_devices() assert len(devices) == 1 assert devices[0]["id"] == id + assert devices[0]["properties"] is None @responses.activate @@ -103,3 +126,47 @@ def test_delete_device(): client.delete_device(device_name=name) except: assert False + + +@responses.activate +def test_update_device(): + old_name = "old_name" + new_name = "new_name" + properties = {"sn": 1} + # Patching name alone + responses.add( + responses.PATCH, + api_url(f"/v1/devices/{old_name}"), + match=[json_params_matcher({"name": new_name}, strict_match=True)], + json={ + "id": "no-new-properties", + "name": new_name, + "properties": properties, + }, + ) + # Patching name and properties + responses.add( + responses.PATCH, + api_url(f"/v1/devices/{old_name}"), + match=[ + json_params_matcher( + {"name": new_name, "properties": properties}, strict_match=True + ) + ], + json={ + "id": "with-new-properties", + "name": new_name, + "properties": properties, + }, + ) + client = Client("test") + device = client.update_device( + device_name=old_name, new_name=new_name, properties=properties + ) + assert device["id"] == "with-new-properties" + assert device["name"] == new_name + assert device["properties"] == properties + + device = client.update_device(device_name=old_name, new_name=new_name) + assert device["id"] == "no-new-properties" + assert device["name"] == new_name