clarity-api-sdk-python 0.4.2__tar.gz → 0.4.3__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 (63) hide show
  1. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/PKG-INFO +1 -1
  2. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/pyproject.toml +1 -1
  3. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/clarity_api_sdk_python.egg-info/PKG-INFO +1 -1
  4. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/api/sonar_wiz_api.py +30 -7
  5. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/api/sonar_wiz_async_api.py +30 -7
  6. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/tests/test_sdk_async_methods.py +160 -0
  7. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/tests/test_sdk_methods.py +150 -0
  8. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/README.md +0 -0
  9. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/setup.cfg +0 -0
  10. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/clarity_api_sdk_python.egg-info/SOURCES.txt +0 -0
  11. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/clarity_api_sdk_python.egg-info/dependency_links.txt +0 -0
  12. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/clarity_api_sdk_python.egg-info/entry_points.txt +0 -0
  13. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/clarity_api_sdk_python.egg-info/requires.txt +0 -0
  14. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/clarity_api_sdk_python.egg-info/top_level.txt +0 -0
  15. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/__init__.py +0 -0
  16. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/api/__init__.py +0 -0
  17. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/api/async_client.py +0 -0
  18. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/api/client.py +0 -0
  19. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/api/session.py +0 -0
  20. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/cli/__init__.py +0 -0
  21. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/cli/__main__.py +0 -0
  22. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/cli/client.py +0 -0
  23. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/cli/main.py +0 -0
  24. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/logger/__init__.py +0 -0
  25. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/logger/logger.py +0 -0
  26. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/main.py +0 -0
  27. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/main_api.py +0 -0
  28. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/__init__.py +0 -0
  29. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/altitude_source.py +0 -0
  30. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/attitude_source.py +0 -0
  31. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/deferred_object_deletion.py +0 -0
  32. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/depth_source.py +0 -0
  33. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/device.py +0 -0
  34. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/device_type.py +0 -0
  35. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/final_product.py +0 -0
  36. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/hierarchy.py +0 -0
  37. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/layback_algorithm.py +0 -0
  38. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/layback_source.py +0 -0
  39. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/layback_type.py +0 -0
  40. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/organization.py +0 -0
  41. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/platform.py +0 -0
  42. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/platform_type.py +0 -0
  43. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/position_source.py +0 -0
  44. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/processing_job.py +0 -0
  45. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/processing_log.py +0 -0
  46. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/project.py +0 -0
  47. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/projection_option.py +0 -0
  48. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/raw_file.py +0 -0
  49. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/raw_file_configuration.py +0 -0
  50. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/raw_file_device_mapping.py +0 -0
  51. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/raw_file_state.py +0 -0
  52. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/s3.py +0 -0
  53. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/sidescan_ping_source.py +0 -0
  54. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/source.py +0 -0
  55. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/survey.py +0 -0
  56. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/target.py +0 -0
  57. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/tow_system.py +0 -0
  58. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/model/user_layer.py +0 -0
  59. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/positioning/__init__.py +0 -0
  60. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/src/cti/positioning/target_geometry.py +0 -0
  61. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/tests/test_cli.py +0 -0
  62. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/tests/test_raw_file_device_mapping_model.py +0 -0
  63. {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.3}/tests/test_target_geometry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clarity-api-sdk-python
3
- Version: 0.4.2
3
+ Version: 0.4.3
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.4.2"
8
+ version = "0.4.3"
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.4.2
3
+ Version: 0.4.3
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
@@ -1709,33 +1709,56 @@ class SonarWizApi:
1709
1709
  response.raise_for_status()
1710
1710
  return Target.model_validate(response.json())
1711
1711
 
1712
- def list_targets(self) -> list[Target]:
1713
- """List all targets.
1712
+ def list_targets(self, sidescan_ping_source_id: UUID | str) -> list[Target]:
1713
+ """List all targets for one sidescan ping source.
1714
+
1715
+ The cross-source ``GET /api/v1/targets`` endpoint is deprecated;
1716
+ the spec scopes target reads to a single SidescanPingSource.
1717
+
1718
+ Args:
1719
+ sidescan_ping_source_id: SidescanPingSource UUID or string identifier.
1714
1720
 
1715
1721
  Returns:
1716
- List of target instances.
1722
+ List of target instances, each with the ``derived`` block
1723
+ populated when the parent line's nav state is available.
1717
1724
  """
1718
- response = self._client.get("/api/v1/targets")
1725
+ response = self._client.get(
1726
+ f"/api/v1/sidescan-ping-sources/{sidescan_ping_source_id}/targets"
1727
+ )
1719
1728
  response.raise_for_status()
1720
1729
  return [Target.model_validate(item) for item in response.json()]
1721
1730
 
1722
1731
  def update_target(self, target_id: UUID | str, target: TargetUpdate) -> Target:
1723
- """Update a target.
1732
+ """Partially update a target.
1733
+
1734
+ Targets MVP is the first endpoint in the API to use PATCH (older
1735
+ update endpoints use PUT for legacy reasons but already behave as
1736
+ PATCH server-side via ``exclude_unset=True``). Body fields are
1737
+ optional; only the supplied fields are mutated.
1724
1738
 
1725
1739
  Args:
1726
1740
  target_id: Target UUID or string identifier.
1727
- target: Target update data.
1741
+ target: Partial target update data.
1728
1742
 
1729
1743
  Returns:
1730
1744
  Updated target instance.
1731
1745
  """
1732
- response = self._client.put(
1746
+ response = self._client.patch(
1733
1747
  f"/api/v1/targets/{target_id}",
1734
1748
  json=target.model_dump(mode="json", exclude_unset=True),
1735
1749
  )
1736
1750
  response.raise_for_status()
1737
1751
  return Target.model_validate(response.json())
1738
1752
 
1753
+ def delete_target(self, target_id: UUID | str) -> None:
1754
+ """Hard-delete a target.
1755
+
1756
+ Args:
1757
+ target_id: Target UUID or string identifier.
1758
+ """
1759
+ response = self._client.delete(f"/api/v1/targets/{target_id}")
1760
+ response.raise_for_status()
1761
+
1739
1762
  def create_tow_system(self, tow_system: TowSystemCreate) -> TowSystem:
1740
1763
  """Create a new tow system.
1741
1764
 
@@ -1753,35 +1753,58 @@ class SonarWizAsyncApi:
1753
1753
  response.raise_for_status()
1754
1754
  return Target.model_validate(response.json())
1755
1755
 
1756
- async def list_targets(self) -> list[Target]:
1757
- """List all targets.
1756
+ async def list_targets(self, sidescan_ping_source_id: UUID | str) -> list[Target]:
1757
+ """List all targets for one sidescan ping source.
1758
+
1759
+ The cross-source ``GET /api/v1/targets`` endpoint is deprecated;
1760
+ the spec scopes target reads to a single SidescanPingSource.
1761
+
1762
+ Args:
1763
+ sidescan_ping_source_id: SidescanPingSource UUID or string identifier.
1758
1764
 
1759
1765
  Returns:
1760
- List of target instances.
1766
+ List of target instances, each with the ``derived`` block
1767
+ populated when the parent line's nav state is available.
1761
1768
  """
1762
- response = await self._client.get("/api/v1/targets")
1769
+ response = await self._client.get(
1770
+ f"/api/v1/sidescan-ping-sources/{sidescan_ping_source_id}/targets"
1771
+ )
1763
1772
  response.raise_for_status()
1764
1773
  return [Target.model_validate(item) for item in response.json()]
1765
1774
 
1766
1775
  async def update_target(
1767
1776
  self, target_id: UUID | str, target: TargetUpdate
1768
1777
  ) -> Target:
1769
- """Update a target.
1778
+ """Partially update a target.
1779
+
1780
+ Targets MVP is the first endpoint in the API to use PATCH (older
1781
+ update endpoints use PUT for legacy reasons but already behave as
1782
+ PATCH server-side via ``exclude_unset=True``). Body fields are
1783
+ optional; only the supplied fields are mutated.
1770
1784
 
1771
1785
  Args:
1772
1786
  target_id: Target UUID or string identifier.
1773
- target: Target update data.
1787
+ target: Partial target update data.
1774
1788
 
1775
1789
  Returns:
1776
1790
  Updated target instance.
1777
1791
  """
1778
- response = await self._client.put(
1792
+ response = await self._client.patch(
1779
1793
  f"/api/v1/targets/{target_id}",
1780
1794
  json=target.model_dump(mode="json", exclude_unset=True),
1781
1795
  )
1782
1796
  response.raise_for_status()
1783
1797
  return Target.model_validate(response.json())
1784
1798
 
1799
+ async def delete_target(self, target_id: UUID | str) -> None:
1800
+ """Hard-delete a target.
1801
+
1802
+ Args:
1803
+ target_id: Target UUID or string identifier.
1804
+ """
1805
+ response = await self._client.delete(f"/api/v1/targets/{target_id}")
1806
+ response.raise_for_status()
1807
+
1785
1808
  async def create_tow_system(self, tow_system: TowSystemCreate) -> TowSystem:
1786
1809
  """Create a new tow system.
1787
1810
 
@@ -369,3 +369,163 @@ async def test_get_raw_file_device_mappings_accepts_string_uuids():
369
369
  "/api/v1/raw-file-device-mappings",
370
370
  params={"raw_file_id": str(RAW_FILE_ID), "device_id": str(DEVICE_ID)},
371
371
  )
372
+
373
+
374
+ # ──────────────────────────────────────────────────────────────────────────
375
+ # Targets MVP — async client method coverage (issue #26)
376
+
377
+
378
+ _TARGET_ID = UUID("00000000-0000-0000-0000-0000000000a1")
379
+ _SPS_ID = UUID("00000000-0000-0000-0000-0000000000a2")
380
+ _USER_ID = UUID("00000000-0000-0000-0000-000000000001")
381
+
382
+
383
+ def _make_target_json(
384
+ *,
385
+ target_id: UUID = _TARGET_ID,
386
+ sidescan_ping_source_id: UUID = _SPS_ID,
387
+ name: str = "[unreviewed] [Unknown Object]",
388
+ derived: dict | None = None,
389
+ ) -> dict:
390
+ """Build a Target JSON dict matching the server's read response shape."""
391
+ return {
392
+ "target_id": str(target_id),
393
+ "sidescan_ping_source_id": str(sidescan_ping_source_id),
394
+ "channel": 0,
395
+ "ping": 100,
396
+ "sample": 250,
397
+ "name": name,
398
+ "description": "",
399
+ "width_samples": None,
400
+ "height_pings": None,
401
+ "shadow_samples": None,
402
+ "derived": derived,
403
+ "created_by_user_id": str(_USER_ID),
404
+ "last_modified_by_user_id": str(_USER_ID),
405
+ "created_at": "2026-05-05T12:00:00Z",
406
+ "updated_at": "2026-05-05T12:00:00Z",
407
+ }
408
+
409
+
410
+ @pytest.mark.asyncio
411
+ async def test_create_target_posts_json_and_parses_target():
412
+ """create_target POSTs sample-address JSON and returns a typed Target."""
413
+ from cti.model.target import TargetCreate
414
+
415
+ client = AsyncMock()
416
+ client.post.return_value = _mock_response(_make_target_json())
417
+
418
+ api = SonarWizAsyncApi(client)
419
+ payload = TargetCreate(
420
+ sidescan_ping_source_id=_SPS_ID,
421
+ channel=0,
422
+ ping=100,
423
+ sample=250,
424
+ )
425
+ result = await api.create_target(payload)
426
+
427
+ client.post.assert_awaited_once()
428
+ url, _ = client.post.call_args[0], client.post.call_args[1]
429
+ assert url[0] == "/api/v1/targets"
430
+ sent = client.post.call_args.kwargs["json"]
431
+ assert sent["sidescan_ping_source_id"] == str(_SPS_ID)
432
+ assert sent["channel"] == 0
433
+ assert sent["ping"] == 100
434
+ assert sent["sample"] == 250
435
+ assert result.target_id == _TARGET_ID
436
+
437
+
438
+ @pytest.mark.asyncio
439
+ async def test_get_target_returns_typed_target_with_derived_block():
440
+ """get_target hits /targets/{id} and surfaces the derived block."""
441
+ derived = {
442
+ "longitude": -76.123,
443
+ "latitude": 38.456,
444
+ "projected_x": 412345.67,
445
+ "projected_y": 4256789.01,
446
+ "srid": "EPSG:32618",
447
+ "width": None,
448
+ "height": None,
449
+ "shadow": None,
450
+ "linear_unit": "metre",
451
+ }
452
+ client = AsyncMock()
453
+ client.get.return_value = _mock_response(_make_target_json(derived=derived))
454
+
455
+ api = SonarWizAsyncApi(client)
456
+ result = await api.get_target(_TARGET_ID)
457
+
458
+ client.get.assert_awaited_once_with(f"/api/v1/targets/{_TARGET_ID}")
459
+ assert result.target_id == _TARGET_ID
460
+ assert result.derived is not None
461
+ assert result.derived.srid == "EPSG:32618"
462
+
463
+
464
+ @pytest.mark.asyncio
465
+ async def test_list_targets_scopes_to_sidescan_ping_source():
466
+ """list_targets hits the per-SPS endpoint, not the deprecated cross-source one."""
467
+ client = AsyncMock()
468
+ client.get.return_value = _mock_response(
469
+ [_make_target_json(target_id=UUID(int=i + 1), name=f"t{i}") for i in range(3)]
470
+ )
471
+
472
+ api = SonarWizAsyncApi(client)
473
+ results = await api.list_targets(_SPS_ID)
474
+
475
+ client.get.assert_awaited_once_with(
476
+ f"/api/v1/sidescan-ping-sources/{_SPS_ID}/targets"
477
+ )
478
+ assert len(results) == 3
479
+ assert {t.name for t in results} == {"t0", "t1", "t2"}
480
+
481
+
482
+ @pytest.mark.asyncio
483
+ async def test_update_target_uses_patch_with_partial_body():
484
+ """update_target uses PATCH and serialises only the fields the caller set."""
485
+ from cti.model.target import TargetUpdate
486
+
487
+ client = AsyncMock()
488
+ client.patch.return_value = _mock_response(_make_target_json(name="renamed"))
489
+
490
+ api = SonarWizAsyncApi(client)
491
+ payload = TargetUpdate(name="renamed")
492
+ result = await api.update_target(_TARGET_ID, payload)
493
+
494
+ client.patch.assert_awaited_once()
495
+ assert client.patch.call_args[0][0] == f"/api/v1/targets/{_TARGET_ID}"
496
+ sent = client.patch.call_args.kwargs["json"]
497
+ # exclude_unset → only the field the caller set ships
498
+ assert sent == {"name": "renamed"}
499
+ assert result.name == "renamed"
500
+
501
+
502
+ @pytest.mark.asyncio
503
+ async def test_delete_target_returns_none_and_calls_delete():
504
+ """delete_target returns None after a successful DELETE."""
505
+ client = AsyncMock()
506
+ delete_resp = MagicMock(spec=httpx.Response)
507
+ delete_resp.status_code = 204
508
+ delete_resp.raise_for_status.return_value = None
509
+ client.delete.return_value = delete_resp
510
+
511
+ api = SonarWizAsyncApi(client)
512
+ result = await api.delete_target(_TARGET_ID)
513
+
514
+ client.delete.assert_awaited_once_with(f"/api/v1/targets/{_TARGET_ID}")
515
+ assert result is None
516
+
517
+
518
+ @pytest.mark.asyncio
519
+ async def test_target_method_errors_propagate_via_raise_for_status():
520
+ """Server errors bubble up as httpx.HTTPStatusError via raise_for_status."""
521
+ client = AsyncMock()
522
+ err_resp = MagicMock(spec=httpx.Response)
523
+ err_resp.status_code = 404
524
+ err_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
525
+ "Not found", request=MagicMock(), response=err_resp
526
+ )
527
+ client.get.return_value = err_resp
528
+
529
+ api = SonarWizAsyncApi(client)
530
+ with pytest.raises(httpx.HTTPStatusError):
531
+ await api.get_target(_TARGET_ID)
@@ -385,3 +385,153 @@ def test_get_raw_file_device_mappings_accepts_string_uuids():
385
385
  "/api/v1/raw-file-device-mappings",
386
386
  params={"raw_file_id": str(RAW_FILE_ID), "device_id": str(DEVICE_ID)},
387
387
  )
388
+
389
+
390
+ # ──────────────────────────────────────────────────────────────────────────
391
+ # Targets MVP — sync client method coverage (issue #26 sync mirror)
392
+
393
+
394
+ _TARGET_ID = UUID("00000000-0000-0000-0000-0000000000a1")
395
+ _SPS_ID = UUID("00000000-0000-0000-0000-0000000000a2")
396
+ _USER_ID = UUID("00000000-0000-0000-0000-000000000001")
397
+
398
+
399
+ def _make_target_json(
400
+ *,
401
+ target_id: UUID = _TARGET_ID,
402
+ sidescan_ping_source_id: UUID = _SPS_ID,
403
+ name: str = "[unreviewed] [Unknown Object]",
404
+ derived: dict | None = None,
405
+ ) -> dict:
406
+ """Build a Target JSON dict matching the server's read response shape."""
407
+ return {
408
+ "target_id": str(target_id),
409
+ "sidescan_ping_source_id": str(sidescan_ping_source_id),
410
+ "channel": 0,
411
+ "ping": 100,
412
+ "sample": 250,
413
+ "name": name,
414
+ "description": "",
415
+ "width_samples": None,
416
+ "height_pings": None,
417
+ "shadow_samples": None,
418
+ "derived": derived,
419
+ "created_by_user_id": str(_USER_ID),
420
+ "last_modified_by_user_id": str(_USER_ID),
421
+ "created_at": "2026-05-05T12:00:00Z",
422
+ "updated_at": "2026-05-05T12:00:00Z",
423
+ }
424
+
425
+
426
+ def test_create_target_posts_json_and_parses_target():
427
+ """create_target POSTs sample-address JSON and returns a typed Target."""
428
+ from cti.model.target import TargetCreate
429
+
430
+ client = MagicMock()
431
+ client.post.return_value = _mock_response(_make_target_json())
432
+
433
+ api = SonarWizApi(client)
434
+ payload = TargetCreate(
435
+ sidescan_ping_source_id=_SPS_ID,
436
+ channel=0,
437
+ ping=100,
438
+ sample=250,
439
+ )
440
+ result = api.create_target(payload)
441
+
442
+ client.post.assert_called_once()
443
+ assert client.post.call_args[0][0] == "/api/v1/targets"
444
+ sent = client.post.call_args.kwargs["json"]
445
+ assert sent["sidescan_ping_source_id"] == str(_SPS_ID)
446
+ assert sent["ping"] == 100
447
+ assert result.target_id == _TARGET_ID
448
+
449
+
450
+ def test_get_target_returns_typed_target_with_derived_block():
451
+ """get_target hits /targets/{id} and surfaces the derived block."""
452
+ derived = {
453
+ "longitude": -76.123,
454
+ "latitude": 38.456,
455
+ "projected_x": 412345.67,
456
+ "projected_y": 4256789.01,
457
+ "srid": "EPSG:32618",
458
+ "width": None,
459
+ "height": None,
460
+ "shadow": None,
461
+ "linear_unit": "metre",
462
+ }
463
+ client = MagicMock()
464
+ client.get.return_value = _mock_response(_make_target_json(derived=derived))
465
+
466
+ api = SonarWizApi(client)
467
+ result = api.get_target(_TARGET_ID)
468
+
469
+ client.get.assert_called_once_with(f"/api/v1/targets/{_TARGET_ID}")
470
+ assert result.target_id == _TARGET_ID
471
+ assert result.derived is not None
472
+ assert result.derived.srid == "EPSG:32618"
473
+
474
+
475
+ def test_list_targets_scopes_to_sidescan_ping_source():
476
+ """list_targets hits the per-SPS endpoint, not the deprecated cross-source one."""
477
+ client = MagicMock()
478
+ client.get.return_value = _mock_response(
479
+ [_make_target_json(target_id=UUID(int=i + 1), name=f"t{i}") for i in range(3)]
480
+ )
481
+
482
+ api = SonarWizApi(client)
483
+ results = api.list_targets(_SPS_ID)
484
+
485
+ client.get.assert_called_once_with(
486
+ f"/api/v1/sidescan-ping-sources/{_SPS_ID}/targets"
487
+ )
488
+ assert len(results) == 3
489
+ assert {t.name for t in results} == {"t0", "t1", "t2"}
490
+
491
+
492
+ def test_update_target_uses_patch_with_partial_body():
493
+ """update_target uses PATCH and serialises only the fields the caller set."""
494
+ from cti.model.target import TargetUpdate
495
+
496
+ client = MagicMock()
497
+ client.patch.return_value = _mock_response(_make_target_json(name="renamed"))
498
+
499
+ api = SonarWizApi(client)
500
+ payload = TargetUpdate(name="renamed")
501
+ result = api.update_target(_TARGET_ID, payload)
502
+
503
+ client.patch.assert_called_once()
504
+ assert client.patch.call_args[0][0] == f"/api/v1/targets/{_TARGET_ID}"
505
+ sent = client.patch.call_args.kwargs["json"]
506
+ assert sent == {"name": "renamed"}
507
+ assert result.name == "renamed"
508
+
509
+
510
+ def test_delete_target_returns_none_and_calls_delete():
511
+ """delete_target returns None after a successful DELETE."""
512
+ client = MagicMock()
513
+ delete_resp = MagicMock(spec=httpx.Response)
514
+ delete_resp.status_code = 204
515
+ delete_resp.raise_for_status.return_value = None
516
+ client.delete.return_value = delete_resp
517
+
518
+ api = SonarWizApi(client)
519
+ result = api.delete_target(_TARGET_ID)
520
+
521
+ client.delete.assert_called_once_with(f"/api/v1/targets/{_TARGET_ID}")
522
+ assert result is None
523
+
524
+
525
+ def test_target_method_errors_propagate_via_raise_for_status():
526
+ """Server errors bubble up as httpx.HTTPStatusError via raise_for_status."""
527
+ client = MagicMock()
528
+ err_resp = MagicMock(spec=httpx.Response)
529
+ err_resp.status_code = 404
530
+ err_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
531
+ "Not found", request=MagicMock(), response=err_resp
532
+ )
533
+ client.get.return_value = err_resp
534
+
535
+ api = SonarWizApi(client)
536
+ with pytest.raises(httpx.HTTPStatusError):
537
+ api.get_target(_TARGET_ID)