clarity-api-sdk-python 0.3.38__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/PKG-INFO +1 -1
  2. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/pyproject.toml +1 -1
  3. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/clarity_api_sdk_python.egg-info/PKG-INFO +1 -1
  4. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/clarity_api_sdk_python.egg-info/SOURCES.txt +2 -0
  5. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/api/sonar_wiz_api.py +132 -8
  6. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/api/sonar_wiz_async_api.py +138 -8
  7. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/cli/main.py +31 -6
  8. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/raw_file_device_mapping.py +43 -2
  9. clarity_api_sdk_python-0.4.1/src/cti/model/user_layer.py +125 -0
  10. clarity_api_sdk_python-0.4.1/tests/test_raw_file_device_mapping_model.py +110 -0
  11. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/tests/test_sdk_async_methods.py +150 -0
  12. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/tests/test_sdk_methods.py +123 -0
  13. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/README.md +0 -0
  14. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/setup.cfg +0 -0
  15. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/clarity_api_sdk_python.egg-info/dependency_links.txt +0 -0
  16. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/clarity_api_sdk_python.egg-info/entry_points.txt +0 -0
  17. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/clarity_api_sdk_python.egg-info/requires.txt +0 -0
  18. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/clarity_api_sdk_python.egg-info/top_level.txt +0 -0
  19. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/__init__.py +0 -0
  20. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/api/__init__.py +0 -0
  21. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/api/async_client.py +0 -0
  22. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/api/client.py +0 -0
  23. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/api/session.py +0 -0
  24. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/cli/__init__.py +0 -0
  25. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/cli/__main__.py +0 -0
  26. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/cli/client.py +0 -0
  27. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/logger/__init__.py +0 -0
  28. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/logger/logger.py +0 -0
  29. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/main.py +0 -0
  30. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/main_api.py +0 -0
  31. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/__init__.py +0 -0
  32. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/altitude_source.py +0 -0
  33. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/attitude_source.py +0 -0
  34. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/deferred_object_deletion.py +0 -0
  35. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/depth_source.py +0 -0
  36. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/device.py +0 -0
  37. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/device_type.py +0 -0
  38. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/final_product.py +0 -0
  39. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/hierarchy.py +0 -0
  40. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/layback_algorithm.py +0 -0
  41. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/layback_source.py +0 -0
  42. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/layback_type.py +0 -0
  43. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/organization.py +0 -0
  44. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/platform.py +0 -0
  45. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/platform_type.py +0 -0
  46. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/position_source.py +0 -0
  47. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/processing_job.py +0 -0
  48. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/processing_log.py +0 -0
  49. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/project.py +0 -0
  50. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/projection_option.py +0 -0
  51. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/raw_file.py +0 -0
  52. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/raw_file_configuration.py +0 -0
  53. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/raw_file_state.py +0 -0
  54. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/s3.py +0 -0
  55. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/sidescan_ping_source.py +0 -0
  56. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/source.py +0 -0
  57. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/survey.py +0 -0
  58. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/target.py +0 -0
  59. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/src/cti/model/tow_system.py +0 -0
  60. {clarity_api_sdk_python-0.3.38 → clarity_api_sdk_python-0.4.1}/tests/test_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clarity-api-sdk-python
3
- Version: 0.3.38
3
+ Version: 0.4.1
4
4
  Summary: A Python SDK to connect to the CTI Clarity API server.
5
5
  Author-email: "Chesapeake Technology Inc." <support@chesapeaketech.com>
6
6
  Project-URL: Homepage, https://github.com/chesapeake-tech/clarity-api-sdk-python
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "clarity-api-sdk-python"
8
- version = "0.3.38"
8
+ version = "0.4.1"
9
9
  authors = [
10
10
  { name="Chesapeake Technology Inc.", email="support@chesapeaketech.com" },
11
11
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clarity-api-sdk-python
3
- Version: 0.3.38
3
+ Version: 0.4.1
4
4
  Summary: A Python SDK to connect to the CTI Clarity API server.
5
5
  Author-email: "Chesapeake Technology Inc." <support@chesapeaketech.com>
6
6
  Project-URL: Homepage, https://github.com/chesapeake-tech/clarity-api-sdk-python
@@ -51,6 +51,8 @@ src/cti/model/source.py
51
51
  src/cti/model/survey.py
52
52
  src/cti/model/target.py
53
53
  src/cti/model/tow_system.py
54
+ src/cti/model/user_layer.py
54
55
  tests/test_cli.py
56
+ tests/test_raw_file_device_mapping_model.py
55
57
  tests/test_sdk_async_methods.py
56
58
  tests/test_sdk_methods.py
@@ -99,6 +99,10 @@ from cti.model.target import TargetUpdate
99
99
  from cti.model.tow_system import TowSystem
100
100
  from cti.model.tow_system import TowSystemCreate
101
101
  from cti.model.tow_system import TowSystemUpdate
102
+ from cti.model.user_layer import UserLayer
103
+ from cti.model.user_layer import UserLayerCreate
104
+ from cti.model.user_layer import UserLayerUpdate
105
+ from cti.model.user_layer import UserLayerWithUpload
102
106
 
103
107
  from .client import ClarityApiClient
104
108
 
@@ -353,13 +357,37 @@ class SonarWizApi:
353
357
  response.raise_for_status()
354
358
  return RawFileDeviceMapping.model_validate(response.json())
355
359
 
356
- def list_raw_file_device_mappings(self) -> list[RawFileDeviceMapping]:
357
- """List all raw file device mappings.
360
+ def get_raw_file_device_mappings(
361
+ self,
362
+ *,
363
+ raw_file_id: UUID | str | None = None,
364
+ device_id: UUID | str | None = None,
365
+ ) -> list[RawFileDeviceMapping]:
366
+ """Get raw file device mappings filtered by raw file or device.
367
+
368
+ At least one of ``raw_file_id`` / ``device_id`` must be provided —
369
+ the server requires a filter and returns 400 otherwise.
370
+
371
+ Args:
372
+ raw_file_id: Match only mappings for this raw file.
373
+ device_id: Match only mappings for this device.
358
374
 
359
375
  Returns:
360
- List of raw file device mapping instances.
376
+ List of matching raw file device mappings (empty if none match).
377
+
378
+ Raises:
379
+ ValueError: If neither ``raw_file_id`` nor ``device_id`` is given.
361
380
  """
362
- response = self._client.get("/api/v1/raw-file-device-mappings")
381
+ if raw_file_id is None and device_id is None:
382
+ raise ValueError(
383
+ "At least one of raw_file_id or device_id must be provided."
384
+ )
385
+ params: dict[str, str] = {}
386
+ if raw_file_id is not None:
387
+ params["raw_file_id"] = str(raw_file_id)
388
+ if device_id is not None:
389
+ params["device_id"] = str(device_id)
390
+ response = self._client.get("/api/v1/raw-file-device-mappings", params=params)
363
391
  response.raise_for_status()
364
392
  return [RawFileDeviceMapping.model_validate(item) for item in response.json()]
365
393
 
@@ -1975,13 +2003,34 @@ class SonarWizApi:
1975
2003
  response.raise_for_status()
1976
2004
  return ProcessingLog.model_validate(response.json())
1977
2005
 
1978
- def list_processing_logs(self) -> list[ProcessingLog]:
1979
- """List all processing logs.
2006
+ def list_processing_logs(
2007
+ self,
2008
+ *,
2009
+ raw_file_id: UUID | str | None = None,
2010
+ survey_id: UUID | str | None = None,
2011
+ processing_step: str | None = None,
2012
+ ) -> list[ProcessingLog]:
2013
+ """List processing logs, optionally filtered.
2014
+
2015
+ Filters compose: each provided filter narrows the result set.
2016
+
2017
+ Args:
2018
+ raw_file_id: Match only logs for this raw file.
2019
+ survey_id: Match only logs for this survey.
2020
+ processing_step: Match only logs with this processing step
2021
+ (e.g. ``"scan"``, ``"ingest"``, ``"products"``).
1980
2022
 
1981
2023
  Returns:
1982
- List of processing log instances.
2024
+ List of matching processing logs (empty list if none match).
1983
2025
  """
1984
- response = self._client.get("/api/v1/processing-logs")
2026
+ params: dict[str, str] = {}
2027
+ if raw_file_id is not None:
2028
+ params["raw_file_id"] = str(raw_file_id)
2029
+ if survey_id is not None:
2030
+ params["survey_id"] = str(survey_id)
2031
+ if processing_step is not None:
2032
+ params["processing_step"] = processing_step
2033
+ response = self._client.get("/api/v1/processing-logs", params=params or None)
1985
2034
  response.raise_for_status()
1986
2035
  return [ProcessingLog.model_validate(item) for item in response.json()]
1987
2036
 
@@ -2004,6 +2053,81 @@ class SonarWizApi:
2004
2053
  response.raise_for_status()
2005
2054
  return ProcessingLog.model_validate(response.json())
2006
2055
 
2056
+ # ── User Layers ──────────────────────────────────────────────────────
2057
+
2058
+ def create_user_layer(self, user_layer: UserLayerCreate) -> UserLayerWithUpload:
2059
+ """Create a new user layer and initiate upload.
2060
+
2061
+ Args:
2062
+ user_layer: User layer creation data.
2063
+
2064
+ Returns:
2065
+ Created user layer instance with upload information.
2066
+ """
2067
+ response = self._client.post(
2068
+ "/api/v1/user-layers/upload",
2069
+ json=user_layer.model_dump(mode="json"),
2070
+ )
2071
+ response.raise_for_status()
2072
+ return UserLayerWithUpload.model_validate(response.json())
2073
+
2074
+ def get_user_layer(self, user_layer_id: UUID) -> UserLayer:
2075
+ """Fetch a user layer by ID.
2076
+
2077
+ Args:
2078
+ user_layer_id: User layer UUID to fetch.
2079
+
2080
+ Returns:
2081
+ UserLayer instance.
2082
+ """
2083
+ response = self._client.get(f"/api/v1/user-layers/{user_layer_id}")
2084
+ response.raise_for_status()
2085
+ return UserLayer.model_validate(response.json())
2086
+
2087
+ def list_user_layers(self, project_id: UUID) -> list[UserLayer]:
2088
+ """List all user layers for a project.
2089
+
2090
+ Args:
2091
+ project_id: Project UUID to filter by.
2092
+
2093
+ Returns:
2094
+ List of user layer instances.
2095
+ """
2096
+ response = self._client.get(
2097
+ "/api/v1/user-layers",
2098
+ params={"project_id": str(project_id)},
2099
+ )
2100
+ response.raise_for_status()
2101
+ return [UserLayer.model_validate(item) for item in response.json()]
2102
+
2103
+ def update_user_layer(
2104
+ self, user_layer_id: UUID, user_layer: UserLayerUpdate
2105
+ ) -> UserLayer:
2106
+ """Update a user layer.
2107
+
2108
+ Args:
2109
+ user_layer_id: User layer UUID to update.
2110
+ user_layer: User layer update data.
2111
+
2112
+ Returns:
2113
+ Updated user layer instance.
2114
+ """
2115
+ response = self._client.patch(
2116
+ f"/api/v1/user-layers/{user_layer_id}",
2117
+ json=user_layer.model_dump(mode="json", exclude_none=True),
2118
+ )
2119
+ response.raise_for_status()
2120
+ return UserLayer.model_validate(response.json())
2121
+
2122
+ def delete_user_layer(self, user_layer_id: UUID) -> None:
2123
+ """Delete a user layer.
2124
+
2125
+ Args:
2126
+ user_layer_id: User layer UUID to delete.
2127
+ """
2128
+ response = self._client.delete(f"/api/v1/user-layers/{user_layer_id}")
2129
+ response.raise_for_status()
2130
+
2007
2131
  # ── Processing Jobs ──────────────────────────────────────────────────
2008
2132
 
2009
2133
  def create_job(self, job_data: ProcessingJobCreate) -> ProcessingJob:
@@ -97,6 +97,10 @@ from cti.model.target import TargetUpdate
97
97
  from cti.model.tow_system import TowSystem
98
98
  from cti.model.tow_system import TowSystemCreate
99
99
  from cti.model.tow_system import TowSystemUpdate
100
+ from cti.model.user_layer import UserLayer
101
+ from cti.model.user_layer import UserLayerCreate
102
+ from cti.model.user_layer import UserLayerUpdate
103
+ from cti.model.user_layer import UserLayerWithUpload
100
104
 
101
105
  from .async_client import ClarityApiAsyncClient
102
106
 
@@ -353,13 +357,39 @@ class SonarWizAsyncApi:
353
357
  response.raise_for_status()
354
358
  return RawFileDeviceMapping.model_validate(response.json())
355
359
 
356
- async def list_raw_file_device_mappings(self) -> list[RawFileDeviceMapping]:
357
- """List all raw file device mappings.
360
+ async def get_raw_file_device_mappings(
361
+ self,
362
+ *,
363
+ raw_file_id: UUID | str | None = None,
364
+ device_id: UUID | str | None = None,
365
+ ) -> list[RawFileDeviceMapping]:
366
+ """Get raw file device mappings filtered by raw file or device.
367
+
368
+ At least one of ``raw_file_id`` / ``device_id`` must be provided —
369
+ the server requires a filter and returns 400 otherwise.
370
+
371
+ Args:
372
+ raw_file_id: Match only mappings for this raw file.
373
+ device_id: Match only mappings for this device.
358
374
 
359
375
  Returns:
360
- List of raw file device mapping instances.
376
+ List of matching raw file device mappings (empty if none match).
377
+
378
+ Raises:
379
+ ValueError: If neither ``raw_file_id`` nor ``device_id`` is given.
361
380
  """
362
- response = await self._client.get("/api/v1/raw-file-device-mappings")
381
+ if raw_file_id is None and device_id is None:
382
+ raise ValueError(
383
+ "At least one of raw_file_id or device_id must be provided."
384
+ )
385
+ params: dict[str, str] = {}
386
+ if raw_file_id is not None:
387
+ params["raw_file_id"] = str(raw_file_id)
388
+ if device_id is not None:
389
+ params["device_id"] = str(device_id)
390
+ response = await self._client.get(
391
+ "/api/v1/raw-file-device-mappings", params=params
392
+ )
363
393
  response.raise_for_status()
364
394
  return [RawFileDeviceMapping.model_validate(item) for item in response.json()]
365
395
 
@@ -2023,13 +2053,36 @@ class SonarWizAsyncApi:
2023
2053
  response.raise_for_status()
2024
2054
  return ProcessingLog.model_validate(response.json())
2025
2055
 
2026
- async def list_processing_logs(self) -> list[ProcessingLog]:
2027
- """List all processing logs.
2056
+ async def list_processing_logs(
2057
+ self,
2058
+ *,
2059
+ raw_file_id: UUID | str | None = None,
2060
+ survey_id: UUID | str | None = None,
2061
+ processing_step: str | None = None,
2062
+ ) -> list[ProcessingLog]:
2063
+ """List processing logs, optionally filtered.
2064
+
2065
+ Filters compose: each provided filter narrows the result set.
2066
+
2067
+ Args:
2068
+ raw_file_id: Match only logs for this raw file.
2069
+ survey_id: Match only logs for this survey.
2070
+ processing_step: Match only logs with this processing step
2071
+ (e.g. ``"scan"``, ``"ingest"``, ``"products"``).
2028
2072
 
2029
2073
  Returns:
2030
- List of processing log instances.
2074
+ List of matching processing logs (empty list if none match).
2031
2075
  """
2032
- response = await self._client.get("/api/v1/processing-logs")
2076
+ params: dict[str, str] = {}
2077
+ if raw_file_id is not None:
2078
+ params["raw_file_id"] = str(raw_file_id)
2079
+ if survey_id is not None:
2080
+ params["survey_id"] = str(survey_id)
2081
+ if processing_step is not None:
2082
+ params["processing_step"] = processing_step
2083
+ response = await self._client.get(
2084
+ "/api/v1/processing-logs", params=params or None
2085
+ )
2033
2086
  response.raise_for_status()
2034
2087
  return [ProcessingLog.model_validate(item) for item in response.json()]
2035
2088
 
@@ -2052,6 +2105,83 @@ class SonarWizAsyncApi:
2052
2105
  response.raise_for_status()
2053
2106
  return ProcessingLog.model_validate(response.json())
2054
2107
 
2108
+ # ── User Layers ──────────────────────────────────────────────────────
2109
+
2110
+ async def create_user_layer(
2111
+ self, user_layer: UserLayerCreate
2112
+ ) -> UserLayerWithUpload:
2113
+ """Create a new user layer and initiate upload.
2114
+
2115
+ Args:
2116
+ user_layer: User layer creation data.
2117
+
2118
+ Returns:
2119
+ Created user layer instance with upload information.
2120
+ """
2121
+ response = await self._client.post(
2122
+ "/api/v1/user-layers/upload",
2123
+ json=user_layer.model_dump(mode="json"),
2124
+ )
2125
+ response.raise_for_status()
2126
+ return UserLayerWithUpload.model_validate(response.json())
2127
+
2128
+ async def get_user_layer(self, user_layer_id: UUID) -> UserLayer:
2129
+ """Fetch a user layer by ID.
2130
+
2131
+ Args:
2132
+ user_layer_id: User layer UUID to fetch.
2133
+
2134
+ Returns:
2135
+ UserLayer instance.
2136
+ """
2137
+ response = await self._client.get(f"/api/v1/user-layers/{user_layer_id}")
2138
+ response.raise_for_status()
2139
+ return UserLayer.model_validate(response.json())
2140
+
2141
+ async def list_user_layers(self, project_id: UUID) -> list[UserLayer]:
2142
+ """List all user layers for a project.
2143
+
2144
+ Args:
2145
+ project_id: Project UUID to filter by.
2146
+
2147
+ Returns:
2148
+ List of user layer instances.
2149
+ """
2150
+ response = await self._client.get(
2151
+ "/api/v1/user-layers",
2152
+ params={"project_id": str(project_id)},
2153
+ )
2154
+ response.raise_for_status()
2155
+ return [UserLayer.model_validate(item) for item in response.json()]
2156
+
2157
+ async def update_user_layer(
2158
+ self, user_layer_id: UUID, user_layer: UserLayerUpdate
2159
+ ) -> UserLayer:
2160
+ """Update a user layer.
2161
+
2162
+ Args:
2163
+ user_layer_id: User layer UUID to update.
2164
+ user_layer: User layer update data.
2165
+
2166
+ Returns:
2167
+ Updated user layer instance.
2168
+ """
2169
+ response = await self._client.patch(
2170
+ f"/api/v1/user-layers/{user_layer_id}",
2171
+ json=user_layer.model_dump(mode="json", exclude_none=True),
2172
+ )
2173
+ response.raise_for_status()
2174
+ return UserLayer.model_validate(response.json())
2175
+
2176
+ async def delete_user_layer(self, user_layer_id: UUID) -> None:
2177
+ """Delete a user layer.
2178
+
2179
+ Args:
2180
+ user_layer_id: User layer UUID to delete.
2181
+ """
2182
+ response = await self._client.delete(f"/api/v1/user-layers/{user_layer_id}")
2183
+ response.raise_for_status()
2184
+
2055
2185
  # ── Processing Jobs ──────────────────────────────────────────────────
2056
2186
 
2057
2187
  async def create_job(self, job_data: ProcessingJobCreate) -> ProcessingJob:
@@ -59,10 +59,14 @@ def upload(
59
59
  False, "--no-mappings", help="Skip automatic device mapping creation"
60
60
  ),
61
61
  sidescan_stream: str = typer.Option(
62
- "0", "--sidescan-stream", help="Source stream identifier for sidescan device"
62
+ "xtf://sidescan(source=ping_header,pair=0)",
63
+ "--sidescan-stream",
64
+ help="Source URI for the sidescan stream identifier",
63
65
  ),
64
66
  gnss_stream: str = typer.Option(
65
- "1", "--gnss-stream", help="Source stream identifier for GNSS device"
67
+ "xtf://position(source=ping_header,description=ship_position)",
68
+ "--gnss-stream",
69
+ help="Source URI for the GNSS stream identifier",
66
70
  ),
67
71
  output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
68
72
  ) -> None:
@@ -71,7 +75,22 @@ def upload(
71
75
  After uploading, automatically creates device mappings linking the file
72
76
  to the survey's sidescan and GNSS devices. Use --no-mappings to skip.
73
77
  """
74
- from cti.model.raw_file_device_mapping import RawFileDeviceMappingCreate
78
+ from cti.model.raw_file_device_mapping import (
79
+ RawFileDeviceMappingCreate,
80
+ SourceSelection,
81
+ StreamIdentifier,
82
+ )
83
+
84
+ def _identifier_from_uri(uri: str, roles: tuple[str, ...]) -> StreamIdentifier:
85
+ return StreamIdentifier(
86
+ source=SourceSelection(available=[uri], selected=uri),
87
+ fields_by_source={
88
+ uri: {
89
+ role: SourceSelection(available=[role], selected=role)
90
+ for role in roles
91
+ }
92
+ },
93
+ )
75
94
 
76
95
  if not file.exists():
77
96
  typer.echo(f"Error: file not found: {file}", err=True)
@@ -115,9 +134,13 @@ def upload(
115
134
  if not dt:
116
135
  continue
117
136
  if dt.enum_name == "sidescan_sonar":
118
- stream_id = sidescan_stream
137
+ stream_id = _identifier_from_uri(
138
+ sidescan_stream, ("samples", "timestamp")
139
+ )
119
140
  elif dt.enum_name == "gnss_gps_position_sensor":
120
- stream_id = gnss_stream
141
+ stream_id = _identifier_from_uri(
142
+ gnss_stream, ("position", "timestamp")
143
+ )
121
144
  else:
122
145
  continue
123
146
 
@@ -130,7 +153,9 @@ def upload(
130
153
  input_timezone=tz,
131
154
  )
132
155
  )
133
- typer.echo(f"Mapped: {device.name} → stream {stream_id}")
156
+ typer.echo(
157
+ f"Mapped: {device.name} → stream {stream_id.source.selected}"
158
+ )
134
159
  mappings_created += 1
135
160
 
136
161
  if mappings_created == 0:
@@ -7,20 +7,61 @@ from uuid import UUID
7
7
  from pydantic import BaseModel, ConfigDict, Field, field_validator
8
8
 
9
9
 
10
+ class SourceSelection(BaseModel):
11
+ """An ``{available, selected}`` pair.
12
+
13
+ Used at both levels of ``StreamIdentifier``: outer source URI and
14
+ inner field-role selection.
15
+
16
+ Attributes:
17
+ available: All candidate values produced by the scan.
18
+ selected: The currently chosen value; must be one of ``available``.
19
+ """
20
+
21
+ available: list[str]
22
+ selected: str
23
+
24
+
25
+ class StreamIdentifier(BaseModel):
26
+ """Two-level source / field selection persisted on RawFileDeviceMapping.
27
+
28
+ Mirrors the shape produced by the engine's manifest serializer for
29
+ one stream descriptor (see clarity-engine
30
+ ``pipeline/ingest/manifest_serializer.py``):
31
+
32
+ - ``source`` — which packet-level source within the file feeds this
33
+ device (e.g., ``xtf://sidescan(source=ping_header,pair=0)``).
34
+ - ``fields_by_source`` — for each available source URI, which raw
35
+ field provides each role (``samples``, ``timestamp``, ``position``,
36
+ …).
37
+
38
+ Attributes:
39
+ source: Outer source-URI selection.
40
+ fields_by_source: Per-source role → field selection. Outer key is
41
+ the source URI; inner key is the role name.
42
+ """
43
+
44
+ source: SourceSelection
45
+ fields_by_source: dict[str, dict[str, SourceSelection]]
46
+
47
+
10
48
  class RawFileDeviceMappingBase(BaseModel):
11
49
  """Base model for raw file device mapping.
12
50
 
13
51
  Attributes:
14
52
  raw_file_id: Foreign key reference to RawFile.
15
53
  device_id: Foreign key reference to Device.
16
- source_stream_identifier: Identifier for the source stream.
54
+ source_stream_identifier: Two-level source / field-role selection.
55
+ auto_mapped: True for system-generated mappings, False for user
56
+ overrides. Defaults to True.
17
57
  input_srid: Spatial reference ID for the input data.
18
58
  input_timezone: IANA timezone name for the input data (e.g. Etc/UTC).
19
59
  """
20
60
 
21
61
  raw_file_id: UUID
22
62
  device_id: UUID
23
- source_stream_identifier: str
63
+ source_stream_identifier: StreamIdentifier
64
+ auto_mapped: bool = True
24
65
  input_srid: int = Field(ge=2000, le=900913)
25
66
  input_timezone: str
26
67
 
@@ -0,0 +1,125 @@
1
+ """Pydantic models for user layer. User-uploaded geospatial layers associated with a project."""
2
+
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from uuid import UUID
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+
10
+ class UserLayerState(str, Enum):
11
+ """Status of a user layer."""
12
+
13
+ UPLOADING = "uploading"
14
+ READY = "ready"
15
+ ERROR = "error"
16
+
17
+
18
+ class UserLayerType(str, Enum):
19
+ """Supported user layer file types."""
20
+
21
+ GEOJSON = "geojson"
22
+ KML = "kml"
23
+ KMZ = "kmz"
24
+ SHAPEFILE = "shapefile"
25
+ GPX = "gpx"
26
+ CSV = "csv"
27
+ GEOTIFF = "geotiff"
28
+ BAG = "bag"
29
+ LAS = "las"
30
+ LAZ = "laz"
31
+ PLY = "ply"
32
+ PCD = "pcd"
33
+ MAGNETOMETRY = "magnetometry"
34
+
35
+
36
+ class UserLayerBase(BaseModel):
37
+ """Base model for user layer data.
38
+
39
+ Attributes:
40
+ project_id: Foreign key reference to Project.
41
+ layer_name: Display name for the layer.
42
+ layer_type: Type of the user layer file.
43
+ file_name: Name of the uploaded file.
44
+ file_size: Size of the uploaded file in bytes.
45
+ style: Optional styling configuration for the layer.
46
+ metadata: Optional metadata for the layer.
47
+ sort_order: Display sort order.
48
+ """
49
+
50
+ project_id: UUID
51
+ layer_name: str
52
+ layer_type: UserLayerType
53
+ file_name: str | None = None
54
+ file_size: int | None = None
55
+ style: dict | None = None
56
+ metadata: dict | None = None
57
+ sort_order: int = 0
58
+
59
+
60
+ class UserLayerCreate(UserLayerBase):
61
+ """Model for creating a user layer."""
62
+
63
+
64
+ class UserLayerUpdate(BaseModel):
65
+ """Model for updating a user layer.
66
+
67
+ Attributes:
68
+ layer_name: Optional updated display name.
69
+ style: Optional updated styling configuration.
70
+ metadata: Optional updated metadata.
71
+ sort_order: Optional updated sort order.
72
+ state: Optional updated state.
73
+ """
74
+
75
+ layer_name: str | None = None
76
+ style: dict | None = None
77
+ metadata: dict | None = None
78
+ sort_order: int | None = None
79
+ state: UserLayerState | None = None
80
+
81
+
82
+ class UserLayer(UserLayerBase):
83
+ """Model for user layer data with database fields.
84
+
85
+ Attributes:
86
+ user_layer_id: Unique identifier for the user layer.
87
+ user_id: ID of the user who created the layer.
88
+ state: Upload state (uploading, ready, error).
89
+ uri: URI for the layer file storage location.
90
+ upload_id: S3 multipart upload ID (present while uploading, cleared when complete).
91
+ created_date: Timestamp when the layer was created.
92
+ updated_date: Timestamp when the layer was last updated.
93
+ updated_by: ID of the user who last updated the layer.
94
+ """
95
+
96
+ user_layer_id: UUID
97
+ user_id: str
98
+ state: UserLayerState = UserLayerState.UPLOADING
99
+ uri: str | None = None
100
+ upload_id: str | None = None
101
+ created_date: datetime
102
+ updated_date: datetime
103
+ updated_by: str
104
+
105
+ model_config = ConfigDict(from_attributes=True)
106
+
107
+
108
+ class UserLayerWithUpload(UserLayer):
109
+ """UserLayer response for the create-with-upload endpoint.
110
+
111
+ The endpoint initiates an S3 multipart upload before returning, so `uri`
112
+ and `upload_id` are guaranteed to be present. Overrides those fields to
113
+ be non-nullable so callers don't have to defensively check.
114
+
115
+ Attributes:
116
+ uri: S3 URI for the layer file storage location. Always populated.
117
+ upload_id: S3 multipart upload ID. Always populated; clients pass it
118
+ to subsequent part-upload and complete-upload calls.
119
+ """
120
+
121
+ # Tightening parent's `str | None` to required `str`. Pyright flags the
122
+ # type narrowing as an override issue; Pydantic treats the bare field
123
+ # annotation as required at runtime, which is what we want.
124
+ uri: str # type: ignore[assignment]
125
+ upload_id: str # type: ignore[assignment]
@@ -0,0 +1,110 @@
1
+ """Round-trip tests for the RawFileDeviceMapping SDK Pydantic model.
2
+
3
+ Phase II Step 10 reshaped ``source_stream_identifier`` from a free-form
4
+ string to a nested ``StreamIdentifier``. These tests pin the wire shape
5
+ the engine and server already agree on so future drift fails loudly.
6
+
7
+ See clarity-server#104 / #105 and
8
+ designs/spec-file-prescan-stream-mapping.md §lines 489–495, 939–953.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from uuid import uuid4
14
+
15
+ import pytest
16
+ from pydantic import ValidationError
17
+
18
+ from cti.model.raw_file_device_mapping import (
19
+ RawFileDeviceMappingCreate,
20
+ SourceSelection,
21
+ StreamIdentifier,
22
+ )
23
+
24
+
25
+ _SOURCE_URI = "xtf://sidescan(source=ping_header,pair=0)"
26
+ _SAMPLE_IDENTIFIER_DICT = {
27
+ "source": {"available": [_SOURCE_URI], "selected": _SOURCE_URI},
28
+ "fields_by_source": {
29
+ _SOURCE_URI: {
30
+ "samples": {"available": ["samples"], "selected": "samples"},
31
+ "timestamp": {"available": ["timestamp"], "selected": "timestamp"},
32
+ }
33
+ },
34
+ }
35
+
36
+
37
+ def test_stream_identifier_round_trips_from_dict() -> None:
38
+ """A dict in the manifest-serializer shape parses and serializes back unchanged."""
39
+ ident = StreamIdentifier.model_validate(_SAMPLE_IDENTIFIER_DICT)
40
+
41
+ assert isinstance(ident.source, SourceSelection)
42
+ assert ident.source.selected == _SOURCE_URI
43
+ assert ident.fields_by_source[_SOURCE_URI]["samples"].selected == "samples"
44
+
45
+ assert ident.model_dump() == _SAMPLE_IDENTIFIER_DICT
46
+
47
+
48
+ def test_create_payload_round_trips_through_json() -> None:
49
+ """End-to-end: build a Create model, serialize to JSON, parse it back."""
50
+ payload = RawFileDeviceMappingCreate(
51
+ raw_file_id=uuid4(),
52
+ device_id=uuid4(),
53
+ source_stream_identifier=StreamIdentifier.model_validate(
54
+ _SAMPLE_IDENTIFIER_DICT
55
+ ),
56
+ input_srid=4326,
57
+ input_timezone="Etc/UTC",
58
+ )
59
+
60
+ parsed = RawFileDeviceMappingCreate.model_validate_json(payload.model_dump_json())
61
+ assert parsed == payload
62
+ assert parsed.auto_mapped is True
63
+
64
+
65
+ def test_auto_mapped_defaults_to_true_when_omitted() -> None:
66
+ """``auto_mapped`` is a system-generated default, not required from clients."""
67
+ payload = RawFileDeviceMappingCreate.model_validate(
68
+ {
69
+ "raw_file_id": str(uuid4()),
70
+ "device_id": str(uuid4()),
71
+ "source_stream_identifier": _SAMPLE_IDENTIFIER_DICT,
72
+ "input_srid": 4326,
73
+ "input_timezone": "Etc/UTC",
74
+ }
75
+ )
76
+ assert payload.auto_mapped is True
77
+
78
+
79
+ def test_auto_mapped_false_persists_for_user_overrides() -> None:
80
+ """User-supplied overrides must round-trip ``auto_mapped: false``."""
81
+ payload = RawFileDeviceMappingCreate.model_validate(
82
+ {
83
+ "raw_file_id": str(uuid4()),
84
+ "device_id": str(uuid4()),
85
+ "source_stream_identifier": _SAMPLE_IDENTIFIER_DICT,
86
+ "auto_mapped": False,
87
+ "input_srid": 4326,
88
+ "input_timezone": "Etc/UTC",
89
+ }
90
+ )
91
+ assert payload.auto_mapped is False
92
+
93
+
94
+ def test_string_source_stream_identifier_is_rejected() -> None:
95
+ """The pre-Phase-II free-form string form must no longer validate.
96
+
97
+ Pins the breaking change so a future regression that loosens the type
98
+ back to ``str`` fails this test instead of silently re-allowing
99
+ unstructured identifiers.
100
+ """
101
+ with pytest.raises(ValidationError):
102
+ RawFileDeviceMappingCreate.model_validate(
103
+ {
104
+ "raw_file_id": str(uuid4()),
105
+ "device_id": str(uuid4()),
106
+ "source_stream_identifier": "channel-0",
107
+ "input_srid": 4326,
108
+ "input_timezone": "Etc/UTC",
109
+ }
110
+ )
@@ -18,11 +18,16 @@ from .conftest import (
18
18
  RAW_FILE_ID,
19
19
  SURVEY_ID,
20
20
  make_job_json,
21
+ make_processing_log_json,
22
+ make_raw_file_device_mapping_json,
21
23
  make_raw_file_json,
22
24
  make_raw_file_with_upload_json,
23
25
  )
24
26
 
25
27
 
28
+ DEVICE_ID = UUID("00000000-0000-0000-0000-000000000099")
29
+
30
+
26
31
  def _mock_response(
27
32
  json_data: dict, status_code: int = 200, headers: dict | None = None
28
33
  ) -> MagicMock:
@@ -219,3 +224,148 @@ async def test_wait_for_job_raises_on_timeout():
219
224
  with patch.object(loop, "time", side_effect=mock_time):
220
225
  with pytest.raises(TimeoutError, match="did not complete"):
221
226
  await api.wait_for_job(job_id=JOB_ID, timeout=10)
227
+
228
+
229
+ # ── list_processing_logs filter kwargs (WI-0106 A1) ─────────────────────
230
+
231
+
232
+ @pytest.mark.asyncio
233
+ async def test_list_processing_logs_no_filters_omits_params():
234
+ """No kwargs → request sent with params=None (preserves existing behavior)."""
235
+ client = AsyncMock()
236
+ client.get.return_value = _mock_response([make_processing_log_json()])
237
+
238
+ api = SonarWizAsyncApi(client)
239
+ logs = await api.list_processing_logs()
240
+
241
+ assert len(logs) == 1
242
+ client.get.assert_awaited_once_with("/api/v1/processing-logs", params=None)
243
+
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_list_processing_logs_all_filters_passed_as_query_params():
247
+ """All three filters compose into the query string; UUIDs stringified."""
248
+ client = AsyncMock()
249
+ client.get.return_value = _mock_response(
250
+ [make_processing_log_json(processing_step="scan", result='{"streams":[]}')]
251
+ )
252
+
253
+ api = SonarWizAsyncApi(client)
254
+ logs = await api.list_processing_logs(
255
+ raw_file_id=RAW_FILE_ID,
256
+ survey_id=SURVEY_ID,
257
+ processing_step="scan",
258
+ )
259
+
260
+ client.get.assert_awaited_once_with(
261
+ "/api/v1/processing-logs",
262
+ params={
263
+ "raw_file_id": str(RAW_FILE_ID),
264
+ "survey_id": str(SURVEY_ID),
265
+ "processing_step": "scan",
266
+ },
267
+ )
268
+ assert logs[0].processing_step == "scan"
269
+ assert logs[0].result == '{"streams":[]}'
270
+ assert logs[0].processing_hash == "a" * 64
271
+
272
+
273
+ @pytest.mark.asyncio
274
+ async def test_list_processing_logs_partial_filter_only_includes_provided():
275
+ """Unset filters are omitted from the query string."""
276
+ client = AsyncMock()
277
+ client.get.return_value = _mock_response([])
278
+
279
+ api = SonarWizAsyncApi(client)
280
+ logs = await api.list_processing_logs(
281
+ raw_file_id=RAW_FILE_ID, processing_step="scan"
282
+ )
283
+
284
+ client.get.assert_awaited_once_with(
285
+ "/api/v1/processing-logs",
286
+ params={"raw_file_id": str(RAW_FILE_ID), "processing_step": "scan"},
287
+ )
288
+ assert logs == []
289
+
290
+
291
+ @pytest.mark.asyncio
292
+ async def test_list_processing_logs_accepts_string_uuid():
293
+ """String UUIDs are accepted and stringified the same way."""
294
+ client = AsyncMock()
295
+ client.get.return_value = _mock_response([])
296
+
297
+ api = SonarWizAsyncApi(client)
298
+ await api.list_processing_logs(raw_file_id=str(RAW_FILE_ID))
299
+
300
+ client.get.assert_awaited_once_with(
301
+ "/api/v1/processing-logs",
302
+ params={"raw_file_id": str(RAW_FILE_ID)},
303
+ )
304
+
305
+
306
+ # ── get_raw_file_device_mappings filter kwargs (#23) ────────────────────
307
+
308
+
309
+ @pytest.mark.asyncio
310
+ async def test_get_raw_file_device_mappings_requires_a_filter():
311
+ """Calling with neither filter raises ValueError before touching the wire."""
312
+ client = AsyncMock()
313
+ api = SonarWizAsyncApi(client)
314
+
315
+ with pytest.raises(ValueError, match="raw_file_id or device_id"):
316
+ await api.get_raw_file_device_mappings()
317
+
318
+ client.get.assert_not_called()
319
+
320
+
321
+ @pytest.mark.asyncio
322
+ async def test_get_raw_file_device_mappings_filters_by_raw_file_id():
323
+ """raw_file_id filter is sent as a query param; UUID stringified."""
324
+ client = AsyncMock()
325
+ client.get.return_value = _mock_response(
326
+ [make_raw_file_device_mapping_json(device_id=DEVICE_ID)]
327
+ )
328
+
329
+ api = SonarWizAsyncApi(client)
330
+ mappings = await api.get_raw_file_device_mappings(raw_file_id=RAW_FILE_ID)
331
+
332
+ client.get.assert_awaited_once_with(
333
+ "/api/v1/raw-file-device-mappings",
334
+ params={"raw_file_id": str(RAW_FILE_ID)},
335
+ )
336
+ assert len(mappings) == 1
337
+ assert mappings[0].raw_file_id == RAW_FILE_ID
338
+ assert mappings[0].device_id == DEVICE_ID
339
+
340
+
341
+ @pytest.mark.asyncio
342
+ async def test_get_raw_file_device_mappings_filters_by_device_id():
343
+ """device_id filter is sent alone when raw_file_id is omitted."""
344
+ client = AsyncMock()
345
+ client.get.return_value = _mock_response([])
346
+
347
+ api = SonarWizAsyncApi(client)
348
+ mappings = await api.get_raw_file_device_mappings(device_id=DEVICE_ID)
349
+
350
+ client.get.assert_awaited_once_with(
351
+ "/api/v1/raw-file-device-mappings",
352
+ params={"device_id": str(DEVICE_ID)},
353
+ )
354
+ assert mappings == []
355
+
356
+
357
+ @pytest.mark.asyncio
358
+ async def test_get_raw_file_device_mappings_accepts_string_uuids():
359
+ """String UUIDs are accepted and stringified the same way."""
360
+ client = AsyncMock()
361
+ client.get.return_value = _mock_response([])
362
+
363
+ api = SonarWizAsyncApi(client)
364
+ await api.get_raw_file_device_mappings(
365
+ raw_file_id=str(RAW_FILE_ID), device_id=str(DEVICE_ID)
366
+ )
367
+
368
+ client.get.assert_awaited_once_with(
369
+ "/api/v1/raw-file-device-mappings",
370
+ params={"raw_file_id": str(RAW_FILE_ID), "device_id": str(DEVICE_ID)},
371
+ )
@@ -19,11 +19,16 @@ from .conftest import (
19
19
  RAW_FILE_ID,
20
20
  SURVEY_ID,
21
21
  make_job_json,
22
+ make_processing_log_json,
23
+ make_raw_file_device_mapping_json,
22
24
  make_raw_file_json,
23
25
  make_raw_file_with_upload_json,
24
26
  )
25
27
 
26
28
 
29
+ DEVICE_ID = UUID("00000000-0000-0000-0000-000000000099")
30
+
31
+
27
32
  def _mock_response(
28
33
  json_data: dict, status_code: int = 200, headers: dict | None = None
29
34
  ) -> MagicMock:
@@ -262,3 +267,121 @@ class TestWaitForJob:
262
267
  mock_mono.side_effect = [0.0, 0.0, 0.0, 100.0]
263
268
  with pytest.raises(TimeoutError, match="did not complete"):
264
269
  api.wait_for_job(job_id=JOB_ID, timeout=10)
270
+
271
+
272
+ # ── list_processing_logs filter kwargs (WI-0106 A1) ─────────────────────
273
+
274
+
275
+ def test_list_processing_logs_no_filters_omits_params():
276
+ """No kwargs → request sent with params=None."""
277
+ client = MagicMock()
278
+ client.get.return_value = _mock_response([make_processing_log_json()])
279
+
280
+ api = SonarWizApi(client)
281
+ logs = api.list_processing_logs()
282
+
283
+ assert len(logs) == 1
284
+ client.get.assert_called_once_with("/api/v1/processing-logs", params=None)
285
+
286
+
287
+ def test_list_processing_logs_all_filters_passed_as_query_params():
288
+ """All three filters compose into the query string."""
289
+ client = MagicMock()
290
+ client.get.return_value = _mock_response(
291
+ [make_processing_log_json(processing_step="scan")]
292
+ )
293
+
294
+ api = SonarWizApi(client)
295
+ api.list_processing_logs(
296
+ raw_file_id=RAW_FILE_ID,
297
+ survey_id=SURVEY_ID,
298
+ processing_step="scan",
299
+ )
300
+
301
+ client.get.assert_called_once_with(
302
+ "/api/v1/processing-logs",
303
+ params={
304
+ "raw_file_id": str(RAW_FILE_ID),
305
+ "survey_id": str(SURVEY_ID),
306
+ "processing_step": "scan",
307
+ },
308
+ )
309
+
310
+
311
+ def test_list_processing_logs_partial_filter_only_includes_provided():
312
+ """Unset filters are omitted from the query string."""
313
+ client = MagicMock()
314
+ client.get.return_value = _mock_response([])
315
+
316
+ api = SonarWizApi(client)
317
+ logs = api.list_processing_logs(survey_id=SURVEY_ID)
318
+
319
+ client.get.assert_called_once_with(
320
+ "/api/v1/processing-logs",
321
+ params={"survey_id": str(SURVEY_ID)},
322
+ )
323
+ assert logs == []
324
+
325
+
326
+ # ── get_raw_file_device_mappings filter kwargs (#23) ────────────────────
327
+
328
+
329
+ def test_get_raw_file_device_mappings_requires_a_filter():
330
+ """Calling with neither filter raises ValueError before touching the wire."""
331
+ client = MagicMock()
332
+ api = SonarWizApi(client)
333
+
334
+ with pytest.raises(ValueError, match="raw_file_id or device_id"):
335
+ api.get_raw_file_device_mappings()
336
+
337
+ client.get.assert_not_called()
338
+
339
+
340
+ def test_get_raw_file_device_mappings_filters_by_raw_file_id():
341
+ """raw_file_id filter is sent as a query param; UUID stringified."""
342
+ client = MagicMock()
343
+ client.get.return_value = _mock_response(
344
+ [make_raw_file_device_mapping_json(device_id=DEVICE_ID)]
345
+ )
346
+
347
+ api = SonarWizApi(client)
348
+ mappings = api.get_raw_file_device_mappings(raw_file_id=RAW_FILE_ID)
349
+
350
+ client.get.assert_called_once_with(
351
+ "/api/v1/raw-file-device-mappings",
352
+ params={"raw_file_id": str(RAW_FILE_ID)},
353
+ )
354
+ assert len(mappings) == 1
355
+ assert mappings[0].raw_file_id == RAW_FILE_ID
356
+ assert mappings[0].device_id == DEVICE_ID
357
+
358
+
359
+ def test_get_raw_file_device_mappings_filters_by_device_id():
360
+ """device_id filter is sent alone when raw_file_id is omitted."""
361
+ client = MagicMock()
362
+ client.get.return_value = _mock_response([])
363
+
364
+ api = SonarWizApi(client)
365
+ mappings = api.get_raw_file_device_mappings(device_id=DEVICE_ID)
366
+
367
+ client.get.assert_called_once_with(
368
+ "/api/v1/raw-file-device-mappings",
369
+ params={"device_id": str(DEVICE_ID)},
370
+ )
371
+ assert mappings == []
372
+
373
+
374
+ def test_get_raw_file_device_mappings_accepts_string_uuids():
375
+ """String UUIDs are accepted and stringified the same way."""
376
+ client = MagicMock()
377
+ client.get.return_value = _mock_response([])
378
+
379
+ api = SonarWizApi(client)
380
+ api.get_raw_file_device_mappings(
381
+ raw_file_id=str(RAW_FILE_ID), device_id=str(DEVICE_ID)
382
+ )
383
+
384
+ client.get.assert_called_once_with(
385
+ "/api/v1/raw-file-device-mappings",
386
+ params={"raw_file_id": str(RAW_FILE_ID), "device_id": str(DEVICE_ID)},
387
+ )