clarity-api-sdk-python 0.4.2__tar.gz → 0.4.4__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.
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/PKG-INFO +1 -1
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/pyproject.toml +1 -1
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/PKG-INFO +1 -1
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/SOURCES.txt +1 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/api/sonar_wiz_api.py +30 -7
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/api/sonar_wiz_async_api.py +51 -7
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/cli/main.py +288 -60
- clarity_api_sdk_python-0.4.4/src/cti/events/__init__.py +152 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/tests/test_sdk_async_methods.py +160 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/tests/test_sdk_methods.py +150 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/README.md +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/setup.cfg +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/dependency_links.txt +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/entry_points.txt +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/requires.txt +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/clarity_api_sdk_python.egg-info/top_level.txt +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/__init__.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/api/__init__.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/api/async_client.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/api/client.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/api/session.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/cli/__init__.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/cli/__main__.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/cli/client.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/logger/__init__.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/logger/logger.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/main.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/main_api.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/__init__.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/altitude_source.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/attitude_source.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/deferred_object_deletion.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/depth_source.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/device.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/device_type.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/final_product.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/hierarchy.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/layback_algorithm.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/layback_source.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/layback_type.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/organization.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/platform.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/platform_type.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/position_source.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/processing_job.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/processing_log.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/project.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/projection_option.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/raw_file.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/raw_file_configuration.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/raw_file_device_mapping.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/raw_file_state.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/s3.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/sidescan_ping_source.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/source.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/survey.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/target.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/tow_system.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/user_layer.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/positioning/__init__.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/positioning/target_geometry.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/tests/test_cli.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/tests/test_raw_file_device_mapping_model.py +0 -0
- {clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/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.
|
|
3
|
+
Version: 0.4.4
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clarity-api-sdk-python
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
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(
|
|
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
|
-
"""
|
|
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:
|
|
1741
|
+
target: Partial target update data.
|
|
1728
1742
|
|
|
1729
1743
|
Returns:
|
|
1730
1744
|
Updated target instance.
|
|
1731
1745
|
"""
|
|
1732
|
-
response = self._client.
|
|
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
|
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/api/sonar_wiz_async_api.py
RENAMED
|
@@ -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(
|
|
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
|
-
"""
|
|
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:
|
|
1787
|
+
target: Partial target update data.
|
|
1774
1788
|
|
|
1775
1789
|
Returns:
|
|
1776
1790
|
Updated target instance.
|
|
1777
1791
|
"""
|
|
1778
|
-
response = await self._client.
|
|
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
|
|
|
@@ -1879,6 +1902,27 @@ class SonarWizAsyncApi:
|
|
|
1879
1902
|
response.raise_for_status()
|
|
1880
1903
|
return [FinalProduct.model_validate(item) for item in response.json()]
|
|
1881
1904
|
|
|
1905
|
+
async def delete_final_product(self, final_product_id: UUID | str) -> None:
|
|
1906
|
+
"""Hard-delete a final product row.
|
|
1907
|
+
|
|
1908
|
+
Used by ``xs_pipeline`` to clear superseded ``FinalProduct`` rows
|
|
1909
|
+
for a (survey, product_type) before inserting the fresh one — so
|
|
1910
|
+
the latest run is the only product the client UI auto-picks.
|
|
1911
|
+
S3 artifacts at the row's ``file_path`` are not touched here;
|
|
1912
|
+
callers that need full cleanup should remove the S3 prefix
|
|
1913
|
+
separately.
|
|
1914
|
+
|
|
1915
|
+
Args:
|
|
1916
|
+
final_product_id: Final product UUID or string identifier.
|
|
1917
|
+
|
|
1918
|
+
Raises:
|
|
1919
|
+
httpx.HTTPStatusError: 404 if the product was already gone.
|
|
1920
|
+
"""
|
|
1921
|
+
response = await self._client.delete(
|
|
1922
|
+
f"/api/v1/final-products/{final_product_id}"
|
|
1923
|
+
)
|
|
1924
|
+
response.raise_for_status()
|
|
1925
|
+
|
|
1882
1926
|
async def update_final_product(
|
|
1883
1927
|
self, final_product_id: UUID | str, final_product: FinalProductUpdate
|
|
1884
1928
|
) -> FinalProduct:
|
|
@@ -113,67 +113,20 @@ def upload(
|
|
|
113
113
|
f"Uploaded: {result.raw_file_id} ({result.file_name}, {result.file_size} bytes)"
|
|
114
114
|
)
|
|
115
115
|
|
|
116
|
-
#
|
|
116
|
+
# Stream-to-device mapping is handled by ``ftm automap`` after upload.
|
|
117
|
+
# Auto-mapping at upload time was guessing stream URIs (e.g.
|
|
118
|
+
# ``xtf://sidescan(source=ping_header,pair=0)``) that don't match what
|
|
119
|
+
# the engine's scan phase actually emits in the manifest, so the
|
|
120
|
+
# rows it created weren't usable downstream. Run ``ftm automap`` after
|
|
121
|
+
# upload — it reads the actual scan manifest and replicates the UI's
|
|
122
|
+
# "Generate auto-platform" + "Auto-mapping" buttons.
|
|
117
123
|
if not no_mappings:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
dt_list = api.list_device_types()
|
|
125
|
-
dt_by_id = {dt.device_type_id: dt for dt in dt_list}
|
|
126
|
-
|
|
127
|
-
# Get survey timezone for mapping
|
|
128
|
-
survey_obj = api.get_survey(survey)
|
|
129
|
-
tz = survey_obj.timezone_name
|
|
130
|
-
|
|
131
|
-
mappings_created = 0
|
|
132
|
-
for device in devices:
|
|
133
|
-
dt = dt_by_id.get(device.device_type_id)
|
|
134
|
-
if not dt:
|
|
135
|
-
continue
|
|
136
|
-
if dt.enum_name == "sidescan_sonar":
|
|
137
|
-
stream_id = _identifier_from_uri(
|
|
138
|
-
sidescan_stream, ("samples", "timestamp")
|
|
139
|
-
)
|
|
140
|
-
elif dt.enum_name == "gnss_gps_position_sensor":
|
|
141
|
-
stream_id = _identifier_from_uri(
|
|
142
|
-
gnss_stream, ("position", "timestamp")
|
|
143
|
-
)
|
|
144
|
-
else:
|
|
145
|
-
continue
|
|
146
|
-
|
|
147
|
-
api.create_raw_file_device_mapping(
|
|
148
|
-
RawFileDeviceMappingCreate(
|
|
149
|
-
raw_file_id=result.raw_file_id,
|
|
150
|
-
device_id=device.device_id,
|
|
151
|
-
source_stream_identifier=stream_id,
|
|
152
|
-
input_srid=4326,
|
|
153
|
-
input_timezone=tz,
|
|
154
|
-
)
|
|
155
|
-
)
|
|
156
|
-
typer.echo(
|
|
157
|
-
f"Mapped: {device.name} → stream {stream_id.source.selected}"
|
|
158
|
-
)
|
|
159
|
-
mappings_created += 1
|
|
160
|
-
|
|
161
|
-
if mappings_created == 0:
|
|
162
|
-
typer.echo(
|
|
163
|
-
"Warning: no sidescan/GNSS devices found on survey platform — no mappings created",
|
|
164
|
-
err=True,
|
|
165
|
-
)
|
|
166
|
-
except httpx.HTTPStatusError as e:
|
|
167
|
-
if e.response.status_code == 404:
|
|
168
|
-
typer.echo(
|
|
169
|
-
"Warning: no platform found on survey — skipping device mappings. Run 'ftm init' first.",
|
|
170
|
-
err=True,
|
|
171
|
-
)
|
|
172
|
-
else:
|
|
173
|
-
typer.echo(
|
|
174
|
-
f"Warning: failed to create device mappings: {_get_error_detail(e)}",
|
|
175
|
-
err=True,
|
|
176
|
-
)
|
|
124
|
+
typer.echo(
|
|
125
|
+
"Note: per-stream device mappings are no longer created at upload time. "
|
|
126
|
+
"Run `ftm automap <survey>` after upload (waits for prescan, then mirrors "
|
|
127
|
+
"the UI's Auto-platform + Auto-mapping buttons).",
|
|
128
|
+
err=True,
|
|
129
|
+
)
|
|
177
130
|
|
|
178
131
|
_output(
|
|
179
132
|
{
|
|
@@ -190,6 +143,281 @@ def upload(
|
|
|
190
143
|
)
|
|
191
144
|
|
|
192
145
|
|
|
146
|
+
# =============================================================================
|
|
147
|
+
# Stream type → seeded DeviceType.enum_name mapping.
|
|
148
|
+
# Mirrors clarity-client/src/lib/auto-platform/stream-device-mapping.ts —
|
|
149
|
+
# keep in sync with that table. Multiple stream types may collapse to
|
|
150
|
+
# the same device type (geographic + projected nav both → GNSS).
|
|
151
|
+
# =============================================================================
|
|
152
|
+
_STREAM_TO_DEVICE_ENUM: dict[str, str] = {
|
|
153
|
+
"sidescan": "sidescan_sonar",
|
|
154
|
+
"geographic_navigation": "gnss_gps_position_sensor",
|
|
155
|
+
"projected_navigation": "gnss_gps_position_sensor",
|
|
156
|
+
"attitude": "imu",
|
|
157
|
+
"altitude": "single_beam_echosounder_sbes",
|
|
158
|
+
"depth": "depth_sensor",
|
|
159
|
+
"cable_counter": "sheave_block",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def automap(
|
|
165
|
+
survey: UUID = typer.Argument(..., help="Survey ID to map streams for"),
|
|
166
|
+
timeout: float = typer.Option(
|
|
167
|
+
180.0,
|
|
168
|
+
"--timeout",
|
|
169
|
+
help="Seconds to wait for prescan to finish across all raw files.",
|
|
170
|
+
),
|
|
171
|
+
output_json: bool = typer.Option(False, "--json", "-j"),
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Auto-platform + auto-map streams for every raw file in a survey.
|
|
174
|
+
|
|
175
|
+
Mirrors the clarity-client UI flow operators run after upload:
|
|
176
|
+
|
|
177
|
+
1. Wait for the prescan phase to complete on every raw file
|
|
178
|
+
(the engine writes the parsed stream manifest to
|
|
179
|
+
``ProcessingLog.result`` for each file as soon as scan finishes).
|
|
180
|
+
2. Discover the per-file stream manifests, derive the auto-platform
|
|
181
|
+
spec from them, and create the platform + one device per stream
|
|
182
|
+
type — equivalent to clicking "Generate auto-platform" on the
|
|
183
|
+
Platform page.
|
|
184
|
+
3. For every (raw_file, stream) in the manifests, find the matching
|
|
185
|
+
device on the auto-platform (by ``DeviceType.enum_name``) and
|
|
186
|
+
POST a ``RawFileDeviceMapping`` carrying the scan manifest's
|
|
187
|
+
exact ``source`` + ``fields_by_source`` shapes — equivalent to
|
|
188
|
+
clicking "Auto-mapping" on the stream-mapping page.
|
|
189
|
+
|
|
190
|
+
Idempotent: existing platforms / devices / mappings are reused; the
|
|
191
|
+
server's unique-constraint dedup matches the planner's "first wins"
|
|
192
|
+
rule on ``(raw_file, device)``.
|
|
193
|
+
"""
|
|
194
|
+
import time
|
|
195
|
+
from cti.model.device import DeviceCreate
|
|
196
|
+
from cti.model.platform import PlatformCreate
|
|
197
|
+
from cti.model.raw_file_device_mapping import (
|
|
198
|
+
RawFileDeviceMappingCreate,
|
|
199
|
+
SourceSelection,
|
|
200
|
+
StreamIdentifier,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
api = get_api()
|
|
204
|
+
|
|
205
|
+
survey_obj = api.get_survey(survey)
|
|
206
|
+
raw_files = api.get_raw_files(survey)
|
|
207
|
+
if not raw_files:
|
|
208
|
+
typer.echo(f"Error: survey {survey} has no raw files; upload first", err=True)
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
|
|
211
|
+
typer.echo(
|
|
212
|
+
f"Survey {survey_obj.name}: {len(raw_files)} raw file(s); "
|
|
213
|
+
f"waiting up to {timeout:.0f}s for prescan to finish..."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# ── 1. Wait for prescan ─────────────────────────────────────────────
|
|
217
|
+
deadline = time.monotonic() + timeout
|
|
218
|
+
manifests: dict[UUID, dict] = {}
|
|
219
|
+
pending = {rf.raw_file_id for rf in raw_files}
|
|
220
|
+
while pending and time.monotonic() < deadline:
|
|
221
|
+
for raw_file_id in list(pending):
|
|
222
|
+
logs = api.list_processing_logs(
|
|
223
|
+
raw_file_id=raw_file_id, processing_step="scan"
|
|
224
|
+
)
|
|
225
|
+
done = next(
|
|
226
|
+
(
|
|
227
|
+
l for l in logs
|
|
228
|
+
if l.status == "complete" and l.result
|
|
229
|
+
),
|
|
230
|
+
None,
|
|
231
|
+
)
|
|
232
|
+
if done:
|
|
233
|
+
try:
|
|
234
|
+
manifests[raw_file_id] = json.loads(done.result)
|
|
235
|
+
except (ValueError, TypeError) as exc:
|
|
236
|
+
typer.echo(
|
|
237
|
+
f"Error: scan manifest for {raw_file_id} is not valid JSON: {exc}",
|
|
238
|
+
err=True,
|
|
239
|
+
)
|
|
240
|
+
raise typer.Exit(1)
|
|
241
|
+
pending.discard(raw_file_id)
|
|
242
|
+
if pending:
|
|
243
|
+
time.sleep(2.0)
|
|
244
|
+
|
|
245
|
+
if pending:
|
|
246
|
+
typer.echo(
|
|
247
|
+
f"Error: prescan still pending for {len(pending)} file(s) after {timeout:.0f}s",
|
|
248
|
+
err=True,
|
|
249
|
+
)
|
|
250
|
+
raise typer.Exit(1)
|
|
251
|
+
|
|
252
|
+
typer.echo(f"Prescan complete: {len(manifests)} manifest(s) loaded")
|
|
253
|
+
|
|
254
|
+
# ── 2. Generate auto-platform (clicks "Generate auto-platform") ─────
|
|
255
|
+
file_format = next(
|
|
256
|
+
(m.get("file_format", "").upper() for m in manifests.values() if m.get("file_format")),
|
|
257
|
+
"MIXED",
|
|
258
|
+
)
|
|
259
|
+
auto_name = f"Auto-{file_format}"
|
|
260
|
+
|
|
261
|
+
existing_platforms = [
|
|
262
|
+
p for p in api.list_platforms() if p.survey_id == survey
|
|
263
|
+
]
|
|
264
|
+
auto_platform = next((p for p in existing_platforms if p.name == auto_name), None)
|
|
265
|
+
|
|
266
|
+
device_types = api.list_device_types()
|
|
267
|
+
dt_by_enum = {dt.enum_name: dt for dt in device_types}
|
|
268
|
+
|
|
269
|
+
if auto_platform is None:
|
|
270
|
+
platform_types = api.list_platform_types()
|
|
271
|
+
if not platform_types:
|
|
272
|
+
typer.echo("Error: no PlatformType seeded; cannot create auto-platform", err=True)
|
|
273
|
+
raise typer.Exit(1)
|
|
274
|
+
auto_platform = api.create_platform(
|
|
275
|
+
PlatformCreate(
|
|
276
|
+
survey_id=survey,
|
|
277
|
+
platform_type_id=platform_types[0].platform_type_id,
|
|
278
|
+
name=auto_name,
|
|
279
|
+
description=f"Auto-generated for {file_format} files",
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
typer.echo(f"Created auto-platform: {auto_name} ({auto_platform.platform_id})")
|
|
283
|
+
else:
|
|
284
|
+
typer.echo(f"Reusing auto-platform: {auto_name} ({auto_platform.platform_id})")
|
|
285
|
+
|
|
286
|
+
# Aggregate which DeviceType enums we need across all manifests.
|
|
287
|
+
needed_enums: set[str] = set()
|
|
288
|
+
for manifest in manifests.values():
|
|
289
|
+
for stream in manifest.get("streams", []) or []:
|
|
290
|
+
stype = stream.get("stream_type")
|
|
291
|
+
target_enum = _STREAM_TO_DEVICE_ENUM.get(stype) if stype else None
|
|
292
|
+
if target_enum and target_enum in dt_by_enum:
|
|
293
|
+
needed_enums.add(target_enum)
|
|
294
|
+
|
|
295
|
+
# Existing devices on the auto-platform — first-wins per stream type.
|
|
296
|
+
existing_devices = api.get_platform_devices(platform_id=auto_platform.platform_id)
|
|
297
|
+
device_by_enum: dict[str, "Device"] = {} # type: ignore[name-defined]
|
|
298
|
+
for d in existing_devices:
|
|
299
|
+
dt = next((t for t in device_types if t.device_type_id == d.device_type_id), None)
|
|
300
|
+
if dt and dt.enum_name not in device_by_enum:
|
|
301
|
+
device_by_enum[dt.enum_name] = d
|
|
302
|
+
|
|
303
|
+
# Create devices for any missing enum.
|
|
304
|
+
created_devices = 0
|
|
305
|
+
for enum_name in sorted(needed_enums):
|
|
306
|
+
if enum_name in device_by_enum:
|
|
307
|
+
continue
|
|
308
|
+
dt = dt_by_enum[enum_name]
|
|
309
|
+
# Defaults match the JS auto-platform planner
|
|
310
|
+
# (clarity-client/src/lib/auto-platform/plan.ts): all offsets
|
|
311
|
+
# zero, channel 1, is_towed=false. Refinement is deferred —
|
|
312
|
+
# operators can edit these in the UI after auto-platform creation.
|
|
313
|
+
new_device = api.create_device(
|
|
314
|
+
DeviceCreate(
|
|
315
|
+
platform_id=auto_platform.platform_id,
|
|
316
|
+
device_type_id=dt.device_type_id,
|
|
317
|
+
name=dt.name,
|
|
318
|
+
description=f"Auto-generated for {file_format} {enum_name} stream",
|
|
319
|
+
channel=1,
|
|
320
|
+
offset_x=0.0,
|
|
321
|
+
offset_y=0.0,
|
|
322
|
+
offset_z=0.0,
|
|
323
|
+
offset_heading=0.0,
|
|
324
|
+
offset_pitch=0.0,
|
|
325
|
+
offset_roll=0.0,
|
|
326
|
+
latency=0.0,
|
|
327
|
+
is_towed=False,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
device_by_enum[enum_name] = new_device
|
|
331
|
+
created_devices += 1
|
|
332
|
+
typer.echo(f" + device: {dt.name} ({enum_name})")
|
|
333
|
+
|
|
334
|
+
typer.echo(
|
|
335
|
+
f"Auto-platform devices: {len(device_by_enum)} total ({created_devices} new)"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# ── 3. Auto-map streams (clicks "Auto-mapping") ─────────────────────
|
|
339
|
+
tz = survey_obj.timezone_name
|
|
340
|
+
mappings_created = 0
|
|
341
|
+
mappings_skipped = 0
|
|
342
|
+
for raw_file_id, manifest in manifests.items():
|
|
343
|
+
# Existing mappings for this file — dedup by device_id (matches
|
|
344
|
+
# server's unique constraint on (raw_file, device)).
|
|
345
|
+
existing = api.get_raw_file_device_mappings(raw_file_id=raw_file_id)
|
|
346
|
+
existing_device_ids = {m.device_id for m in existing}
|
|
347
|
+
used_device_ids: set = set()
|
|
348
|
+
|
|
349
|
+
for stream in manifest.get("streams", []) or []:
|
|
350
|
+
stype = stream.get("stream_type")
|
|
351
|
+
target_enum = _STREAM_TO_DEVICE_ENUM.get(stype) if stype else None
|
|
352
|
+
if not target_enum:
|
|
353
|
+
continue
|
|
354
|
+
device = device_by_enum.get(target_enum)
|
|
355
|
+
if device is None:
|
|
356
|
+
continue
|
|
357
|
+
if device.device_id in existing_device_ids or device.device_id in used_device_ids:
|
|
358
|
+
mappings_skipped += 1
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
# Build StreamIdentifier from manifest's source + fields_by_source.
|
|
362
|
+
src = stream.get("source") or {}
|
|
363
|
+
uri = src.get("selected") if isinstance(src, dict) else None
|
|
364
|
+
if not uri:
|
|
365
|
+
continue
|
|
366
|
+
available = src.get("available", [uri]) if isinstance(src, dict) else [uri]
|
|
367
|
+
fields_by_source_raw = stream.get("fields_by_source") or {}
|
|
368
|
+
fields_by_source: dict = {}
|
|
369
|
+
for fs_uri, roles in fields_by_source_raw.items():
|
|
370
|
+
role_dict: dict = {}
|
|
371
|
+
for role_name, role_val in (roles or {}).items():
|
|
372
|
+
if isinstance(role_val, dict):
|
|
373
|
+
ravail = role_val.get("available", [role_name])
|
|
374
|
+
rsel = role_val.get("selected", role_name)
|
|
375
|
+
else:
|
|
376
|
+
ravail = [str(role_val)]
|
|
377
|
+
rsel = str(role_val)
|
|
378
|
+
role_dict[role_name] = SourceSelection(
|
|
379
|
+
available=list(ravail), selected=rsel
|
|
380
|
+
)
|
|
381
|
+
fields_by_source[fs_uri] = role_dict
|
|
382
|
+
|
|
383
|
+
stream_id = StreamIdentifier(
|
|
384
|
+
source=SourceSelection(available=list(available), selected=uri),
|
|
385
|
+
fields_by_source=fields_by_source,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
api.create_raw_file_device_mapping(
|
|
389
|
+
RawFileDeviceMappingCreate(
|
|
390
|
+
raw_file_id=raw_file_id,
|
|
391
|
+
device_id=device.device_id,
|
|
392
|
+
source_stream_identifier=stream_id,
|
|
393
|
+
input_srid=4326,
|
|
394
|
+
input_timezone=tz,
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
used_device_ids.add(device.device_id)
|
|
398
|
+
mappings_created += 1
|
|
399
|
+
typer.echo(
|
|
400
|
+
f" + mapping: {raw_file_id} {stype} → {device.name}"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
typer.echo(
|
|
404
|
+
f"Done. {mappings_created} mapping(s) created, {mappings_skipped} skipped (already existed)."
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
_output(
|
|
408
|
+
{
|
|
409
|
+
"platform_id": str(auto_platform.platform_id),
|
|
410
|
+
"platform_name": auto_name,
|
|
411
|
+
"devices_total": len(device_by_enum),
|
|
412
|
+
"devices_created": created_devices,
|
|
413
|
+
"mappings_created": mappings_created,
|
|
414
|
+
"mappings_skipped": mappings_skipped,
|
|
415
|
+
"files_processed": len(manifests),
|
|
416
|
+
},
|
|
417
|
+
output_json,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
193
421
|
@app.command()
|
|
194
422
|
def process(
|
|
195
423
|
survey: UUID = typer.Argument(..., help="Survey ID to process"),
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Typed event classes for structured logging.
|
|
2
|
+
|
|
3
|
+
Each class is the canonical definition of a log event the dashboard or
|
|
4
|
+
QA harness programs against. Construct an instance and ``emit()`` it
|
|
5
|
+
through any ``cti.logger``-compatible logger::
|
|
6
|
+
|
|
7
|
+
from cti.events import TaskCompleted, emit
|
|
8
|
+
|
|
9
|
+
emit(
|
|
10
|
+
TaskCompleted(
|
|
11
|
+
job_id=jid,
|
|
12
|
+
phase="ingest",
|
|
13
|
+
task_type="ingest",
|
|
14
|
+
worker_pid=os.getpid(),
|
|
15
|
+
duration_sec=elapsed,
|
|
16
|
+
bytes_processed=output.bytes,
|
|
17
|
+
pings_processed=output.pings,
|
|
18
|
+
),
|
|
19
|
+
logger,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
The dataclass IS the contract — pyright catches missing or wrong-typed
|
|
23
|
+
fields at the call site, and ``from cti.events import <Tab>`` discovers
|
|
24
|
+
every defined event. The string carried in ``event_name`` (a
|
|
25
|
+
``ClassVar``, so excluded from ``asdict()``) is what shows up in the log
|
|
26
|
+
stream — kept snake-case so existing dashboard log-search prefixes
|
|
27
|
+
(``task.``, ``module.``) still match.
|
|
28
|
+
|
|
29
|
+
The set is small on purpose: only events something programs against
|
|
30
|
+
structurally. Free-form ``logger.info("xs_pipeline_v2_uploaded",
|
|
31
|
+
extra={...})`` remains the right shape for diagnostic chatter that
|
|
32
|
+
nothing consumes by name.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from dataclasses import asdict, dataclass
|
|
36
|
+
from typing import ClassVar
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def emit(event, logger) -> None:
|
|
40
|
+
"""Emit a typed event through the given structlog-style logger.
|
|
41
|
+
|
|
42
|
+
Looks up the per-class ``level`` (info / error / warning / debug)
|
|
43
|
+
and calls the matching method, so failure events stay at ERROR and
|
|
44
|
+
pass through level filters the dashboard depends on.
|
|
45
|
+
"""
|
|
46
|
+
getattr(logger, event.level)(event.event_name, extra=asdict(event))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class TaskStarted:
|
|
51
|
+
"""A worker task has begun executing inside a phase's pool."""
|
|
52
|
+
|
|
53
|
+
event_name: ClassVar[str] = "task.started"
|
|
54
|
+
level: ClassVar[str] = "info"
|
|
55
|
+
|
|
56
|
+
job_id: str
|
|
57
|
+
phase: str
|
|
58
|
+
task_type: str
|
|
59
|
+
worker_pid: int
|
|
60
|
+
raw_file_id: str | None = None
|
|
61
|
+
file_name: str | None = None
|
|
62
|
+
file_size_bytes: int | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class TaskCompleted:
|
|
67
|
+
"""A worker task finished successfully."""
|
|
68
|
+
|
|
69
|
+
event_name: ClassVar[str] = "task.completed"
|
|
70
|
+
level: ClassVar[str] = "info"
|
|
71
|
+
|
|
72
|
+
job_id: str
|
|
73
|
+
phase: str
|
|
74
|
+
task_type: str
|
|
75
|
+
worker_pid: int
|
|
76
|
+
duration_sec: float
|
|
77
|
+
raw_file_id: str | None = None
|
|
78
|
+
file_name: str | None = None
|
|
79
|
+
file_size_bytes: int | None = None
|
|
80
|
+
bytes_processed: int | None = None
|
|
81
|
+
pings_processed: int | None = None
|
|
82
|
+
batch_size: int | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class TaskFailed:
|
|
87
|
+
"""A worker task raised an exception. Re-raised after emit."""
|
|
88
|
+
|
|
89
|
+
event_name: ClassVar[str] = "task.failed"
|
|
90
|
+
level: ClassVar[str] = "error"
|
|
91
|
+
|
|
92
|
+
job_id: str
|
|
93
|
+
phase: str
|
|
94
|
+
task_type: str
|
|
95
|
+
worker_pid: int
|
|
96
|
+
duration_sec: float
|
|
97
|
+
error: str
|
|
98
|
+
traceback: str
|
|
99
|
+
raw_file_id: str | None = None
|
|
100
|
+
file_name: str | None = None
|
|
101
|
+
file_size_bytes: int | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class ModuleStarted:
|
|
106
|
+
"""A pipeline module began executing on the orchestrator thread."""
|
|
107
|
+
|
|
108
|
+
event_name: ClassVar[str] = "module.started"
|
|
109
|
+
level: ClassVar[str] = "info"
|
|
110
|
+
|
|
111
|
+
module: str
|
|
112
|
+
phase: str
|
|
113
|
+
job_id: str | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class ModuleCompleted:
|
|
118
|
+
"""A pipeline module completed successfully."""
|
|
119
|
+
|
|
120
|
+
event_name: ClassVar[str] = "module.completed"
|
|
121
|
+
level: ClassVar[str] = "info"
|
|
122
|
+
|
|
123
|
+
module: str
|
|
124
|
+
phase: str
|
|
125
|
+
duration_sec: float
|
|
126
|
+
job_id: str | None = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class ModuleFailed:
|
|
131
|
+
"""A pipeline module raised an exception. Re-raised after emit."""
|
|
132
|
+
|
|
133
|
+
event_name: ClassVar[str] = "module.failed"
|
|
134
|
+
level: ClassVar[str] = "error"
|
|
135
|
+
|
|
136
|
+
module: str
|
|
137
|
+
phase: str
|
|
138
|
+
duration_sec: float
|
|
139
|
+
error: str
|
|
140
|
+
traceback: str
|
|
141
|
+
job_id: str | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = [
|
|
145
|
+
"ModuleCompleted",
|
|
146
|
+
"ModuleFailed",
|
|
147
|
+
"ModuleStarted",
|
|
148
|
+
"TaskCompleted",
|
|
149
|
+
"TaskFailed",
|
|
150
|
+
"TaskStarted",
|
|
151
|
+
"emit",
|
|
152
|
+
]
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/tests/test_sdk_async_methods.py
RENAMED
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/altitude_source.py
RENAMED
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/attitude_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/final_product.py
RENAMED
|
File without changes
|
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/layback_algorithm.py
RENAMED
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/layback_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/platform_type.py
RENAMED
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/position_source.py
RENAMED
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/processing_job.py
RENAMED
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/processing_log.py
RENAMED
|
File without changes
|
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/projection_option.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/raw_file_state.py
RENAMED
|
File without changes
|
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/model/sidescan_ping_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/positioning/__init__.py
RENAMED
|
File without changes
|
{clarity_api_sdk_python-0.4.2 → clarity_api_sdk_python-0.4.4}/src/cti/positioning/target_geometry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|