diff --git a/foxglove/client/api.py b/foxglove/client/api.py index 4f0750d..cee4f7b 100644 --- a/foxglove/client/api.py +++ b/foxglove/client/api.py @@ -584,7 +584,9 @@ def iter_messages( session_id: ID of a session to download data from session_key: Key of a session to download data from start: The earliest time from which to retrieve data. + Required with device_id or device_name. end: The latest time from which to retrieve data. + Required with device_id or device_name. topics: An optional list of topics to retrieve. All topics will be retrieved if this is omitted. decoder_factories: an optional list of :py:class:`~mcap.decoder.DecoderFactory` instances @@ -731,11 +733,9 @@ def _make_stream_link( raise RuntimeError( "recording_id or recording_key cannot be combined with a session" ) - needs_range = episode_id is None and (by_device or not by_recording) - if needs_range and (start is None or end is None): + if by_device and (start is None or end is None): raise RuntimeError( - "start and end must be provided unless using episode_id, " - "recording_id, or recording_key" + "start and end must be provided when using device_id or device_name" ) params = { @@ -797,7 +797,9 @@ def download_data( session_id: ID of a session to download data from session_key: Key of a session to download data from start: The earliest time from which to retrieve data. + Required with device_id or device_name. end: The latest time from which to retrieve data. + Required with device_id or device_name. topics: An optional list of topics to retrieve. All topics will be retrieved if this is omitted. output_format: The output format of the data, either .bag or .mcap, defaulting to .mcap. @@ -1802,8 +1804,11 @@ def get_topics( :param device_id: Optionally filter topics by this device ID. :param device_name: Optionally filter topics by this device name. :param start: Filter topics by this start time. + Required with ``device_id`` or ``device_name``. :param end: Filter topics by this end time. + Required with ``device_id`` or ``device_name``. :param include_schemas: Optionally include the schema in the response. + A topic's ``schema`` is ``None`` when the API returns none for it. :param project_id: Optional Project to filter topics by. :param session_id: ID of a session to list topics from :param session_key: Key of a session to list topics from @@ -1832,10 +1837,11 @@ def get_topics( or by_recording ): raise RuntimeError("episode_id cannot be combined with another identifier") - if episode_id is None and not by_recording and (start is None or end is None): + if (device_id is not None or device_name is not None) and ( + start is None or end is None + ): raise RuntimeError( - "start and end must be provided unless using episode_id, " - "recording_id, or recording_key" + "start and end must be provided when using device_id or device_name" ) response = self.__session.get( self.__url__("/v1/data/topics"), @@ -1871,7 +1877,10 @@ def get_topics( "schema_name": t["schemaName"], } if include_schemas: - result["schema"] = base64.b64decode(t["schema"]) + schema = t.get("schema") + result["schema"] = ( + base64.b64decode(schema) if schema is not None else None + ) results.append(result) return results diff --git a/tests/test_client.py b/tests/test_client.py index 3286f78..4eba99c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,4 @@ +import base64 from datetime import datetime import pytest @@ -99,6 +100,47 @@ def test_get_topics_by_recording_without_time_range(): ) +@responses.activate +def test_get_topics_by_session_without_time_range_or_schema(): + responses.add( + responses.GET, + api_url("/v1/data/topics"), + match=[query_param_matcher({"sessionId": "ses_1", "includeSchemas": "true"})], + json=[ + { + "topic": "/a", + "version": "1", + "encoding": "json", + "schemaEncoding": "jsonschema", + "schemaName": "A", + "schema": base64.b64encode(b"{}").decode(), + }, + { + "topic": "/b", + "version": "1", + "encoding": "json", + "schemaEncoding": "jsonschema", + "schemaName": "B", + }, + ], + ) + topics = Client("test").get_topics(session_id="ses_1", include_schemas=True) + assert topics[0]["schema"] == b"{}" + assert topics[1]["schema"] is None + + +@responses.activate +def test_get_topics_for_a_project_without_time_range(): + # The API needs a time range only when filtering by device. + responses.add( + responses.GET, + api_url("/v1/data/topics"), + match=[query_param_matcher({"projectId": "prj_1", "includeSchemas": "false"})], + json=[], + ) + assert Client("test").get_topics(project_id="prj_1") == [] + + def test_camelize(): assert camelize("a_field_name") == "aFieldName" assert camelize("aFieldName") == "aFieldName" diff --git a/tests/test_data.py b/tests/test_data.py index e476ae2..cfba10d 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -262,6 +262,31 @@ def test_download_data_by_recording_without_time_range(): assert Client("test").download_data(recording_id="rec_1") == b"mcap" +@responses.activate +def test_download_data_by_session_without_time_range(): + download_link = fake.url() + responses.add( + responses.POST, + api_url("/v1/data/stream"), + match=[ + json_params_matcher( + { + "sessionKey": "run-1", + "projectId": "prj_1", + "outputFormat": "mcap", + "topics": [], + }, + strict_match=True, + ) + ], + json={"link": download_link}, + ) + responses.add(responses.GET, download_link, body=b"mcap") + + data = Client("test").download_data(session_key="run-1", project_id="prj_1") + assert data == b"mcap" + + @responses.activate def test_streaming_upload(): upload_link = fake.url() diff --git a/tests/test_episodes.py b/tests/test_episodes.py index d151a36..db4ba5a 100644 --- a/tests/test_episodes.py +++ b/tests/test_episodes.py @@ -575,20 +575,18 @@ def test_download_without_episode_requires_time_range(): client.download_data(device_id="dev_1") assert str(raised.value) == ( - "start and end must be provided unless using episode_id, " - "recording_id, or recording_key" + "start and end must be provided when using device_id or device_name" ) -def test_get_topics_without_episode_requires_time_range(): +def test_get_topics_by_device_requires_time_range(): client = Client("test") with pytest.raises(RuntimeError) as raised: - client.get_topics(session_id="ses_1") + client.get_topics(device_name="robot", start=datetime(2025, 1, 1)) assert str(raised.value) == ( - "start and end must be provided unless using episode_id, " - "recording_id, or recording_key" + "start and end must be provided when using device_id or device_name" )