From 4c7eee27b381cfbfab2c668d4b400871b213a803 Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Mon, 10 Aug 2026 16:30:37 +0000 Subject: [PATCH 01/13] Improve SDK pagination and contract clarity --- README.md | 16 +- src/kanopy/client.py | 40 +++- tests/fixtures/openapi.public.json | 362 ++++++++++++++++++++++++++++- tests/test_client.py | 43 ++++ 4 files changed, 453 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f43c7ff..e1749a1 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,10 @@ outputs. Each entry has a stable `id`, a `kind`, a `format`, and a `version` token that only changes when the bytes change, so a synchronizing client can skip work it has already done: +`size_bytes` is populated for stored files and is `None` for tables, camera +poses, and packages rendered on demand; downloading those assets is the first +time their final byte size is known. + ```python for output in kanopy.list_job_outputs(job_id): if output["kind"] != "merged_point_cloud": @@ -202,8 +206,16 @@ re-authentication. ## Pagination -List methods return a `Page`. Offset pagination is used by default. Pass -`cursor=""` to start keyset pagination, then use `page.next_cursor`: +List methods return a `Page`. Offset pagination is used by default. For a full +scan, the iterator helpers handle keyset cursors automatically: + +```python +for job in kanopy.iter_jobs(limit=100): + print(job["id"], job["status"]) +``` + +Pass `cursor=""` directly when you need page boundaries or pagination +metadata, then use `page.next_cursor`: ```python page = kanopy.list_jobs(cursor="", limit=100) diff --git a/src/kanopy/client.py b/src/kanopy/client.py index 529b6bf..d96d52f 100644 --- a/src/kanopy/client.py +++ b/src/kanopy/client.py @@ -7,7 +7,7 @@ import json import math import time -from collections.abc import Callable, Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor, as_completed from contextlib import ExitStack from os import PathLike @@ -237,6 +237,20 @@ def list_projects( ) return self._page(response) + def iter_projects(self, *, limit: int = 100) -> Iterator[JsonObject]: + """Yield every accessible project using stable cursor pagination.""" + cursor = "" + seen_cursors: set[str] = set() + while True: + page = self.list_projects(cursor=cursor, limit=limit) + yield from page.items + if not page.next_cursor: + return + if page.next_cursor in seen_cursors: + raise RuntimeError("Kanopy API returned a repeated project cursor") + seen_cursors.add(page.next_cursor) + cursor = page.next_cursor + def create_project( self, *, name: str, description: str | None = None ) -> JsonObject: @@ -271,12 +285,36 @@ def list_jobs( limit: int = 50, cursor: str | None = None, project_id: str | None = None, + skip_count: bool = False, ) -> Page[JsonObject]: params = self._pagination_params(skip=skip, limit=limit, cursor=cursor) if project_id is not None: params["project_id"] = project_id + if skip_count: + params["skip_count"] = True return self._page(self._request("GET", "/jobs", params=params)) + def iter_jobs( + self, *, limit: int = 100, project_id: str | None = None + ) -> Iterator[JsonObject]: + """Yield every accessible job using stable cursor pagination.""" + cursor = "" + seen_cursors: set[str] = set() + while True: + page = self.list_jobs( + cursor=cursor, + limit=limit, + project_id=project_id, + skip_count=bool(cursor), + ) + yield from page.items + if not page.next_cursor: + return + if page.next_cursor in seen_cursors: + raise RuntimeError("Kanopy API returned a repeated job cursor") + seen_cursors.add(page.next_cursor) + cursor = page.next_cursor + def list_project_jobs( self, project_id: str, *, skip: int = 0, limit: int = 50 ) -> Page[JsonObject]: diff --git a/tests/fixtures/openapi.public.json b/tests/fixtures/openapi.public.json index 4bfc19b..4d3dadd 100644 --- a/tests/fixtures/openapi.public.json +++ b/tests/fixtures/openapi.public.json @@ -1288,6 +1288,360 @@ "title": "InviteCodePublic", "type": "object" }, + "JobApiPublic": { + "description": "Stable job representation for API-key reads.\n\nBrowser sessions use :class:`JobPublic` because operational UI features\nneed storage and migration state. Integrations should not receive backend\npaths, bucket names, migration controls, or cleanup-assignment metadata.", + "properties": { + "capture_device": { + "$ref": "#/components/schemas/CaptureDevice", + "default": "drone" + }, + "circuit_clearance_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Circuit Clearance M" + }, + "circuit_count": { + "default": 1, + "title": "Circuit Count", + "type": "integer" + }, + "circuit_width_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Circuit Width M" + }, + "conductor_class": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Conductor Class" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "flight_latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Flight Latitude" + }, + "flight_location_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Flight Location Title" + }, + "flight_longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Flight Longitude" + }, + "flight_state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Flight State" + }, + "flight_state_abbr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Flight State Abbr" + }, + "has_reconstruction_video": { + "default": false, + "title": "Has Reconstruction Video", + "type": "boolean" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "input_video_size_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Input Video Size Bytes" + }, + "is_360_video": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is 360 Video" + }, + "job_folder_size_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Job Folder Size Bytes" + }, + "job_length_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Job Length M" + }, + "line_clearance_enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Line Clearance Enabled" + }, + "metric_accuracy_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Metric Accuracy M" + }, + "metric_accuracy_pct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Metric Accuracy Pct" + }, + "organization_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Organization Id" + }, + "phase_count": { + "default": 2, + "title": "Phase Count", + "type": "integer" + }, + "progress_detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progress Detail" + }, + "progress_pct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progress Pct" + }, + "progress_stage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Progress Stage" + }, + "project_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Project Id" + }, + "project_uuid": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "deprecated": true, + "description": "Deprecated alias for project_id; retained for v1 compatibility.", + "title": "Project Uuid" + }, + "published": { + "default": true, + "title": "Published", + "type": "boolean" + }, + "published_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Published At" + }, + "risk_refreshed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Risk Refreshed At" + }, + "status": { + "$ref": "#/components/schemas/JobStatus" + }, + "title": { + "title": "Title", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "voltage_class": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Voltage Class" + } + }, + "required": [ + "id", + "title", + "status", + "created_at", + "updated_at" + ], + "title": "JobApiPublic", + "type": "object" + }, "JobCreate": { "description": "Payload for creating a new job.", "properties": { @@ -6274,9 +6628,8 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/JobPublic" + "$ref": "#/components/schemas/JobApiPublic" }, - "title": "Response List Jobs Api V1 Jobs Get", "type": "array" } } @@ -6422,7 +6775,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobPublic" + "$ref": "#/components/schemas/JobApiPublic" } } }, @@ -7458,9 +7811,8 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/JobPublic" + "$ref": "#/components/schemas/JobApiPublic" }, - "title": "Response List Project Jobs Api V1 Projects Project Id Jobs Get", "type": "array" } } diff --git a/tests/test_client.py b/tests/test_client.py index 1a5c447..1f6b6cb 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -47,6 +47,49 @@ def handler(request: httpx.Request) -> httpx.Response: assert page.has_next +@pytest.mark.parametrize( + ("iterator_name", "path"), + [("iter_projects", "/api/v1/projects"), ("iter_jobs", "/api/v1/jobs")], +) +def test_iterators_walk_cursor_pages(iterator_name: str, path: str) -> None: + cursors: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == path + cursor = request.url.params["cursor"] + cursors.append(cursor) + if cursor == "": + assert "skip_count" not in request.url.params + return httpx.Response( + 200, + json=[{"id": "first"}], + headers={"X-Next-Cursor": "page-2", "X-Total-Count": "2"}, + ) + assert cursor == "page-2" + if iterator_name == "iter_jobs": + assert request.url.params["skip_count"] == "true" + return httpx.Response( + 200, + json=[{"id": "second"}], + headers={"X-Total-Count": "2"}, + ) + + with Kanopy("key", transport=httpx.MockTransport(handler)) as client: + items = list(getattr(client, iterator_name)()) + + assert items == [{"id": "first"}, {"id": "second"}] + assert cursors == ["", "page-2"] + + +def test_iter_jobs_forwards_project_filter() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params["project_id"] == "project-1" + return httpx.Response(200, json=[]) + + with Kanopy("key", transport=httpx.MockTransport(handler)) as client: + assert list(client.iter_jobs(project_id="project-1")) == [] + + def test_api_error_exposes_kanopy_error_fields() -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( From 830f8415250502df75bdfeea830557eb4a3530f3 Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Tue, 11 Aug 2026 14:26:16 +0000 Subject: [PATCH 02/13] Release version 0.5.0 --- setup.cfg | 2 +- src/kanopy/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 0c0a4a4..cfee0b2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = kanopy-ai -version = 0.4.0 +version = 0.5.0 description = Python SDK for the Kanopy infrastructure inspection API long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/kanopy/__init__.py b/src/kanopy/__init__.py index 4dfa5fa..0e75979 100644 --- a/src/kanopy/__init__.py +++ b/src/kanopy/__init__.py @@ -5,4 +5,4 @@ from .models import Page __all__ = ["DEFAULT_BASE_URL", "Kanopy", "KanopyError", "KanopyUploadError", "Page"] -__version__ = "0.4.0" +__version__ = "0.5.0" From b2c4e2db5cf912f5894940b1b1e167dd4890b258 Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Tue, 11 Aug 2026 14:28:55 +0000 Subject: [PATCH 03/13] Accept standard wheel license layouts --- scripts/check_dist.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check_dist.py b/scripts/check_dist.py index eb85b04..7299e94 100644 --- a/scripts/check_dist.py +++ b/scripts/check_dist.py @@ -69,7 +69,9 @@ def sdist_names(path: Path) -> set[str]: for required in REQUIRED_LICENSE_FILES: if not any( - name.endswith(f".dist-info/licenses/{required}") for name in wheel_contents + name.endswith(f".dist-info/{required}") + or name.endswith(f".dist-info/licenses/{required}") + for name in wheel_contents ): raise SystemExit(f"wheel is missing {required}") From efd07a7c87778b417e3e5ebffb62c7ec14186896 Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Tue, 11 Aug 2026 14:31:50 +0000 Subject: [PATCH 04/13] Satisfy release lint for license checks --- scripts/check_dist.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/check_dist.py b/scripts/check_dist.py index 7299e94..9f2f37c 100644 --- a/scripts/check_dist.py +++ b/scripts/check_dist.py @@ -69,8 +69,7 @@ def sdist_names(path: Path) -> set[str]: for required in REQUIRED_LICENSE_FILES: if not any( - name.endswith(f".dist-info/{required}") - or name.endswith(f".dist-info/licenses/{required}") + name.endswith((f".dist-info/{required}", f".dist-info/licenses/{required}")) for name in wheel_contents ): raise SystemExit(f"wheel is missing {required}") From 9f4159a37453db9c23212c901b350ed83e7d0cfc Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Tue, 11 Aug 2026 19:19:37 +0000 Subject: [PATCH 05/13] Improve SDK download reliability --- scripts/local_download_acceptance.py | 173 +++ setup.cfg | 2 +- src/kanopy/__init__.py | 4 +- src/kanopy/_version.py | 7 + src/kanopy/client.py | 64 +- tests/fixtures/openapi.public.json | 1661 ++++++++------------------ 6 files changed, 758 insertions(+), 1153 deletions(-) create mode 100644 scripts/local_download_acceptance.py create mode 100644 src/kanopy/_version.py diff --git a/scripts/local_download_acceptance.py b/scripts/local_download_acceptance.py new file mode 100644 index 0000000..18b6db7 --- /dev/null +++ b/scripts/local_download_acceptance.py @@ -0,0 +1,173 @@ +"""Exercise every customer download family against completed local job data. + +Requires a short-lived local API key plus explicit fixture IDs. The caller owns +credential creation/revocation so the raw key never needs to be printed. +""" + +from __future__ import annotations + +import csv +import io +import json +import os +import tempfile +import zipfile +from pathlib import Path + +import httpx + +from kanopy import Kanopy + + +def required_env(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError(f"{name} is required") + return value + + +def members_with_suffix(archive: zipfile.ZipFile, suffix: str) -> list[str]: + return [name for name in archive.namelist() if name.endswith(suffix)] + + +def ply_vertex_properties(archive: zipfile.ZipFile, member: str) -> set[str]: + properties: set[str] = set() + with archive.open(member) as source: + for raw_line in source: + line = raw_line.decode("ascii", errors="strict").strip() + if line == "end_header": + return properties + if line.startswith("property "): + properties.add(line.rsplit(" ", 1)[-1]) + raise AssertionError(f"{member} has no PLY end_header") + + +def assert_csv(path: Path, minimum_rows: int = 1) -> None: + with path.open(newline="", encoding="utf-8") as source: + rows = list(csv.DictReader(source)) + if len(rows) < minimum_rows: + raise AssertionError(f"{path.name} contains {len(rows)} data rows") + + +api_key = required_env("ACCEPTANCE_API_KEY") +base_url = required_env("ACCEPTANCE_BASE_URL") +project_id = required_env("ACCEPTANCE_PROJECT_ID") +job_id = required_env("ACCEPTANCE_JOB_ID") +georeferenced_job_id = required_env("ACCEPTANCE_GEO_JOB_ID") +target_epsg = int(os.environ.get("ACCEPTANCE_EPSG", "26919")) + +timeout = httpx.Timeout(30 * 60, connect=30) +with tempfile.TemporaryDirectory(prefix="kanopy-download-acceptance-") as tmp: + output = Path(tmp) + with Kanopy(api_key, base_url=base_url, timeout=timeout) as kanopy: + identity = kanopy.get_identity() + project = kanopy.get_project(project_id) + job = kanopy.get_job(job_id) + geo_job = kanopy.get_job(georeferenced_job_id) + assert identity.get("id") + assert str(project.get("id")) == project_id + assert str(job.get("status", "")).lower() == "completed" + assert str(geo_job.get("status", "")).lower() == "completed" + + complete_zip = kanopy.download_job_folder(job_id, output / "job.zip") + with zipfile.ZipFile(complete_zip) as archive: + assert members_with_suffix(archive, "/summary.txt") + assert members_with_suffix(archive, "/analytics/trees.csv") + assert members_with_suffix(archive, "/analytics/poles.csv") + assert members_with_suffix(archive, "/analytics/spans.csv") + assert any("/camera/" in name for name in archive.namelist()) + merged = [ + name + for name in archive.namelist() + if "/point_clouds/segmented/merged_point_cloud" in name + and name.endswith(".ply") + ] + assert merged, "complete job ZIP has no merged segmented point cloud" + properties = ply_vertex_properties(archive, merged[0]) + coordinate_and_color = {"x", "y", "z", "red", "green", "blue"} + embedded_fields = properties - coordinate_and_color + assert embedded_fields, "merged PLY has no embedded vertex fields" + + geo_zip = kanopy.download_job_folder( + georeferenced_job_id, + output / "point-clouds.zip", + include="point_cloud", + point_cloud_epsg=target_epsg, + ) + with zipfile.ZipFile(geo_zip) as archive: + names = archive.namelist() + assert any(name.endswith(".las") for name in names) + assert any(name.endswith(f"_epsg{target_epsg}.ply") for name in names) + reports = members_with_suffix(archive, "/registration_report.txt") + assert reports + report = archive.read(reports[0]).decode("utf-8") + assert f"EPSG:{target_epsg}" in report + + for table in ("trees", "poles", "spans"): + table_path = kanopy.download_job_table( + job_id, table, output / f"job-{table}.csv" + ) + assert_csv(table_path) + + project_exports = ( + ("trees", "csv"), + ("trees", "json"), + ("trees", "kml"), + ("trees", "geojson"), + ("poles", "csv"), + ("poles", "json"), + ("spans", "csv"), + ("spans", "json"), + ) + for table, format_name in project_exports: + destination = output / f"project-{table}.{format_name}" + kanopy.download_project_table( + project_id, table, destination, format=format_name + ) + assert destination.stat().st_size > 0 + assert json.loads((output / "project-trees.geojson").read_text())["features"] + assert "" in (output / "project-trees.kml").read_text() + + tree_payload = kanopy.list_project_trees(project_id, job_id=job_id, limit=1) + pole_payload = kanopy.list_project_poles(project_id, job_id=job_id, limit=1) + tree_id = str(tree_payload["trees"][0]["tree_id"]) + pole_id = str(pole_payload["poles"][0]["pole_id"]) + tree_pdf = kanopy.download_tree_report( + project_id, tree_id, output / "tree-analysis.pdf" + ) + pole_pdf = kanopy.download_pole_report( + project_id, pole_id, output / "pole-analysis.pdf" + ) + assert tree_pdf.read_bytes().startswith(b"%PDF-") + assert pole_pdf.read_bytes().startswith(b"%PDF-") + + counts = kanopy.get_project_object_counts(project_id, job_ids=[job_id]) + assert counts["trees"][job_id] > 0 + assert counts["poles"][job_id] > 0 + + audit_csv = kanopy.download_audit_events(output / "audit-events.csv") + staff_csv = kanopy.download_audit_events( + output / "staff-access.csv", staff_only=True + ) + assert audit_csv.read_text().startswith("created_at,action,") + assert staff_csv.read_text().startswith("created_at,action,") + + project_zip = kanopy.download_project_export( + project_id, + output / "project.zip", + timeout=30 * 60, + poll_interval=2, + ) + with zipfile.ZipFile(project_zip) as archive: + summaries = members_with_suffix(archive, "/summary.csv") + assert len(summaries) == 1 + summary = list( + csv.DictReader(io.StringIO(archive.read(summaries[0]).decode("utf-8"))) + ) + assert summary + +print( + "PASS: completed-job archives, embedded PLY fields, requested-CRS point clouds, " + "job/project analytics, portable formats, PDFs, counts, audit exports, and async " + "project export" +) diff --git a/setup.cfg b/setup.cfg index cfee0b2..4499b5c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = kanopy-ai -version = 0.5.0 +version = attr: kanopy._version.__version__ description = Python SDK for the Kanopy infrastructure inspection API long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/kanopy/__init__.py b/src/kanopy/__init__.py index 0e75979..cd5be73 100644 --- a/src/kanopy/__init__.py +++ b/src/kanopy/__init__.py @@ -1,8 +1,8 @@ """Official Python client for the Kanopy Developer API.""" +from ._version import __version__ from .client import DEFAULT_BASE_URL, Kanopy from .errors import KanopyError, KanopyUploadError from .models import Page -__all__ = ["DEFAULT_BASE_URL", "Kanopy", "KanopyError", "KanopyUploadError", "Page"] -__version__ = "0.5.0" +__all__ = ["DEFAULT_BASE_URL", "Kanopy", "KanopyError", "KanopyUploadError", "Page", "__version__"] diff --git a/src/kanopy/_version.py b/src/kanopy/_version.py new file mode 100644 index 0000000..e43f669 --- /dev/null +++ b/src/kanopy/_version.py @@ -0,0 +1,7 @@ +"""Single source of truth for the package version. + +Lives in its own module so client.py can build the User-Agent from it +without importing the package __init__ (which imports client.py back). +""" + +__version__ = "0.5.0" diff --git a/src/kanopy/client.py b/src/kanopy/client.py index d96d52f..c62a503 100644 --- a/src/kanopy/client.py +++ b/src/kanopy/client.py @@ -7,6 +7,7 @@ import json import math import time +import warnings from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor, as_completed from contextlib import ExitStack @@ -19,10 +20,15 @@ import httpx from typing_extensions import Self +from ._version import __version__ from .errors import KanopyError, KanopyUploadError from .models import Page DEFAULT_BASE_URL = "https://app.kanopy-ai.com/api/v1" +# Bounded, opt-out retry on 429: the platform sends Retry-After on every +# throttle response; honoring it is part of the API contract. +MAX_RATE_LIMIT_RETRIES = 2 +MAX_RETRY_AFTER_SECONDS = 120.0 DEFAULT_MULTIPART_PART_SIZE = 64 * 1024 * 1024 MIN_MULTIPART_PART_SIZE = 5 * 1024 * 1024 MAX_MULTIPART_PARTS = 10_000 @@ -153,7 +159,7 @@ def __init__( headers={ "Authorization": f"Bearer {api_key}", "Accept": "application/json", - "User-Agent": "kanopy-ai-python/0.2.0", + "User-Agent": f"kanopy-ai-python/{__version__}", }, ) self._upload_client = httpx.Client( @@ -173,10 +179,25 @@ def close(self) -> None: self._upload_client.close() def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: - response = self._client.request(method, path.lstrip("/"), **kwargs) - if response.is_error: - raise KanopyError.from_response(response) - return response + for attempt in range(MAX_RATE_LIMIT_RETRIES + 1): + response = self._client.request(method, path.lstrip("/"), **kwargs) + if response.status_code == 429 and attempt < MAX_RATE_LIMIT_RETRIES: + time.sleep(self._retry_after_seconds(response, attempt)) + continue + if response.is_error: + raise KanopyError.from_response(response) + return response + raise KanopyError.from_response(response) + + @staticmethod + def _retry_after_seconds(response: httpx.Response, attempt: int) -> float: + """Honor the server's Retry-After, falling back to capped backoff.""" + raw = response.headers.get("Retry-After") + try: + seconds = float(raw) if raw is not None else 2.0 * 2**attempt + except ValueError: + seconds = 2.0 * 2**attempt + return max(0.0, min(seconds, MAX_RETRY_AFTER_SECONDS)) def _json(self, method: str, path: str, **kwargs: Any) -> Any: response = self._request(method, path, **kwargs) @@ -1360,14 +1381,31 @@ def _report_value(value: Any) -> str: return str(value) def _fetch_report_asset(self, url: str) -> bytes | None: + """Fetch report imagery WITHOUT ever attaching the API key. + + Asset URLs come from server payloads (frame image paths). They are + expected to be presigned or otherwise publicly fetchable; the paths + are not part of the integration contract, so sending the customer's + credential to whatever string the payload contains is never correct — + neither same-origin (out-of-contract, rejected in block mode) nor + cross-origin (credential replay to a third party). + """ try: - parsed = urlparse(url) - api_origin = urlparse(str(self._client.base_url)) - is_presigned = "x-amz-signature=" in url.lower() - if is_presigned or (parsed.netloc and parsed.netloc != api_origin.netloc): - response = self._upload_client.get(url) - else: - response = self._client.get(url) - return response.content if response.is_success else None + # Resolve relative paths against the API origin so the request is + # well-formed — still on the credential-free client. + resolved = str(httpx.URL(str(self._client.base_url)).join(url)) + response = self._upload_client.get(resolved, follow_redirects=True) + if not response.is_success: + warnings.warn( + f"Report image could not be fetched (HTTP {response.status_code}); " + "the generated report will omit it.", + stacklevel=2, + ) + return None + return response.content except httpx.HTTPError: + warnings.warn( + "Report image could not be fetched; the generated report will omit it.", + stacklevel=2, + ) return None diff --git a/tests/fixtures/openapi.public.json b/tests/fixtures/openapi.public.json index 4d3dadd..309bdc4 100644 --- a/tests/fixtures/openapi.public.json +++ b/tests/fixtures/openapi.public.json @@ -1,55 +1,16 @@ { "components": { "schemas": { - "ApiKeyCreate": { - "properties": { - "expires_in_days": { - "anyOf": [ - { - "maximum": 3650.0, - "minimum": 1.0, - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "Key lifetime in days (default: 365, max: 3650)", - "title": "Expires In Days" - }, - "name": { - "description": "Human-readable label for this key", - "maxLength": 255, - "minLength": 1, - "title": "Name", - "type": "string" - } - }, - "required": [ - "name" - ], - "title": "ApiKeyCreate", - "type": "object" - }, - "ApiKeyCreated": { - "description": "Returned only at creation \u2014 includes the raw token (shown once).", + "AuditEventPage": { "properties": { - "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "expires_at": { - "format": "date-time", - "title": "Expires At", - "type": "string" - }, - "id": { - "format": "uuid", - "title": "Id", - "type": "string" + "events": { + "items": { + "$ref": "#/components/schemas/AuditEventPublic" + }, + "title": "Events", + "type": "array" }, - "last_used_at": { + "next_before": { "anyOf": [ { "format": "date-time", @@ -59,148 +20,82 @@ "type": "null" } ], - "title": "Last Used At" - }, - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Name" - }, - "token": { - "title": "Token", - "type": "string" + "title": "Next Before" } }, "required": [ - "id", - "name", - "created_at", - "expires_at", - "last_used_at", - "token" + "events", + "next_before" ], - "title": "ApiKeyCreated", + "title": "AuditEventPage", "type": "object" }, - "ApiKeyPublic": { - "description": "Public representation of an API key (never includes the raw token).", + "AuditEventPublic": { "properties": { - "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "expires_at": { - "format": "date-time", - "title": "Expires At", - "type": "string" - }, - "id": { - "format": "uuid", - "title": "Id", + "action": { + "title": "Action", "type": "string" }, - "last_used_at": { + "actor_email": { "anyOf": [ { - "format": "date-time", "type": "string" }, { "type": "null" } ], - "title": "Last Used At" + "title": "Actor Email" }, - "name": { + "actor_is_staff": { + "title": "Actor Is Staff", + "type": "boolean" + }, + "actor_user_id": { "anyOf": [ { + "format": "uuid", "type": "string" }, { "type": "null" } ], - "title": "Name" - } - }, - "required": [ - "id", - "name", - "created_at", - "expires_at", - "last_used_at" - ], - "title": "ApiKeyPublic", - "type": "object" - }, - "AuditEventPage": { - "properties": { - "events": { - "items": { - "$ref": "#/components/schemas/AuditEventPublic" - }, - "title": "Events", - "type": "array" + "title": "Actor User Id" }, - "next_before": { + "auth_method": { "anyOf": [ { - "format": "date-time", "type": "string" }, { "type": "null" } ], - "title": "Next Before" - } - }, - "required": [ - "events", - "next_before" - ], - "title": "AuditEventPage", - "type": "object" - }, - "AuditEventPublic": { - "properties": { - "action": { - "title": "Action", - "type": "string" + "title": "Auth Method" }, - "actor_email": { + "auth_token_id": { "anyOf": [ { + "format": "uuid", "type": "string" }, { "type": "null" } ], - "title": "Actor Email" - }, - "actor_is_staff": { - "title": "Actor Is Staff", - "type": "boolean" + "title": "Auth Token Id" }, - "actor_user_id": { + "auth_token_name": { "anyOf": [ { - "format": "uuid", "type": "string" }, { "type": "null" } ], - "title": "Actor User Id" + "title": "Auth Token Name" }, "created_at": { "format": "date-time", @@ -272,6 +167,9 @@ "actor_user_id", "actor_email", "actor_is_staff", + "auth_token_id", + "auth_token_name", + "auth_method", "target_type", "target_id", "ip", @@ -283,7 +181,7 @@ "title": "AuditEventPublic", "type": "object" }, - "Body_complete_presigned_multipart_upload_api_v1_upload_complete_presigned_multipart_post": { + "Body_complete_presigned_multipart_upload": { "properties": { "flight_latitude": { "anyOf": [ @@ -442,10 +340,10 @@ "s3_key", "parts_json" ], - "title": "Body_complete_presigned_multipart_upload_api_v1_upload_complete_presigned_multipart_post", + "title": "Body_complete_presigned_multipart_upload", "type": "object" }, - "Body_complete_presigned_upload_api_v1_upload_complete_presigned_post": { + "Body_complete_presigned_upload": { "properties": { "flight_latitude": { "anyOf": [ @@ -591,10 +489,10 @@ "required": [ "job_id" ], - "title": "Body_complete_presigned_upload_api_v1_upload_complete_presigned_post", + "title": "Body_complete_presigned_upload", "type": "object" }, - "Body_inspect_action_video_api_v1_upload_inspect_action_video_post": { + "Body_inspect_action_video": { "properties": { "video": { "contentMediaType": "application/octet-stream", @@ -606,10 +504,10 @@ "required": [ "video" ], - "title": "Body_inspect_action_video_api_v1_upload_inspect_action_video_post", + "title": "Body_inspect_action_video", "type": "object" }, - "Body_inspect_flight_log_api_v1_upload_inspect_flight_log_post": { + "Body_inspect_flight_log": { "properties": { "metadata": { "contentMediaType": "application/octet-stream", @@ -645,10 +543,10 @@ "required": [ "metadata" ], - "title": "Body_inspect_flight_log_api_v1_upload_inspect_flight_log_post", + "title": "Body_inspect_flight_log", "type": "object" }, - "Body_upload_api_v1_upload_post": { + "Body_upload": { "properties": { "capture_device": { "anyOf": [ @@ -986,7 +884,7 @@ "required": [ "video" ], - "title": "Body_upload_api_v1_upload_post", + "title": "Body_upload", "type": "object" }, "CaptureDevice": { @@ -1870,77 +1768,81 @@ "title": "JobOutputsResponse", "type": "object" }, - "JobPublic": { - "description": "Public representation of a reconstruction job.\n\nOmits internal fields: task_id, input_metadata_path, veg_analysis_model,\nveg_analysis_frames_per_region, efs_expires_at, started_processing_at,\npublished_by.", + "JobStatus": { + "description": "Lifecycle status for a reconstruction job.", + "enum": [ + "COMPRESSING", + "UPLOADING", + "PENDING", + "LOCALIZING", + "AWAITING_POLE_HEIGHT", + "PROCESSING", + "COMPLETED", + "FAILED", + "CANCELED" + ], + "title": "JobStatus", + "type": "string" + }, + "JobUpdate": { + "description": "Payload for updating mutable job fields.", "properties": { - "capture_device": { - "$ref": "#/components/schemas/CaptureDevice", - "default": "drone" - }, - "circuit_clearance_m": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Circuit Clearance M" - }, - "circuit_count": { - "default": 1, - "title": "Circuit Count", - "type": "integer" - }, - "circuit_width_m": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Circuit Width M" + "title": { + "description": "User-facing job title", + "maxLength": 255, + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "title" + ], + "title": "JobUpdate", + "type": "object" + }, + "MultipartInitResponse": { + "properties": { + "content_type": { + "title": "Content Type", + "type": "string" }, - "cleanup_cleaned_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Cleanup Cleaned At" + "job_id": { + "title": "Job Id", + "type": "string" }, - "cleanup_cleaned_by_user_id": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Cleanup Cleaned By User Id" + "s3_key": { + "title": "S3 Key", + "type": "string" }, - "cleanup_credited_user_id": { + "upload_id": { + "title": "Upload Id", + "type": "string" + } + }, + "required": [ + "job_id", + "s3_key", + "upload_id", + "content_type" + ], + "title": "MultipartInitResponse", + "type": "object" + }, + "OrganizationAccountContact": { + "properties": { + "email": { "anyOf": [ { - "format": "uuid", "type": "string" }, { "type": "null" } ], - "title": "Cleanup Credited User Id" + "title": "Email" }, - "cleanup_status": { + "full_name": { "anyOf": [ { "type": "string" @@ -1949,569 +1851,7 @@ "type": "null" } ], - "title": "Cleanup Status" - }, - "conductor_class": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Conductor Class" - }, - "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "flight_latitude": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Flight Latitude" - }, - "flight_location_title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Flight Location Title" - }, - "flight_longitude": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Flight Longitude" - }, - "flight_state": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Flight State" - }, - "flight_state_abbr": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Flight State Abbr" - }, - "has_reconstruction_video": { - "default": false, - "title": "Has Reconstruction Video", - "type": "boolean" - }, - "id": { - "format": "uuid", - "title": "Id", - "type": "string" - }, - "input_video_path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Input Video Path" - }, - "input_video_size_bytes": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Input Video Size Bytes" - }, - "is_360_video": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Is 360 Video" - }, - "job_folder_size_bytes": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Job Folder Size Bytes" - }, - "job_length_m": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Job Length M" - }, - "last_edit_affected_chunk_ids": { - "anyOf": [ - { - "items": { - "type": "integer" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "title": "Last Edit Affected Chunk Ids" - }, - "last_edit_seq": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Last Edit Seq" - }, - "line_clearance_enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Line Clearance Enabled" - }, - "metric_accuracy_m": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Metric Accuracy M" - }, - "metric_accuracy_pct": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Metric Accuracy Pct" - }, - "migration_completed_files": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Migration Completed Files" - }, - "migration_direction": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Migration Direction" - }, - "migration_error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Migration Error" - }, - "migration_status": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Migration Status" - }, - "migration_total_files": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Migration Total Files" - }, - "organization_id": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Organization Id" - }, - "output_path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Output Path" - }, - "phase_count": { - "default": 2, - "title": "Phase Count", - "type": "integer" - }, - "pole_height_m": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Pole Height M" - }, - "progress_detail": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Progress Detail" - }, - "progress_pct": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Progress Pct" - }, - "progress_stage": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Progress Stage" - }, - "project_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Project Id" - }, - "project_uuid": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Project Uuid" - }, - "published": { - "default": true, - "title": "Published", - "type": "boolean" - }, - "published_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Published At" - }, - "risk_refreshed_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Risk Refreshed At" - }, - "s3_bucket": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "S3 Bucket" - }, - "s3_prefix": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "S3 Prefix" - }, - "status": { - "$ref": "#/components/schemas/JobStatus" - }, - "storage_backend": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Storage Backend" - }, - "title": { - "title": "Title", - "type": "string" - }, - "updated_at": { - "format": "date-time", - "title": "Updated At", - "type": "string" - }, - "use_pole_scale_reference": { - "default": false, - "title": "Use Pole Scale Reference", - "type": "boolean" - }, - "veg_analysis_effort": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Veg Analysis Effort" - }, - "veg_analysis_enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Veg Analysis Enabled" - }, - "veg_analysis_providers": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Veg Analysis Providers" - }, - "voltage_class": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Voltage Class" - } - }, - "required": [ - "id", - "title", - "status", - "created_at", - "updated_at" - ], - "title": "JobPublic", - "type": "object" - }, - "JobStatus": { - "description": "Lifecycle status for a reconstruction job.", - "enum": [ - "COMPRESSING", - "UPLOADING", - "PENDING", - "LOCALIZING", - "PROCESSING", - "COMPLETED", - "FAILED", - "CANCELED" - ], - "title": "JobStatus", - "type": "string" - }, - "JobUpdate": { - "description": "Payload for updating mutable job fields.", - "properties": { - "title": { - "description": "User-facing job title", - "maxLength": 255, - "minLength": 1, - "title": "Title", - "type": "string" - } - }, - "required": [ - "title" - ], - "title": "JobUpdate", - "type": "object" - }, - "MultipartInitResponse": { - "properties": { - "content_type": { - "title": "Content Type", - "type": "string" - }, - "job_id": { - "title": "Job Id", - "type": "string" - }, - "s3_key": { - "title": "S3 Key", - "type": "string" - }, - "upload_id": { - "title": "Upload Id", - "type": "string" - } - }, - "required": [ - "job_id", - "s3_key", - "upload_id", - "content_type" - ], - "title": "MultipartInitResponse", - "type": "object" - }, - "OrganizationAccountContact": { - "properties": { - "email": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Email" - }, - "full_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Full Name" + "title": "Full Name" }, "user_id": { "title": "User Id", @@ -2651,6 +1991,11 @@ "title": "Id", "type": "string" }, + "is_demo": { + "default": false, + "title": "Is Demo", + "type": "boolean" + }, "organization_id": { "title": "Organization Id", "type": "string" @@ -2694,6 +2039,21 @@ "title": "OrganizationMemberUpdate", "type": "object" }, + "PoleHeightResumeRequest": { + "description": "Reference scale used to resume raw footage that has no GPS telemetry.", + "properties": { + "pole_height_m": { + "minimum": 0.1, + "title": "Pole Height M", + "type": "number" + } + }, + "required": [ + "pole_height_m" + ], + "title": "PoleHeightResumeRequest", + "type": "object" + }, "PoleListResponse": { "properties": { "limit": { @@ -2847,17 +2207,6 @@ ], "title": "Height M" }, - "instance_path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Instance Path" - }, "job_id": { "title": "Job Id", "type": "string" @@ -3211,13 +2560,6 @@ "title": "Observations", "type": "array" }, - "ply_paths_by_job_id": { - "additionalProperties": { - "type": "string" - }, - "title": "Ply Paths By Job Id", - "type": "object" - }, "pole_id": { "title": "Pole Id", "type": "string" @@ -3859,6 +3201,68 @@ ], "title": "Organization Id" }, + "policy_applied_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Policy Applied At" + }, + "policy_recompute_pending": { + "default": false, + "title": "Policy Recompute Pending", + "type": "boolean" + }, + "pruning_margin_ft": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Pruning Margin Ft" + }, + "pruning_removal_fraction": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Pruning Removal Fraction" + }, + "removal_risk_threshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Removal Risk Threshold" + }, + "risk_constants": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Risk Constants" + }, "updated_at": { "format": "date-time", "title": "Updated At", @@ -3891,26 +3295,77 @@ "description": { "anyOf": [ { - "maxLength": 2048, - "type": "string" + "maxLength": 2048, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "anyOf": [ + { + "maxLength": 255, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "pruning_margin_ft": { + "anyOf": [ + { + "maximum": 8.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Pruning Margin Ft" + }, + "pruning_removal_fraction": { + "anyOf": [ + { + "exclusiveMinimum": 0.0, + "maximum": 1.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Pruning Removal Fraction" + }, + "removal_risk_threshold": { + "anyOf": [ + { + "maximum": 100.0, + "minimum": 0.0, + "type": "number" }, { "type": "null" } ], - "title": "Description" + "title": "Removal Risk Threshold" }, - "name": { + "risk_constants": { "anyOf": [ { - "maxLength": 255, - "type": "string" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], - "title": "Name" + "title": "Risk Constants" } }, "title": "ProjectUpdate", @@ -4572,17 +4027,6 @@ "title": "Frames", "type": "array" }, - "instance_path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Instance Path" - }, "job_id": { "title": "Job Id", "type": "string" @@ -4727,6 +4171,29 @@ ], "title": "Point Instance Id" }, + "pruning_action": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pruning Action" + }, + "pruning_curve": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Pruning Curve" + }, "pruning_fraction": { "anyOf": [ { @@ -4889,6 +4356,18 @@ ], "title": "Center World" }, + "condition_summary": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Condition Summary" + }, "current_band": { "anyOf": [ { @@ -5082,13 +4561,6 @@ "title": "Observations", "type": "array" }, - "ply_paths_by_job_id": { - "additionalProperties": { - "type": "string" - }, - "title": "Ply Paths By Job Id", - "type": "object" - }, "project_id": { "anyOf": [ { @@ -5100,6 +4572,29 @@ ], "title": "Project Id" }, + "pruning_action": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pruning Action" + }, + "pruning_curve": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Pruning Curve" + }, "pruning_fraction": { "anyOf": [ { @@ -5122,6 +4617,52 @@ ], "title": "Pruning Hull Volume M3" }, + "pruning_recommendation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pruning Recommendation" + }, + "pruning_recommendation_breakdown": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Pruning Recommendation Breakdown" + }, + "pruning_recommended_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pruning Recommended At" + }, + "pruning_recommended_volume_m3": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Pruning Recommended Volume M3" + }, "risk_breakdown": { "anyOf": [ { @@ -5336,7 +4877,7 @@ "WebhookCreate": { "properties": { "events": { - "description": "Events to subscribe to. Empty list = all events. Valid values: ['job.canceled', 'job.completed', 'job.failed', 'job.processing']", + "description": "Events to subscribe to. Empty list = all events. Valid values: ['cleanup.completed', 'job.canceled', 'job.cleaned', 'job.completed', 'job.failed', 'job.processing', 'job.published', 'job.uploaded']", "items": { "type": "string" }, @@ -5385,7 +4926,7 @@ "type": "object" }, "WebhookCreated": { - "description": "Returned only at creation \u2014 includes the signing secret (shown once).", + "description": "Returned only at creation \u2014 includes the signing secret (shown once).\n\n``secret`` is null when an idempotency key replays an existing\nregistration: the secret is only ever revealed by the call that minted it,\nso a replayed key cannot be used to re-read it later.", "properties": { "consecutive_failures": { "title": "Consecutive Failures", @@ -5396,6 +4937,10 @@ "title": "Created At", "type": "string" }, + "destination": { + "title": "Destination", + "type": "string" + }, "events": { "items": { "type": "string" @@ -5448,8 +4993,15 @@ "title": "Name" }, "secret": { - "title": "Secret", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Secret" }, "updated_at": { "format": "date-time", @@ -5465,14 +5017,14 @@ "id", "name", "url", + "destination", "events", "is_active", "consecutive_failures", "last_failure_at", "last_success_at", "created_at", - "updated_at", - "secret" + "updated_at" ], "title": "WebhookCreated", "type": "object" @@ -5590,6 +5142,10 @@ "title": "Created At", "type": "string" }, + "destination": { + "title": "Destination", + "type": "string" + }, "events": { "items": { "type": "string" @@ -5655,6 +5211,7 @@ "id", "name", "url", + "destination", "events", "is_active", "consecutive_failures", @@ -5972,165 +5529,47 @@ } }, { - "in": "query", - "name": "from", - "required": false, - "schema": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "From" - } - }, - { - "in": "query", - "name": "to", - "required": false, - "schema": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "To" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": {} - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Export audit events as CSV", - "tags": [ - "audit" - ] - } - }, - "/auth/api-keys": { - "get": { - "description": "Returns all active API keys for the authenticated user. Tokens are never included.", - "operationId": "list_api_keys", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/ApiKeyPublic" - }, - "title": "Response List Api Keys Api V1 Auth Api Keys Get", - "type": "array" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "List API keys", - "tags": [ - "api-keys" - ] - }, - "post": { - "description": "Creates a long-lived API key. The raw token is returned **once** at creation and cannot be retrieved again. Store it securely. Use it as a bearer token: `Authorization: Bearer `.", - "operationId": "create_api_key", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiKeyCreate" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiKeyCreated" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Create an API key", - "tags": [ - "api-keys" - ] - } - }, - "/auth/api-keys/{key_id}": { - "delete": { - "description": "Permanently revokes an API key. Any requests using this key will immediately fail.", - "operationId": "revoke_api_key", - "parameters": [ - { - "in": "path", - "name": "key_id", - "required": true, + "in": "query", + "name": "from", + "required": false, "schema": { - "format": "uuid", - "title": "Key Id", - "type": "string" + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "in": "query", + "name": "to", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "To" } } ], "responses": { - "204": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, "description": "Successful Response" }, "422": { @@ -6149,9 +5588,9 @@ "HTTPBearer": [] } ], - "summary": "Revoke an API key", + "summary": "Export audit events as CSV", "tags": [ - "api-keys" + "audit" ] } }, @@ -6163,7 +5602,7 @@ "content": { "application/json": { "schema": { - "description": "Integration identity returned by /auth/me for API-key callers.\n\nDeliberately excludes the session payload (capabilities, roles,\npreferences, MFA state) \u2014 that is web-app UI state, not part of the\ncustomer API contract, and it exposes internal authorization vocabulary.", + "description": "Integration identity returned by /auth/me for API-key callers.", "properties": { "company_name": { "anyOf": [ @@ -6675,7 +6114,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobPublic" + "$ref": "#/components/schemas/JobApiPublic" } } }, @@ -6835,7 +6274,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobPublic" + "$ref": "#/components/schemas/JobApiPublic" } } }, @@ -6865,7 +6304,7 @@ }, "/jobs/{job_id}/cancel": { "post": { - "description": "Cancel a job that is not in a terminal state.\nSupports canceling jobs in COMPRESSING, UPLOADING, PENDING, or PROCESSING status.\nAny authenticated user can cancel their own jobs.\nIf the Celery task id is known, send a revoke with terminate to stop execution.", + "description": "Cancel a job that is not in a terminal state.\nSupports canceling jobs in COMPRESSING, UPLOADING, PENDING, or PROCESSING status.\nAny authenticated user can cancel their own jobs.", "operationId": "cancel_job", "parameters": [ { @@ -6884,7 +6323,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobPublic" + "$ref": "#/components/schemas/JobApiPublic" } } }, @@ -6934,7 +6373,7 @@ } }, { - "description": "Comma-separated artifact categories to include. Omit for everything. point_cloud = local-frame PLY point clouds with measurement scalars baked in, plus CRS-tagged LAS/PLY copies and a registration report when the job can be georeferenced; camera = per-frame camera poses in the point clouds' local frame; analytics = trees.csv, poles.csv and spans.csv.", + "description": "Comma-separated artifact categories to include. Omit for everything. point_cloud = local-frame PLY point clouds with measurement scalars baked in and fitted wires added to reconstruction chunks, plus CRS-tagged LAS/PLY copies of reconstruction and segmented clouds and a registration report when the job can be georeferenced; camera = per-frame camera poses in the point clouds' local frame; analytics = trees.csv, poles.csv and spans.csv.", "in": "query", "name": "include", "required": false, @@ -6947,7 +6386,7 @@ "type": "null" } ], - "description": "Comma-separated artifact categories to include. Omit for everything. point_cloud = local-frame PLY point clouds with measurement scalars baked in, plus CRS-tagged LAS/PLY copies and a registration report when the job can be georeferenced; camera = per-frame camera poses in the point clouds' local frame; analytics = trees.csv, poles.csv and spans.csv.", + "description": "Comma-separated artifact categories to include. Omit for everything. point_cloud = local-frame PLY point clouds with measurement scalars baked in and fitted wires added to reconstruction chunks, plus CRS-tagged LAS/PLY copies of reconstruction and segmented clouds and a registration report when the job can be georeferenced; camera = per-frame camera poses in the point clouds' local frame; analytics = trees.csv, poles.csv and spans.csv.", "title": "Include" } }, @@ -7004,7 +6443,7 @@ }, "/jobs/{job_id}/outputs": { "get": { - "description": "Discover what a job produced without hard-coding zip layouts. Each entry carries a stable `id` you can pass to GET /jobs/{job_id}/outputs/{output_id} to download that output on its own.\n\nReturns an empty list \u2014 not a 404 \u2014 for a job that has not produced anything yet, so it is safe to poll after a `job.completed` webhook.\n\nGeoreferenced point clouds are reprojected at packaging time and are not listed individually; fetch them via GET /jobs/{job_id}/folder-zip with `include=point_cloud` and `point_cloud_epsg`.", + "description": "Discover what a job produced without hard-coding zip layouts. Each entry carries a stable `id` you can pass to GET /jobs/{job_id}/outputs/{output_id} to download that output on its own.\n\nReturns an empty list \u2014 not a 404 \u2014 for a job that has not produced anything yet. Customer integrations should request outputs after a `job.published` webhook.\n\nGeoreferenced point clouds are reprojected at packaging time and are not listed individually; fetch them via GET /jobs/{job_id}/folder-zip with `include=point_cloud` and `point_cloud_epsg`.", "operationId": "list_job_output_catalog", "parameters": [ { @@ -7120,6 +6559,65 @@ ] } }, + "/jobs/{job_id}/pole-height": { + "post": { + "description": "Resume server-side preparation for raw footage with no GPS telemetry.", + "operationId": "resume_job_with_pole_height", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Job Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoleHeightResumeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobApiPublic" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Resume Job With Pole Height", + "tags": [ + "jobs" + ] + } + }, "/jobs/{job_id}/tables/{table}": { "get": { "description": "Download one analytics table for a job as CSV.\n\ntrees: one row per tree (clearances, risk score, AI vegetation columns).\npoles: one row per utility pole. spans: one row per conductor span.\nSame columns as the analytics/ CSVs inside the folder-zip download.", @@ -7243,22 +6741,6 @@ "title": "Include Clearance Standards", "type": "boolean" } - }, - { - "in": "header", - "name": "x-impersonate-user", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "X-Impersonate-User" - } } ], "responses": { @@ -7393,22 +6875,6 @@ "title": "Project Id", "type": "string" } - }, - { - "in": "header", - "name": "x-impersonate-user", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "X-Impersonate-User" - } } ], "responses": { @@ -7564,8 +7030,8 @@ } }, { - "in": "header", - "name": "x-impersonate-user", + "in": "query", + "name": "include", "required": false, "schema": { "anyOf": [ @@ -7576,7 +7042,23 @@ "type": "null" } ], - "title": "X-Impersonate-User" + "title": "Include" + } + }, + { + "in": "query", + "name": "point_cloud_epsg", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Point Cloud Epsg" } } ], @@ -7637,22 +7119,6 @@ "title": "Export Id", "type": "string" } - }, - { - "in": "header", - "name": "x-impersonate-user", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "X-Impersonate-User" - } } ], "responses": { @@ -7704,8 +7170,8 @@ } }, { - "in": "header", - "name": "x-impersonate-user", + "in": "query", + "name": "include", "required": false, "schema": { "anyOf": [ @@ -7716,7 +7182,24 @@ "type": "null" } ], - "title": "X-Impersonate-User" + "title": "Include" + } + }, + { + "in": "query", + "name": "point_cloud_epsg", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Point Cloud Epsg" } } ], @@ -7787,22 +7270,6 @@ "title": "Limit", "type": "integer" } - }, - { - "in": "header", - "name": "x-impersonate-user", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "X-Impersonate-User" - } } ], "responses": { @@ -7866,22 +7333,6 @@ "title": "Job Ids", "type": "array" } - }, - { - "in": "header", - "name": "x-impersonate-user", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "X-Impersonate-User" - } } ], "responses": { @@ -8102,22 +7553,6 @@ "title": "Skip Count", "type": "boolean" } - }, - { - "in": "header", - "name": "x-impersonate-user", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "X-Impersonate-User" - } } ], "responses": { @@ -8237,22 +7672,6 @@ "title": "Skip Count", "type": "boolean" } - }, - { - "in": "header", - "name": "x-impersonate-user", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "X-Impersonate-User" - } } ], "responses": { @@ -8467,22 +7886,6 @@ "title": "Skip Count", "type": "boolean" } - }, - { - "in": "header", - "name": "x-impersonate-user", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "X-Impersonate-User" - } } ], "responses": { @@ -8619,22 +8022,6 @@ "title": "Include Camera Poses", "type": "boolean" } - }, - { - "in": "header", - "name": "x-impersonate-user", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "X-Impersonate-User" - } } ], "responses": { @@ -8727,7 +8114,7 @@ "content": { "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Body_upload_api_v1_upload_post" + "$ref": "#/components/schemas/Body_upload" } } }, @@ -8770,13 +8157,13 @@ }, "/upload/complete-presigned": { "post": { - "description": "Finalize a presigned upload and dispatch its processing path.\n\nMetadata files (flight logs, GPS tracks) are still uploaded via this endpoint\nsince they are small enough for multipart form. Raw uploads return after\nqueueing server preparation; client-transcoded uploads retain the legacy\nsynchronous completion path during the staged rollout.", + "description": "Finalize a presigned upload and dispatch its processing path.\n\nMetadata files (flight logs, GPS tracks) are still uploaded via this endpoint\nsince they are small enough for multipart form. Raw uploads return as soon as\nserver-side preparation is queued; transcoded uploads complete synchronously.", "operationId": "complete_presigned_upload", "requestBody": { "content": { "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Body_complete_presigned_upload_api_v1_upload_complete_presigned_post" + "$ref": "#/components/schemas/Body_complete_presigned_upload" } } }, @@ -8825,7 +8212,7 @@ "content": { "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Body_complete_presigned_multipart_upload_api_v1_upload_complete_presigned_multipart_post" + "$ref": "#/components/schemas/Body_complete_presigned_multipart_upload" } } }, @@ -8868,7 +8255,7 @@ }, "/upload/init-presigned": { "post": { - "description": "Prepare a job and return a presigned S3 URL for direct video upload.\n\nReuses an existing pre-created job when ``job_id`` is provided so the\nfrontend queue and the upload pipeline operate on the same job record.\nRequires S3 to be configured (``S3_BUCKET_NAME`` set). Returns 501 if not.", + "description": "Prepare a job and return a presigned S3 URL for direct video upload.\n\nReuses an existing pre-created job when ``job_id`` is provided so the\nfrontend queue and the upload pipeline operate on the same job record.\nReturns 501 when direct-to-storage upload is not available in this environment.", "operationId": "init_presigned_upload", "requestBody": { "content": { @@ -8970,7 +8357,7 @@ "content": { "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Body_inspect_action_video_api_v1_upload_inspect_action_video_post" + "$ref": "#/components/schemas/Body_inspect_action_video" } } }, @@ -9017,7 +8404,7 @@ "content": { "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Body_inspect_flight_log_api_v1_upload_inspect_flight_log_post" + "$ref": "#/components/schemas/Body_inspect_flight_log" } } }, From d386a9b3be20b8bcb62a2aed5b13f3f4013267c6 Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Wed, 12 Aug 2026 17:59:38 +0000 Subject: [PATCH 06/13] Support single-source SDK version checks --- scripts/check_version.py | 57 +++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/scripts/check_version.py b/scripts/check_version.py index 3db1a88..3fc96bb 100644 --- a/scripts/check_version.py +++ b/scripts/check_version.py @@ -10,23 +10,54 @@ root = Path(__file__).resolve().parents[1] config = configparser.ConfigParser() config.read(root / "setup.cfg") -distribution_version = config["metadata"]["version"] - -module = ast.parse((root / "src/kanopy/__init__.py").read_text(encoding="utf-8")) -module_version: str | None = None -for statement in module.body: - if not isinstance(statement, ast.Assign): - continue - if any( - isinstance(target, ast.Name) and target.id == "__version__" - for target in statement.targets - ): - module_version = ast.literal_eval(statement.value) +distribution_spec = config["metadata"]["version"] + + +def literal_version(path: Path) -> str | None: + """Read a literal ``__version__`` without importing package dependencies.""" + module = ast.parse(path.read_text(encoding="utf-8")) + for statement in module.body: + if not isinstance(statement, ast.Assign): + continue + if any( + isinstance(target, ast.Name) and target.id == "__version__" + for target in statement.targets + ): + value = ast.literal_eval(statement.value) + return value if isinstance(value, str) else None + return None + + +if distribution_spec.startswith("attr:"): + attribute_ref = distribution_spec.removeprefix("attr:").strip() + *module_parts, attribute_name = attribute_ref.split(".") + if attribute_name != "__version__" or not module_parts: + raise SystemExit(f"unsupported setup.cfg version attribute: {attribute_ref!r}") + distribution_version = literal_version( + root / "src" / Path(*module_parts).with_suffix(".py") + ) +else: + distribution_version = distribution_spec + +init_path = root / "src/kanopy/__init__.py" +module_version = literal_version(init_path) +if module_version is None: + init_module = ast.parse(init_path.read_text(encoding="utf-8")) + for statement in init_module.body: + if not isinstance(statement, ast.ImportFrom) or statement.level != 1: + continue + if not any(alias.name == "__version__" for alias in statement.names): + continue + if statement.module: + module_version = literal_version( + init_path.parent / Path(*statement.module.split(".")).with_suffix(".py") + ) break if module_version != distribution_version: raise SystemExit( - f"version mismatch: setup.cfg={distribution_version!r}, " + f"version mismatch: setup.cfg={distribution_spec!r} " + f"(resolved={distribution_version!r}), " f"kanopy.__version__={module_version!r}" ) From d2a8898d9eaeedd0b21a88e94008fca52a961931 Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Fri, 21 Aug 2026 19:31:39 +0000 Subject: [PATCH 07/13] Sync hardened webhook API contract --- tests/fixtures/openapi.public.json | 94 +++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/openapi.public.json b/tests/fixtures/openapi.public.json index 309bdc4..04f3881 100644 --- a/tests/fixtures/openapi.public.json +++ b/tests/fixtures/openapi.public.json @@ -2575,6 +2575,20 @@ ], "title": "Pole Subclass Label" }, + "pole_subclass_label_sources_by_job_id": { + "additionalProperties": { + "type": "string" + }, + "title": "Pole Subclass Label Sources By Job Id", + "type": "object" + }, + "pole_subclass_labels_by_job_id": { + "additionalProperties": { + "type": "string" + }, + "title": "Pole Subclass Labels By Job Id", + "type": "object" + }, "project_id": { "anyOf": [ { @@ -4877,7 +4891,7 @@ "WebhookCreate": { "properties": { "events": { - "description": "Events to subscribe to. Empty list = all events. Valid values: ['cleanup.completed', 'job.canceled', 'job.cleaned', 'job.completed', 'job.failed', 'job.processing', 'job.published', 'job.uploaded']", + "description": "Events to subscribe to. For customer organizations, empty means all customer events (['job.published', 'job.uploaded']). Internal lifecycle events remain available only to Kanopy staff. Valid values: ['cleanup.completed', 'job.canceled', 'job.cleaned', 'job.completed', 'job.failed', 'job.processing', 'job.published', 'job.uploaded']", "items": { "type": "string" }, @@ -5092,6 +5106,7 @@ "type": "null" } ], + "description": "Deprecated; receiver response bodies are never retained or returned", "title": "Response Body" }, "response_status": { @@ -5123,7 +5138,6 @@ "job_id", "attempt", "response_status", - "response_body", "error", "delivered_at", "success" @@ -5223,6 +5237,31 @@ "title": "WebhookPublic", "type": "object" }, + "WebhookSecretRotated": { + "properties": { + "previous_secret_valid_until": { + "format": "date-time", + "title": "Previous Secret Valid Until", + "type": "string" + }, + "secret": { + "title": "Secret", + "type": "string" + }, + "webhook_id": { + "format": "uuid", + "title": "Webhook Id", + "type": "string" + } + }, + "required": [ + "webhook_id", + "secret", + "previous_secret_valid_until" + ], + "title": "WebhookSecretRotated", + "type": "object" + }, "WebhookUpdate": { "properties": { "events": { @@ -8351,7 +8390,8 @@ }, "/upload/inspect-action-video": { "post": { - "description": "Inspect an action-camera video to extract GPS coordinates for gating uploads.\n\nAlso extracts the full GPS track for use in reconstruction, since the frontend's\ngopro-telemetry library has issues with GPS9 format from Hero 9+ cameras.", + "deprecated": true, + "description": "Deprecated: action-cam GPS extraction happens during server upload prep on\nthe raw object (worker/server_upload_prep.py); this endpoint requires a\nduplicate full upload and parses the file twice. No frontend callers remain.\n\nInspect an action-camera video to extract GPS coordinates for gating uploads.", "operationId": "inspect_action_video", "requestBody": { "content": { @@ -8821,6 +8861,54 @@ ] } }, + "/webhooks/{webhook_id}/rotate-secret": { + "post": { + "operationId": "rotate_webhook_secret", + "parameters": [ + { + "in": "path", + "name": "webhook_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Webhook Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookSecretRotated" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Rotate a webhook signing secret", + "tags": [ + "webhooks" + ] + } + }, "/webhooks/{webhook_id}/test": { "post": { "description": "Immediately sends a `webhook.test` event to the registered URL. Use this to verify your endpoint is reachable and signature verification works.", From 8183792ac9005ead05fa2e5870f50d4c4d9b16d4 Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Fri, 21 Aug 2026 20:21:21 +0000 Subject: [PATCH 08/13] Harden SDK transfer behavior --- README.md | 10 +- src/kanopy/client.py | 157 ++++++++++++++++++++++------- tests/fixtures/openapi.public.json | 63 ++++++++++++ tests/test_client.py | 54 ++++++++++ 4 files changed, 247 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index e1749a1..481fc50 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,15 @@ upload = kanopy.upload_large( The default uses 64 MiB parts, four parallel workers, and three attempts per part. `part_size`, `max_workers`, and `part_retries` are configurable. Memory -use is approximately `part_size * max_workers` while transfers are active. +is bounded to 512 MiB across workers and each buffered part is capped at 256 +MiB. Failed transfers explicitly abort their storage upload instead of waiting +for server cleanup. + +Idempotent reads automatically retry transient network failures and HTTP +408/425/429/5xx responses with capped exponential backoff and jitter. Mutating +requests are never retried unless they carry an idempotency key. Downloads are +written to a temporary file, length-checked when available, and atomically +renamed so a partial response cannot replace a valid export. `upload_large` sends the original source bytes without client-side compression. For declared `drone` and `action_cam` uploads it requests background server diff --git a/src/kanopy/client.py b/src/kanopy/client.py index d497e51..d57d1a4 100644 --- a/src/kanopy/client.py +++ b/src/kanopy/client.py @@ -6,7 +6,10 @@ import io import json import math +import os +import random import time +from uuid import uuid4 import warnings from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor, as_completed @@ -33,6 +36,10 @@ MAX_MULTIPART_PARTS = 10_000 MAX_MULTIPART_PART_SIZE = 5 * 1024 * 1024 * 1024 MAX_MULTIPART_OBJECT_SIZE = 5 * 1024 * 1024 * 1024 * 1024 +MAX_BUFFERED_PART_SIZE = 256 * 1024 * 1024 +MAX_MULTIPART_BUFFER_MEMORY = 512 * 1024 * 1024 +RETRYABLE_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504}) +RETRYABLE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) JsonObject = dict[str, Any] ProgressCallback = Callable[[int, int], None] VALID_CAPTURE_DEVICES = frozenset({"drone", "action_cam", "phone"}) @@ -78,6 +85,11 @@ "/upload/complete-presigned-multipart", "complete_presigned_multipart_upload", ), + "abort_presigned_multipart_upload": ( + "post", + "/upload/abort-presigned-multipart", + "abort_presigned_multipart_upload", + ), "list_project_trees": ("get", "/projects/{project_id}/trees", "list_project_trees"), "get_project_tree": ( "get", @@ -178,9 +190,24 @@ def close(self) -> None: self._upload_client.close() def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + normalized_method = method.upper() + headers = kwargs.get("headers") or {} + retryable = normalized_method in RETRYABLE_METHODS or any( + str(name).lower() == "idempotency-key" for name in headers + ) for attempt in range(MAX_RATE_LIMIT_RETRIES + 1): - response = self._client.request(method, path.lstrip("/"), **kwargs) - if response.status_code == 429 and attempt < MAX_RATE_LIMIT_RETRIES: + try: + response = self._client.request(method, path.lstrip("/"), **kwargs) + except httpx.TransportError: + if not retryable or attempt >= MAX_RATE_LIMIT_RETRIES: + raise + time.sleep(self._backoff_seconds(attempt)) + continue + if ( + retryable + and response.status_code in RETRYABLE_STATUS_CODES + and attempt < MAX_RATE_LIMIT_RETRIES + ): time.sleep(self._retry_after_seconds(response, attempt)) continue if response.is_error: @@ -193,11 +220,19 @@ def _retry_after_seconds(response: httpx.Response, attempt: int) -> float: """Honor the server's Retry-After, falling back to capped backoff.""" raw = response.headers.get("Retry-After") try: - seconds = float(raw) if raw is not None else 2.0 * 2**attempt + seconds = ( + float(raw) if raw is not None else Kanopy._backoff_seconds(attempt) + ) except ValueError: - seconds = 2.0 * 2**attempt + seconds = Kanopy._backoff_seconds(attempt) return max(0.0, min(seconds, MAX_RETRY_AFTER_SECONDS)) + @staticmethod + def _backoff_seconds(attempt: int) -> float: + return min( + MAX_RETRY_AFTER_SECONDS, (1.0 * 2**attempt) + random.uniform(0.0, 0.5) + ) + def _json(self, method: str, path: str, **kwargs: Any) -> Any: response = self._request(method, path, **kwargs) if response.status_code == 204 or not response.content: @@ -543,6 +578,15 @@ def complete_presigned_multipart_upload( ) ) + def abort_presigned_multipart_upload( + self, *, job_id: str, s3_key: str, upload_id: str + ) -> None: + self._json( + "POST", + "/upload/abort-presigned-multipart", + json={"job_id": job_id, "s3_key": s3_key, "upload_id": upload_id}, + ) + def upload_multipart( self, video: str | PathLike[str], @@ -596,6 +640,10 @@ def upload_multipart( ) if part_size > MAX_MULTIPART_PART_SIZE: raise ValueError("part_size must not exceed 5 GiB") + if part_size > MAX_BUFFERED_PART_SIZE: + raise ValueError( + "part_size must not exceed 256 MiB in the buffered uploader" + ) if max_workers < 1 or max_workers > 32: raise ValueError("max_workers must be in the range 1..32") if part_retries < 1: @@ -603,6 +651,10 @@ def upload_multipart( required_part_size = math.ceil(total_size / MAX_MULTIPART_PARTS) effective_part_size = max(part_size, required_part_size) + if effective_part_size > MAX_BUFFERED_PART_SIZE: + raise ValueError( + "video requires multipart parts larger than the SDK's 256 MiB memory bound" + ) part_count = math.ceil(total_size / effective_part_size) init_options = dict(job_options) @@ -622,30 +674,48 @@ def upload_multipart( uploaded: list[dict[str, Any]] = [] transferred = 0 - with ThreadPoolExecutor(max_workers=min(max_workers, part_count)) as executor: - futures = { - executor.submit( - self._upload_part, - path, - offset=(part_number - 1) * effective_part_size, - size=min( - effective_part_size, - total_size - (part_number - 1) * effective_part_size, - ), - job_id=job_id, - s3_key=s3_key, - upload_id=upload_id, - part_number=part_number, - attempts=part_retries, - ): part_number - for part_number in range(1, part_count + 1) - } - for future in as_completed(futures): - part, byte_count = future.result() - uploaded.append(part) - transferred += byte_count - if progress is not None: - progress(transferred, total_size) + bounded_workers = min( + max_workers, + part_count, + max(1, MAX_MULTIPART_BUFFER_MEMORY // effective_part_size), + ) + try: + with ThreadPoolExecutor(max_workers=bounded_workers) as executor: + futures = { + executor.submit( + self._upload_part, + path, + offset=(part_number - 1) * effective_part_size, + size=min( + effective_part_size, + total_size - (part_number - 1) * effective_part_size, + ), + job_id=job_id, + s3_key=s3_key, + upload_id=upload_id, + part_number=part_number, + attempts=part_retries, + ): part_number + for part_number in range(1, part_count + 1) + } + for future in as_completed(futures): + part, byte_count = future.result() + uploaded.append(part) + transferred += byte_count + if progress is not None: + progress(transferred, total_size) + except Exception: + try: + self.abort_presigned_multipart_upload( + job_id=job_id, s3_key=s3_key, upload_id=upload_id + ) + except Exception as abort_error: + warnings.warn( + f"Multipart upload cleanup failed: {type(abort_error).__name__}", + RuntimeWarning, + stacklevel=2, + ) + raise uploaded.sort(key=lambda part: int(part["PartNumber"])) effective_completion_fields = dict(completion_fields or {}) @@ -1080,11 +1150,7 @@ def _download( if response.is_error: response.read() raise KanopyError.from_response(response) - target.parent.mkdir(parents=True, exist_ok=True) - with target.open("wb") as output: - for chunk in response.iter_bytes(): - output.write(chunk) - return target + return self._write_download_atomically(response, target) return self._download_url(location, target) def _download_url(self, url: str, destination: str | PathLike[str]) -> Path: @@ -1097,11 +1163,30 @@ def _download_url(self, url: str, destination: str | PathLike[str]) -> Path: f"Export storage returned HTTP {response.status_code}", status_code=response.status_code, ) - target.parent.mkdir(parents=True, exist_ok=True) - with target.open("wb") as output: + return self._write_download_atomically(response, target) + + @staticmethod + def _write_download_atomically(response: httpx.Response, target: Path) -> Path: + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f".{target.name}.{uuid4().hex}.part") + written = 0 + try: + with temporary.open("wb") as output: for chunk in response.iter_bytes(): output.write(chunk) - return target + written += len(chunk) + output.flush() + os.fsync(output.fileno()) + raw_length = response.headers.get("Content-Length") + if raw_length is not None and int(raw_length) != written: + raise IOError( + f"Incomplete download: expected {raw_length} bytes, received {written}" + ) + os.replace(temporary, target) + return target + except Exception: + temporary.unlink(missing_ok=True) + raise def _all_inventory_items( self, kind: str, project_id: str, *, job_id: str | None diff --git a/tests/fixtures/openapi.public.json b/tests/fixtures/openapi.public.json index 04f3881..970be9b 100644 --- a/tests/fixtures/openapi.public.json +++ b/tests/fixtures/openapi.public.json @@ -1,6 +1,29 @@ { "components": { "schemas": { + "AbortMultipartRequest": { + "properties": { + "job_id": { + "title": "Job Id", + "type": "string" + }, + "s3_key": { + "title": "S3 Key", + "type": "string" + }, + "upload_id": { + "title": "Upload Id", + "type": "string" + } + }, + "required": [ + "job_id", + "s3_key", + "upload_id" + ], + "title": "AbortMultipartRequest", + "type": "object" + }, "AuditEventPage": { "properties": { "events": { @@ -8194,6 +8217,46 @@ ] } }, + "/upload/abort-presigned-multipart": { + "post": { + "description": "Abort exactly the active multipart upload owned by this job.", + "operationId": "abort_presigned_multipart_upload", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AbortMultipartRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Abort Presigned Multipart Upload", + "tags": [ + "upload" + ] + } + }, "/upload/complete-presigned": { "post": { "description": "Finalize a presigned upload and dispatch its processing path.\n\nMetadata files (flight logs, GPS tracks) are still uploaded via this endpoint\nsince they are small enough for multipart form. Raw uploads return as soon as\nserver-side preparation is queued; transcoded uploads complete synchronously.", diff --git a/tests/test_client.py b/tests/test_client.py index 1f6b6cb..e856a39 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -116,6 +116,39 @@ def handler(request: httpx.Request) -> httpx.Response: assert error.request_id == "request-123" +def test_idempotent_reads_retry_transient_failures(monkeypatch) -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts < 3: + return httpx.Response(503) + return httpx.Response(200, json={"id": "project-1"}) + + monkeypatch.setattr("kanopy.client.time.sleep", lambda _seconds: None) + with Kanopy("key", transport=httpx.MockTransport(handler)) as client: + assert client.get_project("project-1")["id"] == "project-1" + + assert attempts == 3 + + +def test_unsafe_mutations_are_not_retried_without_idempotency_key(monkeypatch) -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + return httpx.Response(503) + + monkeypatch.setattr("kanopy.client.time.sleep", lambda _seconds: None) + with Kanopy("key", transport=httpx.MockTransport(handler)) as client: + with pytest.raises(KanopyError): + client.create_project(name="No duplicate") + + assert attempts == 1 + + def test_upload_queues_processing_without_manual_submit() -> None: requests: list[httpx.Request] = [] @@ -198,6 +231,21 @@ def handler(request: httpx.Request) -> httpx.Response: assert destination.read_bytes() == b"tree_id,risk\n1,high\n" +def test_incomplete_download_does_not_replace_existing_destination(tmp_path) -> None: + destination = tmp_path / "trees.csv" + destination.write_bytes(b"previous complete export") + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"short", headers={"Content-Length": "20"}) + + with Kanopy("key", transport=httpx.MockTransport(handler)) as client: + with pytest.raises(IOError, match="Incomplete download"): + client.download_job_table("job-1", "trees", destination) + + assert destination.read_bytes() == b"previous complete export" + assert not list(tmp_path.glob("*.part")) + + def test_download_project_export_waits_then_fetches_presigned_url(tmp_path) -> None: polls = iter(["running", "completed"]) api_requests: list[httpx.Request] = [] @@ -537,7 +585,10 @@ def test_large_upload_reports_part_failure(tmp_path) -> None: video = tmp_path / "flight.mp4" video.write_bytes(b"video") + api_paths: list[str] = [] + def api_handler(request: httpx.Request) -> httpx.Response: + api_paths.append(request.url.path) if request.url.path.endswith("/init-presigned-multipart"): return httpx.Response( 201, @@ -548,6 +599,8 @@ def api_handler(request: httpx.Request) -> httpx.Response: "content_type": "video/mp4", }, ) + if request.url.path.endswith("/abort-presigned-multipart"): + return httpx.Response(204) return httpx.Response( 200, json={"url": "https://storage.test/part", "part_number": 1}, @@ -570,6 +623,7 @@ def api_handler(request: httpx.Request) -> httpx.Response: assert caught.value.part_number == 1 assert caught.value.status_code == 500 + assert api_paths[-1].endswith("/abort-presigned-multipart") def test_list_job_outputs_returns_the_outputs_array() -> None: From 57684eb72bcbfd57788daf1a3396b9e7cc37e4a8 Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Sat, 22 Aug 2026 18:21:27 +0000 Subject: [PATCH 09/13] Fix SDK quality gate --- src/kanopy/client.py | 8 +++++--- tests/test_client.py | 16 ++++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/kanopy/client.py b/src/kanopy/client.py index d57d1a4..def3004 100644 --- a/src/kanopy/client.py +++ b/src/kanopy/client.py @@ -9,7 +9,6 @@ import os import random import time -from uuid import uuid4 import warnings from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor, as_completed @@ -17,6 +16,7 @@ from os import PathLike from pathlib import Path from typing import Any, BinaryIO +from uuid import uuid4 from xml.sax.saxutils import escape import httpx @@ -709,7 +709,9 @@ def upload_multipart( self.abort_presigned_multipart_upload( job_id=job_id, s3_key=s3_key, upload_id=upload_id ) - except Exception as abort_error: + # Cleanup is best-effort and must never mask the original part failure, + # including failures raised by a custom HTTP transport. + except Exception as abort_error: # noqa: BLE001 warnings.warn( f"Multipart upload cleanup failed: {type(abort_error).__name__}", RuntimeWarning, @@ -1179,7 +1181,7 @@ def _write_download_atomically(response: httpx.Response, target: Path) -> Path: os.fsync(output.fileno()) raw_length = response.headers.get("Content-Length") if raw_length is not None and int(raw_length) != written: - raise IOError( + raise OSError( f"Incomplete download: expected {raw_length} bytes, received {written}" ) os.replace(temporary, target) diff --git a/tests/test_client.py b/tests/test_client.py index e856a39..770c9ec 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -142,9 +142,11 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(503) monkeypatch.setattr("kanopy.client.time.sleep", lambda _seconds: None) - with Kanopy("key", transport=httpx.MockTransport(handler)) as client: - with pytest.raises(KanopyError): - client.create_project(name="No duplicate") + with ( + Kanopy("key", transport=httpx.MockTransport(handler)) as client, + pytest.raises(KanopyError), + ): + client.create_project(name="No duplicate") assert attempts == 1 @@ -238,9 +240,11 @@ def test_incomplete_download_does_not_replace_existing_destination(tmp_path) -> def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, content=b"short", headers={"Content-Length": "20"}) - with Kanopy("key", transport=httpx.MockTransport(handler)) as client: - with pytest.raises(IOError, match="Incomplete download"): - client.download_job_table("job-1", "trees", destination) + with ( + Kanopy("key", transport=httpx.MockTransport(handler)) as client, + pytest.raises(OSError, match="Incomplete download"), + ): + client.download_job_table("job-1", "trees", destination) assert destination.read_bytes() == b"previous complete export" assert not list(tmp_path.glob("*.part")) From 82fced1787576282aa12a82dddeb5e79c72dd0df Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Sat, 22 Aug 2026 18:22:40 +0000 Subject: [PATCH 10/13] Release version 0.6.0 --- src/kanopy/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kanopy/_version.py b/src/kanopy/_version.py index e43f669..768ae87 100644 --- a/src/kanopy/_version.py +++ b/src/kanopy/_version.py @@ -4,4 +4,4 @@ without importing the package __init__ (which imports client.py back). """ -__version__ = "0.5.0" +__version__ = "0.6.0" From 64fe7340d8de9ce5f752cb76e8a898084f14afc0 Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Tue, 25 Aug 2026 01:41:16 +0000 Subject: [PATCH 11/13] Add upload_heartbeat for batch uploads that reserve jobs up front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job created ahead of its upload is held for 30 minutes; if no video data arrives it is treated as abandoned and deleted. A script that reserves jobs for a batch and uploads them one at a time outlives that hold, and the jobs still waiting are deleted mid-run — every later call for them fails with a 404 that says nothing about why. The SDK's own upload paths are not exposed: create_job is immediately followed by the transfer, and once a multipart upload opens the job is no longer a candidate for expiry. This is for callers who reserve ids ahead of time, which the idempotent upload_request_id flow encourages. Also sync the contract fixture with the backend's published surface. Co-Authored-By: Claude Opus 5 --- src/kanopy/client.py | 22 ++++++++ tests/fixtures/openapi.public.json | 90 ++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/src/kanopy/client.py b/src/kanopy/client.py index def3004..c3737cf 100644 --- a/src/kanopy/client.py +++ b/src/kanopy/client.py @@ -69,6 +69,7 @@ "update_job": ("patch", "/jobs/{job_id}", "update_job"), "delete_job": ("delete", "/jobs/{job_id}", "delete_job"), "cancel_job": ("post", "/jobs/{job_id}/cancel", "cancel_job"), + "upload_heartbeat": ("post", "/jobs/upload-heartbeat", "upload_heartbeat"), "upload": ("post", "/upload", "upload"), "init_presigned_multipart_upload": ( "post", @@ -405,6 +406,27 @@ def delete_job(self, job_id: str) -> None: def cancel_job(self, job_id: str) -> JsonObject: return self._object(self._json("POST", f"/jobs/{job_id}/cancel")) + def upload_heartbeat(self, job_ids: Sequence[str]) -> JsonObject: + """Renew the hold on jobs that are reserved but not yet uploading. + + A job created ahead of its upload is held for 30 minutes; if no video + data has arrived by then it is treated as abandoned and deleted. A + script that reserves jobs for a batch up front and uploads them one at + a time will outlive that hold, and the jobs still waiting are deleted + mid-run. Call this every few minutes with the ids still waiting. + + Jobs whose upload has already begun need no heartbeat. The returned + ``missing`` ids no longer exist and cannot be revived — re-create the + job to retry that video. + """ + return self._object( + self._json( + "POST", + "/jobs/upload-heartbeat", + json={"job_ids": [str(job_id) for job_id in job_ids]}, + ) + ) + def wait_for_job( self, job_id: str, diff --git a/tests/fixtures/openapi.public.json b/tests/fixtures/openapi.public.json index 970be9b..b915f0d 100644 --- a/tests/fixtures/openapi.public.json +++ b/tests/fixtures/openapi.public.json @@ -4871,6 +4871,49 @@ "title": "TreePublic", "type": "object" }, + "UploadHeartbeatRequest": { + "properties": { + "job_ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Job Ids", + "type": "array" + } + }, + "required": [ + "job_ids" + ], + "title": "UploadHeartbeatRequest", + "type": "object" + }, + "UploadHeartbeatResponse": { + "properties": { + "missing": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Missing", + "type": "array" + }, + "refreshed": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Refreshed", + "type": "array" + } + }, + "required": [ + "refreshed", + "missing" + ], + "title": "UploadHeartbeatResponse", + "type": "object" + }, "ValidationError": { "properties": { "ctx": { @@ -6204,6 +6247,53 @@ ] } }, + "/jobs/upload-heartbeat": { + "post": { + "description": "Keep reserved-but-not-yet-started uploads from expiring.\n\nA job created ahead of its upload is held for 30 minutes; if no video data\nhas arrived by then it is treated as abandoned and deleted. That is the\nright default for a closed browser tab, but it also expires the tail of any\nbatch that reserves its jobs up front and uploads them one at a time.\n\nCall this periodically \u2014 every few minutes \u2014 with the jobs still waiting to\nupload, and their hold is renewed. Jobs whose upload has already begun are\nunaffected and need no heartbeat.\n\n``refreshed`` lists the jobs whose hold was renewed. ``missing`` lists ids\nthat no longer exist: already expired, deleted, or not yours. Those cannot\nbe revived, so treat them as failed and re-create the job to retry.", + "operationId": "upload_heartbeat", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadHeartbeatRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadHeartbeatResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Upload Heartbeat", + "tags": [ + "jobs" + ] + } + }, "/jobs/{job_id}": { "delete": { "description": "Delete a job from the database and remove associated files from storage.\nIf the job is not in a terminal state (COMPLETED, FAILED, CANCELED),\ncancel it first before deletion. This includes jobs in COMPRESSING, UPLOADING,\nPENDING, or PROCESSING status.", From dd73e7fa3e2106808c0bb327ae3bf2f097daa83d Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Tue, 8 Sep 2026 17:13:43 +0000 Subject: [PATCH 12/13] Automate SDK releases from main --- .github/workflows/auto-release.yml | 62 ++++++++++++++++++++++++++++++ .github/workflows/release.yml | 1 + RELEASING.md | 14 +++++-- src/kanopy/_version.py | 2 +- 4 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/auto-release.yml diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..fdafa3f --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,62 @@ +name: Auto-tag SDK release + +on: + push: + branches: [main] + +concurrency: + group: auto-tag-sdk-release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + tag-and-dispatch: + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Find a release version + id: version + shell: bash + run: | + python scripts/check_version.py + version="$(python -c 'import runpy; print(runpy.run_path("src/kanopy/_version.py")["__version__"])')" + tag="v${version}" + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + if git show-ref --verify --quiet "refs/tags/${tag}"; then + if ! git diff --quiet "${tag}..HEAD" -- \ + src setup.cfg pyproject.toml MANIFEST.in README.md LICENSE NOTICE; then + echo "::error::Release-relevant files changed, but ${tag} already exists. Bump src/kanopy/_version.py." + exit 1 + fi + echo "No release-relevant files changed since ${tag}; nothing to publish." + echo "publish=false" >> "${GITHUB_OUTPUT}" + else + echo "Version ${version} will be released as ${tag}." + echo "publish=true" >> "${GITHUB_OUTPUT}" + fi + - name: Create release tag + if: steps.version.outputs.publish == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.version.outputs.tag }} + run: | + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/tags/${RELEASE_TAG}" \ + -f sha="${GITHUB_SHA}" + - name: Dispatch trusted release workflow + if: steps.version.outputs.publish == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.version.outputs.tag }} + run: gh workflow run release.yml --repo "${GITHUB_REPOSITORY}" --ref "${RELEASE_TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f670ba..89677be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,7 @@ name: Release to PyPI on: push: tags: ["v*"] + workflow_dispatch: permissions: contents: read diff --git a/RELEASING.md b/RELEASING.md index 1ec52f1..14d296f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -14,18 +14,24 @@ token belongs in GitHub, a local environment, or this repository. ## Release checklist -1. Choose the version and update both `setup.cfg` and - `src/kanopy/__init__.py`. +1. Choose the version and update `src/kanopy/_version.py`. 2. From the private Kanopy development repository, run `./scripts/sync_public_openapi.sh`, then review and commit the SDK fixture. 3. Run `./scripts/run_local_smoke.sh` against the current backend Docker build. 4. Run `python -m pytest`, Ruff checks, and a clean package build. 5. Merge through the protected `main` branch and confirm CI passes. -6. Create and push the matching tag, for example `v0.1.0`. -7. Review and approve the `pypi` deployment environment. +6. Confirm that `Auto-tag SDK release` created the matching tag and dispatched + `Release to PyPI`. The workflow fails instead of silently skipping when + release-relevant files changed without a version bump. +7. Review and approve the `pypi` deployment environment. This is the only + manual publishing step. 8. Install the exact published version into a clean environment and run the read-only identity/project-list smoke check against staging. +If automatic dispatch fails after the tag is created, run `Release to PyPI` +manually against that tag from the GitHub Actions page. A direct `v*` tag push +also remains supported. + The release job rebuilds nothing after approval: the publishing job downloads the exact wheel and source distribution produced and inspected by the build job. Trusted Publishing also creates PyPI attestations by default. diff --git a/src/kanopy/_version.py b/src/kanopy/_version.py index 768ae87..94aa35b 100644 --- a/src/kanopy/_version.py +++ b/src/kanopy/_version.py @@ -4,4 +4,4 @@ without importing the package __init__ (which imports client.py back). """ -__version__ = "0.6.0" +__version__ = "0.7.0" From 04cb1f2a5e9c6ccead56156804b4e54354d9d54f Mon Sep 17 00:00:00 2001 From: Nicolas Pfitzer Date: Fri, 18 Sep 2026 14:44:23 +0000 Subject: [PATCH 13/13] Refresh public API contract for route splitting and inventory updates --- tests/fixtures/openapi.public.json | 2831 +++++++++++++++++++++++++--- 1 file changed, 2518 insertions(+), 313 deletions(-) diff --git a/tests/fixtures/openapi.public.json b/tests/fixtures/openapi.public.json index b915f0d..6d1f31a 100644 --- a/tests/fixtures/openapi.public.json +++ b/tests/fixtures/openapi.public.json @@ -24,6 +24,209 @@ "title": "AbortMultipartRequest", "type": "object" }, + "AddressResult": { + "properties": { + "attribution": { + "title": "Attribution", + "type": "string" + }, + "attribution_url": { + "title": "Attribution Url", + "type": "string" + }, + "country": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country" + }, + "distance_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Distance M" + }, + "house_number": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "House Number" + }, + "label": { + "title": "Label", + "type": "string" + }, + "locality": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Locality" + }, + "matched_latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Matched Latitude" + }, + "matched_longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Matched Longitude" + }, + "postal_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Postal Code" + }, + "precision": { + "enum": [ + "address", + "street", + "locality" + ], + "title": "Precision", + "type": "string" + }, + "provider": { + "enum": [ + "aws", + "nominatim" + ], + "title": "Provider", + "type": "string" + }, + "query_latitude": { + "title": "Query Latitude", + "type": "number" + }, + "query_longitude": { + "title": "Query Longitude", + "type": "number" + }, + "region": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Region" + }, + "street": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Street" + } + }, + "required": [ + "label", + "provider", + "precision", + "query_latitude", + "query_longitude", + "attribution", + "attribution_url" + ], + "title": "AddressResult", + "type": "object" + }, + "AssetAddress": { + "properties": { + "result": { + "anyOf": [ + { + "$ref": "#/components/schemas/AddressResult" + }, + { + "type": "null" + } + ] + }, + "retry_after_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Retry After Seconds" + }, + "status": { + "default": "not_requested", + "enum": [ + "not_requested", + "unavailable", + "pending", + "ready", + "not_found", + "failed" + ], + "title": "Status", + "type": "string" + }, + "updated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" + } + }, + "title": "AssetAddress", + "type": "object" + }, "AuditEventPage": { "properties": { "events": { @@ -274,6 +477,7 @@ "title": "Gps Track File" }, "job_id": { + "description": "Job ID from init-presigned", "title": "Job Id", "type": "string" }, @@ -307,11 +511,9 @@ "type": "null" } ], - "description": "Original client filename of the video (for duplicate detection)", "title": "Original Filename" }, "parts_json": { - "description": "JSON array of {PartNumber, ETag}", "title": "Parts Json", "type": "string" }, @@ -450,12 +652,10 @@ "type": "null" } ], - "description": "Metadata file", "title": "Metadata" }, "metadata_files": { "default": [], - "description": "Additional flight logs", "items": { "contentMediaType": "application/octet-stream", "type": "string" @@ -472,7 +672,6 @@ "type": "null" } ], - "description": "Original client filename of the video (for duplicate detection)", "title": "Original Filename" }, "pole_height_m": { @@ -519,7 +718,6 @@ "properties": { "video": { "contentMediaType": "application/octet-stream", - "description": "Action camera video file (GoPro MP4 with embedded GPS/GPMF metadata)", "title": "Video", "type": "string" } @@ -534,7 +732,7 @@ "properties": { "metadata": { "contentMediaType": "application/octet-stream", - "description": "Flight log file (.txt or .csv)", + "description": "Flight log file", "title": "Metadata", "type": "string" }, @@ -547,7 +745,6 @@ "type": "null" } ], - "description": "Video creation time in seconds since epoch (UTC)", "title": "Video Creation Time" }, "video_duration": { @@ -559,7 +756,6 @@ "type": "null" } ], - "description": "Video duration in seconds. When provided alongside video_creation_time, the GPS track is trimmed to the video window so adaptive FPS reflects only the recorded segment of the flight.", "title": "Video Duration" } }, @@ -580,7 +776,6 @@ "type": "null" } ], - "description": "Capture device used for the footage (drone|action_cam|phone)", "title": "Capture Device" }, "circuit_clearance_m": { @@ -592,7 +787,6 @@ "type": "null" } ], - "description": "Optional vertical spacing between stacked circuits in metres for legacy wire fitting.", "title": "Circuit Clearance M" }, "circuit_count": { @@ -605,7 +799,6 @@ } ], "default": 1, - "description": "Number of vertically stacked circuits to fit per span for legacy wire fitting.", "title": "Circuit Count" }, "circuit_width_m": { @@ -617,7 +810,6 @@ "type": "null" } ], - "description": "Optional lateral width override for conductors within each circuit, in metres.", "title": "Circuit Width M" }, "conductor_class": { @@ -629,7 +821,6 @@ "type": "null" } ], - "description": "User-selected conductor class (e.g., Distribution|Sub-transmission|Transmission)", "title": "Conductor Class" }, "flight_latitude": { @@ -641,7 +832,6 @@ "type": "null" } ], - "description": "Latitude extracted from the flight log", "title": "Flight Latitude" }, "flight_location_title": { @@ -653,7 +843,6 @@ "type": "null" } ], - "description": "Location title derived from the flight log", "title": "Flight Location Title" }, "flight_longitude": { @@ -665,7 +854,6 @@ "type": "null" } ], - "description": "Longitude extracted from the flight log", "title": "Flight Longitude" }, "flight_state": { @@ -677,7 +865,6 @@ "type": "null" } ], - "description": "US state name derived from the flight log location", "title": "Flight State" }, "flight_state_abbr": { @@ -689,7 +876,6 @@ "type": "null" } ], - "description": "US state abbreviation derived from the flight log location", "title": "Flight State Abbr" }, "gps_track": { @@ -701,7 +887,6 @@ "type": "null" } ], - "description": "Canonical gps_track.json payload as a JSON form field (legacy/iOS direct upload)", "title": "Gps Track" }, "gps_track_file": { @@ -714,7 +899,6 @@ "type": "null" } ], - "description": "Canonical gps_track.json sidecar extracted on the client", "title": "Gps Track File" }, "is_360_video": { @@ -726,7 +910,6 @@ "type": "null" } ], - "description": "Whether this is a 360-degree video (action_cam only). Triggers frame splitting into multiple perspective views.", "title": "Is 360 Video" }, "job_id": { @@ -738,7 +921,6 @@ "type": "null" } ], - "description": "Existing job ID to update (if job was pre-created)", "title": "Job Id" }, "line_clearance": { @@ -750,7 +932,6 @@ "type": "null" } ], - "description": "Whether to enable line clearance pipeline (wire/pole segmentation, wire fitting, measurements). Defaults to True.", "title": "Line Clearance" }, "metadata": { @@ -763,12 +944,10 @@ "type": "null" } ], - "description": "Metadata describing the video flight", "title": "Metadata" }, "metadata_files": { "default": [], - "description": "Additional flight logs for batch drone uploads", "items": { "contentMediaType": "application/octet-stream", "type": "string" @@ -786,7 +965,6 @@ } ], "default": 2, - "description": "Number of phases to fit per span for legacy wire fitting.", "title": "Phase Count" }, "pole_height_m": { @@ -798,7 +976,6 @@ "type": "null" } ], - "description": "Reference pole height in metres for no-GPS scale recovery", "title": "Pole Height M" }, "project_id": { @@ -810,7 +987,6 @@ "type": "null" } ], - "description": "Frontend project ID", "title": "Project Id" }, "project_uuid": { @@ -822,7 +998,6 @@ "type": "null" } ], - "description": "Backend project ID", "title": "Project Uuid" }, "split_360_n_views": { @@ -834,7 +1009,6 @@ "type": "null" } ], - "description": "Number of perspective views to generate from each 360-degree frame (default: 6)", "title": "Split 360 N Views" }, "split_360_view_mode": { @@ -846,7 +1020,6 @@ "type": "null" } ], - "description": "360 split mode defining kept views (front|front&back|full). Overrides split_360_n_views when provided.", "title": "Split 360 View Mode" }, "title": { @@ -858,7 +1031,6 @@ "type": "null" } ], - "description": "User-facing job title", "title": "Title" }, "transcode_config": { @@ -870,7 +1042,6 @@ "type": "null" } ], - "description": "Adaptive transcode configuration used on the client (JSON encoded)", "title": "Transcode Config" }, "use_pole_scale_reference": { @@ -882,7 +1053,6 @@ "type": "null" } ], - "description": "No-GPS path: recover metric scale from pole_height_m instead of a GPS track", "title": "Use Pole Scale Reference" }, "video": { @@ -900,7 +1070,6 @@ "type": "null" } ], - "description": "User-selected voltage class (e.g., <13.2 kV|23-69 kV|115-345 kV)", "title": "Voltage Class" } }, @@ -920,6 +1089,55 @@ "title": "CaptureDevice", "type": "string" }, + "CategoryOption": { + "properties": { + "asset_types": { + "items": { + "enum": [ + "tree", + "pole" + ], + "type": "string" + }, + "title": "Asset Types", + "type": "array" + }, + "code": { + "enum": [ + "species", + "ai_assessment", + "measurements", + "point_cloud", + "location", + "wires", + "report", + "interface", + "other" + ], + "title": "Code", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "problems": { + "items": { + "$ref": "#/components/schemas/ProblemOption" + }, + "title": "Problems", + "type": "array" + } + }, + "required": [ + "code", + "label", + "asset_types", + "problems" + ], + "title": "CategoryOption", + "type": "object" + }, "CoordinatesRequest": { "properties": { "latitude": { @@ -938,130 +1156,566 @@ "title": "CoordinatesRequest", "type": "object" }, - "FlightLogInspectionResponse": { + "FeedbackCreate": { + "additionalProperties": false, "properties": { - "decrypted_csv": { - "default": false, - "title": "Decrypted Csv", - "type": "boolean" - }, - "gps_track": { + "asset_id": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "format": "uuid", + "type": "string" }, { "type": "null" } ], - "title": "Gps Track" + "title": "Asset Id" }, - "latitude": { + "asset_type": { "anyOf": [ { - "type": "number" + "enum": [ + "tree", + "pole" + ], + "type": "string" }, { "type": "null" } ], - "title": "Latitude" + "title": "Asset Type" }, - "location_found": { - "title": "Location Found", - "type": "boolean" + "category": { + "enum": [ + "species", + "ai_assessment", + "measurements", + "point_cloud", + "location", + "wires", + "report", + "interface", + "other" + ], + "title": "Category", + "type": "string" }, - "location_title": { + "chunk_index": { "anyOf": [ { - "type": "string" + "minimum": 0.0, + "type": "integer" }, { "type": "null" } ], - "title": "Location Title" + "title": "Chunk Index" }, - "longitude": { + "expected_value": { "anyOf": [ { - "type": "number" + "maxLength": 1000, + "type": "string" }, { "type": "null" } ], - "title": "Longitude" + "title": "Expected Value" }, - "message": { + "instance_id": { "anyOf": [ { - "type": "string" + "minimum": 0.0, + "type": "integer" }, { "type": "null" } ], - "title": "Message" + "title": "Instance Id" }, - "state": { + "job_id": { "anyOf": [ { + "format": "uuid", "type": "string" }, { "type": "null" } ], - "title": "State" + "title": "Job Id" }, - "state_abbr": { + "note": { + "maxLength": 5000, + "minLength": 3, + "title": "Note", + "type": "string" + }, + "observation_id": { "anyOf": [ { + "format": "uuid", "type": "string" }, { "type": "null" } ], - "title": "State Abbr" + "title": "Observation Id" }, - "transcode_config": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Transcode Config" + "problem_type": { + "enum": [ + "incorrect", + "missing", + "unexpected", + "duplicate", + "misaligned", + "incomplete", + "not_loading", + "unreadable", + "other" + ], + "title": "Problem Type", + "type": "string" + }, + "request_id": { + "description": "Client-generated UUID; reuse on retry to avoid duplicate reports.", + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "source": { + "default": "api", + "enum": [ + "preview", + "analytics_table", + "map", + "measurement_viewer", + "pdf", + "help", + "api" + ], + "title": "Source", + "type": "string" } }, "required": [ - "location_found" + "request_id", + "category", + "problem_type", + "note" ], - "title": "FlightLogInspectionResponse", + "title": "FeedbackCreate", "type": "object" }, - "HTTPValidationError": { + "FeedbackPage": { "properties": { - "detail": { + "items": { "items": { - "$ref": "#/components/schemas/ValidationError" + "$ref": "#/components/schemas/FeedbackPublic" }, - "title": "Detail", + "title": "Items", "type": "array" - } - }, - "title": "HTTPValidationError", - "type": "object" - }, - "InviteCodePublic": { - "properties": { - "code": { + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor" + } + }, + "required": [ + "items", + "next_cursor" + ], + "title": "FeedbackPage", + "type": "object" + }, + "FeedbackPublic": { + "properties": { + "asset_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Asset Id" + }, + "asset_type": { + "anyOf": [ + { + "enum": [ + "tree", + "pole" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Asset Type" + }, + "category": { + "enum": [ + "species", + "ai_assessment", + "measurements", + "point_cloud", + "location", + "wires", + "report", + "interface", + "other" + ], + "title": "Category", + "type": "string" + }, + "chunk_index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Chunk Index" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "expected_value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expected Value" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "instance_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Instance Id" + }, + "job_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Job Id" + }, + "job_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Job Name" + }, + "note": { + "title": "Note", + "type": "string" + }, + "observation_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Observation Id" + }, + "problem_type": { + "enum": [ + "incorrect", + "missing", + "unexpected", + "duplicate", + "misaligned", + "incomplete", + "not_loading", + "unreadable", + "other" + ], + "title": "Problem Type", + "type": "string" + }, + "project_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Project Id" + }, + "project_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Project Name" + }, + "schema_version": { + "title": "Schema Version", + "type": "integer" + }, + "scope": { + "enum": [ + "platform", + "project", + "job", + "asset" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "enum": [ + "preview", + "analytics_table", + "map", + "measurement_viewer", + "pdf", + "help", + "api" + ], + "title": "Source", + "type": "string" + }, + "status": { + "enum": [ + "open", + "in_progress", + "resolved" + ], + "title": "Status", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "id", + "schema_version", + "scope", + "project_id", + "project_name", + "job_name", + "asset_type", + "asset_id", + "job_id", + "observation_id", + "instance_id", + "chunk_index", + "category", + "problem_type", + "source", + "note", + "expected_value", + "status", + "created_at", + "updated_at" + ], + "title": "FeedbackPublic", + "type": "object" + }, + "FeedbackTaxonomy": { + "properties": { + "categories": { + "items": { + "$ref": "#/components/schemas/CategoryOption" + }, + "title": "Categories", + "type": "array" + }, + "schema_version": { + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "categories" + ], + "title": "FeedbackTaxonomy", + "type": "object" + }, + "FlightLogInspectionResponse": { + "properties": { + "decrypted_csv": { + "default": false, + "title": "Decrypted Csv", + "type": "boolean" + }, + "gps_track": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Gps Track" + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latitude" + }, + "location_found": { + "title": "Location Found", + "type": "boolean" + }, + "location_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location Title" + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Longitude" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "State" + }, + "state_abbr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "State Abbr" + }, + "transcode_config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Transcode Config" + } + }, + "required": [ + "location_found" + ], + "title": "FlightLogInspectionResponse", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "InviteCodePublic": { + "properties": { + "code": { "anyOf": [ { "type": "string" @@ -1799,6 +2453,8 @@ "PENDING", "LOCALIZING", "AWAITING_POLE_HEIGHT", + "AWAITING_ROUTE_REVIEW", + "SPLIT", "PROCESSING", "COMPLETED", "FAILED", @@ -2084,6 +2740,20 @@ "title": "Limit", "type": "integer" }, + "pole_type_counts": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Pole Type Counts" + }, "poles": { "items": { "$ref": "#/components/schemas/PolePublic" @@ -2108,6 +2778,11 @@ "PoleObservationPublic": { "description": "One job-scoped observation within a pole response.", "properties": { + "added_by_user": { + "default": false, + "title": "Added By User", + "type": "boolean" + }, "all_frames": { "items": { "additionalProperties": true, @@ -2172,6 +2847,17 @@ ], "title": "Best Camera Pose C2W" }, + "captured_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Captured At" + }, "center_in_best_camera_frame": { "anyOf": [ { @@ -2211,6 +2897,17 @@ ], "title": "Chunk Index" }, + "effective_analysis_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Effective Analysis Id" + }, "frames": { "items": { "additionalProperties": true, @@ -2245,6 +2942,17 @@ ], "title": "Job Instance Id" }, + "job_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Job Title" + }, "label_id": { "anyOf": [ { @@ -2391,6 +3099,11 @@ "PolePublic": { "description": "Merged pole entity with all observations.", "properties": { + "added_by_user": { + "default": false, + "title": "Added By User", + "type": "boolean" + }, "ai_analyses": { "items": { "additionalProperties": true, @@ -2418,6 +3131,28 @@ ], "title": "Altitude" }, + "assessment_as_of": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Assessment As Of" + }, + "assessment_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Assessment Job Id" + }, "best_camera_job_id": { "anyOf": [ { @@ -2474,6 +3209,31 @@ ], "title": "Center World" }, + "effective_analysis_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Effective Analysis Id" + }, + "effective_analysis_ids_by_job_instance": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": "Effective Analysis Ids By Job Instance", + "type": "object" + }, "frames": { "items": { "additionalProperties": true, @@ -2493,6 +3253,18 @@ ], "title": "Height M" }, + "historical_worst": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Historical Worst" + }, "instance_ids_by_job_id": { "additionalProperties": true, "title": "Instance Ids By Job Id", @@ -2576,6 +3348,9 @@ ], "title": "Longitude" }, + "nearest_address": { + "$ref": "#/components/schemas/AssetAddress" + }, "observations": { "items": { "$ref": "#/components/schemas/PoleObservationPublic" @@ -2583,6 +3358,17 @@ "title": "Observations", "type": "array" }, + "org_scope_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org Scope Id" + }, "pole_id": { "title": "Pole Id", "type": "string" @@ -3003,6 +3789,35 @@ "title": "PresignedUploadInitRequest", "type": "object" }, + "ProblemOption": { + "properties": { + "code": { + "enum": [ + "incorrect", + "missing", + "unexpected", + "duplicate", + "misaligned", + "incomplete", + "not_loading", + "unreadable", + "other" + ], + "title": "Code", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + } + }, + "required": [ + "code", + "label" + ], + "title": "ProblemOption", + "type": "object" + }, "ProjectCreate": { "properties": { "description": { @@ -3179,6 +3994,11 @@ ], "title": "Archived At" }, + "clearance_priority_basis": { + "default": "radial", + "title": "Clearance Priority Basis", + "type": "string" + }, "clearance_standards": { "anyOf": [ { @@ -3317,6 +4137,21 @@ }, "ProjectUpdate": { "properties": { + "clearance_priority_basis": { + "anyOf": [ + { + "enum": [ + "radial", + "rectangular" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Clearance Priority Basis" + }, "clearance_standards": { "anyOf": [ { @@ -3408,6 +4243,74 @@ "title": "ProjectUpdate", "type": "object" }, + "RoutePlanCommit": { + "properties": { + "excludedStarts": { + "items": { + "type": "integer" + }, + "maxItems": 50, + "title": "Excludedstarts", + "type": "array" + }, + "requestId": { + "format": "uuid", + "title": "Requestid", + "type": "string" + }, + "revision": { + "maxLength": 64, + "minLength": 1, + "title": "Revision", + "type": "string" + }, + "splitPoints": { + "items": { + "type": "number" + }, + "maxItems": 49, + "title": "Splitpoints", + "type": "array" + } + }, + "required": [ + "revision", + "requestId" + ], + "title": "RoutePlanCommit", + "type": "object" + }, + "RoutePlanRequest": { + "properties": { + "excludedStarts": { + "items": { + "type": "integer" + }, + "maxItems": 50, + "title": "Excludedstarts", + "type": "array" + }, + "revision": { + "maxLength": 64, + "minLength": 1, + "title": "Revision", + "type": "string" + }, + "splitPoints": { + "items": { + "type": "number" + }, + "maxItems": 49, + "title": "Splitpoints", + "type": "array" + } + }, + "required": [ + "revision" + ], + "title": "RoutePlanRequest", + "type": "object" + }, "SpanListResponse": { "properties": { "limit": { @@ -3418,6 +4321,11 @@ "title": "Skip", "type": "integer" }, + "sort_applied": { + "default": "position", + "title": "Sort Applied", + "type": "string" + }, "spans": { "items": { "$ref": "#/components/schemas/SpanResponse" @@ -3428,6 +4336,16 @@ "total": { "title": "Total", "type": "integer" + }, + "totals": { + "anyOf": [ + { + "$ref": "#/components/schemas/SpanTotals" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -3483,6 +4401,9 @@ "title": "Encroaching Tree Count", "type": "integer" }, + "from_address": { + "$ref": "#/components/schemas/AssetAddress" + }, "from_attachment_point": { "anyOf": [ { @@ -3680,6 +4601,9 @@ ], "title": "Pruning Volume M3" }, + "to_address": { + "$ref": "#/components/schemas/AssetAddress" + }, "to_attachment_point": { "anyOf": [ { @@ -3796,6 +4720,14 @@ ], "title": "To Radius M" }, + "tree_clearance_measurements": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Tree Clearance Measurements", + "type": "array" + }, "tree_count": { "default": 0, "title": "Tree Count", @@ -3860,43 +4792,266 @@ ], "title": "Vegetated Distance Mean M" }, - "vegetated_distance_min_m": { + "vegetated_distance_min_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Vegetated Distance Min M" + }, + "verdict_acceptable_tree_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Verdict Acceptable Tree Count" + }, + "verdict_applied_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Verdict Applied At" + }, + "verdict_band": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Verdict Band" + }, + "verdict_critical_tree_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Verdict Critical Tree Count" + }, + "verdict_encroaching_tree_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Verdict Encroaching Tree Count" + }, + "verdict_high_tree_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Verdict High Tree Count" + }, + "verdict_medium_tree_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Verdict Medium Tree Count" + }, + "verdict_pruning_volume_m3": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Verdict Pruning Volume M3" + }, + "verdict_tree_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Verdict Tree Count" + }, + "verdict_tvd_hazard_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Verdict Tvd Hazard M" + }, + "verdict_tvd_maintenance_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Verdict Tvd Maintenance M" + }, + "verdict_tvd_priority_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Verdict Tvd Priority M" + }, + "wire_instance_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Wire Instance Id" + }, + "worked": { + "default": false, + "title": "Worked", + "type": "boolean" + } + }, + "required": [ + "job_id", + "connection_id" + ], + "title": "SpanResponse", + "type": "object" + }, + "SpanTotals": { + "description": "Sums over the whole collection, not the page.\n\nThe span table's footer totals the project. Once the table stops holding\nevery span it can no longer add them up itself, and totalling the page\nwould silently show a page-sized number where a project-sized one belongs.", + "properties": { + "complete": { + "default": true, + "title": "Complete", + "type": "boolean" + }, + "critical_tree_count": { + "default": 0, + "title": "Critical Tree Count", + "type": "integer" + }, + "encroaching_tree_count": { + "default": 0, + "title": "Encroaching Tree Count", + "type": "integer" + }, + "high_tree_count": { + "default": 0, + "title": "High Tree Count", + "type": "integer" + }, + "length_m": { + "default": 0.0, + "title": "Length M", + "type": "number" + }, + "pruning_volume_m3": { + "default": 0.0, + "title": "Pruning Volume M3", + "type": "number" + }, + "span_count": { + "default": 0, + "title": "Span Count", + "type": "integer" + }, + "tree_count": { + "default": 0, + "title": "Tree Count", + "type": "integer" + }, + "tvd_hazard_m": { + "default": 0.0, + "title": "Tvd Hazard M", + "type": "number" + }, + "tvd_maintenance_m": { + "default": 0.0, + "title": "Tvd Maintenance M", + "type": "number" + }, + "tvd_priority_m": { + "default": 0.0, + "title": "Tvd Priority M", + "type": "number" + } + }, + "title": "SpanTotals", + "type": "object" + }, + "TreeListResponse": { + "properties": { + "band_counts": { "anyOf": [ { - "type": "number" + "additionalProperties": { + "type": "integer" + }, + "type": "object" }, { "type": "null" } ], - "title": "Vegetated Distance Min M" + "title": "Band Counts" }, - "wire_instance_id": { + "fall_in_risk_counts": { "anyOf": [ { - "type": "integer" + "additionalProperties": { + "type": "integer" + }, + "type": "object" }, { "type": "null" } ], - "title": "Wire Instance Id" + "title": "Fall In Risk Counts" }, - "worked": { - "default": false, - "title": "Worked", - "type": "boolean" - } - }, - "required": [ - "job_id", - "connection_id" - ], - "title": "SpanResponse", - "type": "object" - }, - "TreeListResponse": { - "properties": { "limit": { "default": 0, "title": "Limit", @@ -4064,6 +5219,28 @@ "title": "Frames", "type": "array" }, + "governing_lateral_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Governing Lateral M" + }, + "governing_vertical_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Governing Vertical M" + }, "job_id": { "title": "Job Id", "type": "string" @@ -4253,6 +5430,18 @@ ], "title": "Pruning Hull Volume M3" }, + "rectangular_clearance_profile": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Rectangular Clearance Profile" + }, "tree_hull_volume_m3": { "anyOf": [ { @@ -4479,6 +5668,28 @@ "title": "Frames", "type": "array" }, + "governing_lateral_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Governing Lateral M" + }, + "governing_vertical_m": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Governing Vertical M" + }, "historical_worst": { "anyOf": [ { @@ -4591,6 +5802,9 @@ ], "title": "Min Underhang Height M" }, + "nearest_address": { + "$ref": "#/components/schemas/AssetAddress" + }, "observations": { "items": { "$ref": "#/components/schemas/TreeObservationPublic" @@ -4598,6 +5812,17 @@ "title": "Observations", "type": "array" }, + "org_scope_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org Scope Id" + }, "project_id": { "anyOf": [ { @@ -4700,6 +5925,18 @@ ], "title": "Pruning Recommended Volume M3" }, + "rectangular_clearance_profile": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Rectangular Clearance Profile" + }, "risk_breakdown": { "anyOf": [ { @@ -4797,6 +6034,17 @@ "title": "Segmentation Base Urls By Job Id", "type": "object" }, + "species_top": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Species Top" + }, "status": { "title": "Status", "type": "string" @@ -5974,17 +7222,192 @@ } }, { - "in": "path", - "name": "user_id", - "required": true, + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Remove Organization Member", + "tags": [ + "auth" + ] + }, + "patch": { + "operationId": "update_organization_member", + "parameters": [ + { + "in": "path", + "name": "organization_id", + "required": true, + "schema": { + "title": "Organization Id", + "type": "string" + } + }, + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationMemberUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationMemberPublic" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Organization Member", + "tags": [ + "auth" + ] + } + }, + "/feedback": { + "get": { + "description": "Your reports and statuses in your current organization, including asset notes.\n\nThese are your original submissions; staff notes and snapshots stay private.", + "operationId": "list_my_feedback", + "parameters": [ + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "open", + "in_progress", + "resolved" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "in": "query", + "name": "scope", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "platform", + "project", + "job", + "asset" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scope" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, "schema": { - "title": "User Id", - "type": "string" + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" } } ], "responses": { - "204": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackPage" + } + } + }, "description": "Successful Response" }, "422": { @@ -6003,49 +7426,30 @@ "HTTPBearer": [] } ], - "summary": "Remove Organization Member", + "summary": "List My Feedback", "tags": [ - "auth" + "feedback" ] }, - "patch": { - "operationId": "update_organization_member", - "parameters": [ - { - "in": "path", - "name": "organization_id", - "required": true, - "schema": { - "title": "Organization Id", - "type": "string" - } - }, - { - "in": "path", - "name": "user_id", - "required": true, - "schema": { - "title": "User Id", - "type": "string" - } - } - ], + "post": { + "description": "Submit general platform feedback, without a project or asset.", + "operationId": "submit_general_feedback", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationMemberUpdate" + "$ref": "#/components/schemas/FeedbackCreate" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationMemberPublic" + "$ref": "#/components/schemas/FeedbackPublic" } } }, @@ -6067,9 +7471,35 @@ "HTTPBearer": [] } ], - "summary": "Update Organization Member", + "summary": "Submit General Feedback", "tags": [ - "auth" + "feedback" + ] + } + }, + "/feedback/taxonomy": { + "get": { + "operationId": "get_feedback_taxonomy", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackTaxonomy" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Feedback Taxonomy", + "tags": [ + "feedback" ] } }, @@ -6154,13 +7584,13 @@ } }, { - "description": "Exclude jobs still being compressed/uploaded by the client (no playable asset yet). Keeps pagination consistent with the video library, which hides those statuses.", + "description": "Exclude client uploads and source recordings replaced by split jobs. Keeps pagination consistent with the video library.", "in": "query", "name": "library_only", "required": false, "schema": { "default": false, - "description": "Exclude jobs still being compressed/uploaded by the client (no playable asset yet). Keeps pagination consistent with the video library, which hides those statuses.", + "description": "Exclude client uploads and source recordings replaced by split jobs. Keeps pagination consistent with the video library.", "title": "Library Only", "type": "boolean" } @@ -6587,31 +8017,201 @@ "HTTPBearer": [] } ], - "summary": "Download Job Folder Zip", + "summary": "Download Job Folder Zip", + "tags": [ + "jobs" + ] + } + }, + "/jobs/{job_id}/outputs": { + "get": { + "description": "Discover what a job produced without hard-coding zip layouts. Each entry carries a stable `id` you can pass to GET /jobs/{job_id}/outputs/{output_id} to download that output on its own.\n\nReturns an empty list \u2014 not a 404 \u2014 for a job that has not produced anything yet. Customer integrations should request outputs after a `job.published` webhook.\n\nGeoreferenced point clouds are reprojected at packaging time and are not listed individually; fetch them via GET /jobs/{job_id}/folder-zip with `include=point_cloud` and `point_cloud_epsg`.", + "operationId": "list_job_output_catalog", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobOutputsResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List a job's downloadable outputs", + "tags": [ + "jobs" + ] + } + }, + "/jobs/{job_id}/outputs/{output_id}": { + "get": { + "description": "Download a single output by the `id` returned from GET /jobs/{job_id}/outputs. Point clouds on S3-backed jobs answer with a 302 to a short-lived presigned URL, so follow redirects.", + "operationId": "download_job_output", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Job Id" + } + }, + { + "in": "path", + "name": "output_id", + "required": true, + "schema": { + "title": "Output Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The output's bytes." + }, + "302": { + "description": "Redirect to a short-lived presigned download URL." + }, + "404": { + "description": "The job has no output with that id." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Download one job output", + "tags": [ + "jobs" + ] + } + }, + "/jobs/{job_id}/pole-height": { + "post": { + "description": "Resume server-side preparation for raw footage with no GPS telemetry.", + "operationId": "resume_job_with_pole_height", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Job Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoleHeightResumeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobApiPublic" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Resume Job With Pole Height", "tags": [ "jobs" ] } }, - "/jobs/{job_id}/outputs": { + "/jobs/{job_id}/route-review": { "get": { - "description": "Discover what a job produced without hard-coding zip layouts. Each entry carries a stable `id` you can pass to GET /jobs/{job_id}/outputs/{output_id} to download that output on its own.\n\nReturns an empty list \u2014 not a 404 \u2014 for a job that has not produced anything yet. Customer integrations should request outputs after a `job.published` webhook.\n\nGeoreferenced point clouds are reprojected at packaging time and are not listed individually; fetch them via GET /jobs/{job_id}/folder-zip with `include=point_cloud` and `point_cloud_epsg`.", - "operationId": "list_job_output_catalog", + "operationId": "get_route_review", "parameters": [ { "in": "path", "name": "job_id", "required": true, "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Job Id" + "format": "uuid", + "title": "Job Id", + "type": "string" } } ], @@ -6619,9 +8219,7 @@ "200": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/JobOutputsResponse" - } + "schema": {} } }, "description": "Successful Response" @@ -6642,52 +8240,43 @@ "HTTPBearer": [] } ], - "summary": "List a job's downloadable outputs", + "summary": "Get Route Review", "tags": [ "jobs" ] - } - }, - "/jobs/{job_id}/outputs/{output_id}": { - "get": { - "description": "Download a single output by the `id` returned from GET /jobs/{job_id}/outputs. Point clouds on S3-backed jobs answer with a 302 to a short-lived presigned URL, so follow redirects.", - "operationId": "download_job_output", + }, + "put": { + "operationId": "save_route_review", "parameters": [ { "in": "path", "name": "job_id", "required": true, "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Job Id" - } - }, - { - "in": "path", - "name": "output_id", - "required": true, - "schema": { - "title": "Output Id", + "format": "uuid", + "title": "Job Id", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoutePlanRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "The output's bytes." - }, - "302": { - "description": "Redirect to a short-lived presigned download URL." - }, - "404": { - "description": "The job has no output with that id." + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" }, "422": { "content": { @@ -6705,16 +8294,15 @@ "HTTPBearer": [] } ], - "summary": "Download one job output", + "summary": "Save Route Review", "tags": [ "jobs" ] } }, - "/jobs/{job_id}/pole-height": { + "/jobs/{job_id}/route-review/commit": { "post": { - "description": "Resume server-side preparation for raw footage with no GPS telemetry.", - "operationId": "resume_job_with_pole_height", + "operationId": "submit_route_review", "parameters": [ { "in": "path", @@ -6731,7 +8319,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PoleHeightResumeRequest" + "$ref": "#/components/schemas/RoutePlanCommit" } } }, @@ -6741,9 +8329,7 @@ "200": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/JobApiPublic" - } + "schema": {} } }, "description": "Successful Response" @@ -6764,7 +8350,7 @@ "HTTPBearer": [] } ], - "summary": "Resume Job With Pole Height", + "summary": "Submit Route Review", "tags": [ "jobs" ] @@ -7034,7 +8620,192 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectPublic" + "$ref": "#/components/schemas/ProjectPublic" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Project", + "tags": [ + "projects" + ] + }, + "patch": { + "operationId": "update_project", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "title": "Project Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectPublic" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Project", + "tags": [ + "projects" + ] + } + }, + "/projects/{project_id}/archive": { + "post": { + "description": "Mark a project completed/archived and migrate its job data to S3 in the background.", + "operationId": "archive_project", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectPublic" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Archive Project", + "tags": [ + "projects" + ] + } + }, + "/projects/{project_id}/exports": { + "post": { + "description": "Start (or reuse) an async customer-facing project download build.\n\nReturns immediately with an export row to poll via GET /exports/{id}.\nA completed export with the same content fingerprint is reused so repeat\ndownloads don't rebuild the archive; any data change (job edit, publish,\nnew job) changes the fingerprint and forces a fresh build.", + "operationId": "create_project_export", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "query", + "name": "include", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Include" + } + }, + { + "in": "query", + "name": "point_cloud_epsg", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Point Cloud Epsg" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectExportPublic" } } }, @@ -7056,40 +8827,44 @@ "HTTPBearer": [] } ], - "summary": "Get Project", + "summary": "Create Project Export", "tags": [ "projects" ] - }, - "patch": { - "operationId": "update_project", + } + }, + "/projects/{project_id}/exports/{export_id}": { + "get": { + "description": "Poll an export build; includes a presigned download_url once completed.", + "operationId": "get_project_export", "parameters": [ { "in": "path", "name": "project_id", "required": true, "schema": { + "format": "uuid", "title": "Project Id", "type": "string" } + }, + { + "in": "path", + "name": "export_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Export Id", + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectUpdate" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectPublic" + "$ref": "#/components/schemas/ProjectExportPublic" } } }, @@ -7111,16 +8886,16 @@ "HTTPBearer": [] } ], - "summary": "Update Project", + "summary": "Get Project Export", "tags": [ "projects" ] } }, - "/projects/{project_id}/archive": { - "post": { - "description": "Mark a project completed/archived and migrate its job data to S3 in the background.", - "operationId": "archive_project", + "/projects/{project_id}/feedback": { + "get": { + "description": "List your own reports in an accessible project, newest changes first.", + "operationId": "list_project_feedback", "parameters": [ { "in": "path", @@ -7131,6 +8906,117 @@ "title": "Project Id", "type": "string" } + }, + { + "in": "query", + "name": "asset_id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Asset Id" + } + }, + { + "in": "query", + "name": "job_id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Job Id" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "open", + "in_progress", + "resolved" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "in": "query", + "name": "category", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "species", + "ai_assessment", + "measurements", + "point_cloud", + "location", + "wires", + "report", + "interface", + "other" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } } ], "responses": { @@ -7138,7 +9024,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectPublic" + "$ref": "#/components/schemas/FeedbackPage" } } }, @@ -7160,16 +9046,14 @@ "HTTPBearer": [] } ], - "summary": "Archive Project", + "summary": "List Project Feedback", "tags": [ - "projects" + "feedback" ] - } - }, - "/projects/{project_id}/exports": { + }, "post": { - "description": "Start (or reuse) an async customer-facing project download build.\n\nReturns immediately with an export row to poll via GET /exports/{id}.\nA completed export with the same content fingerprint is reused so repeat\ndownloads don't rebuild the archive; any data change (job edit, publish,\nnew job) changes the fingerprint and forces a fresh build.", - "operationId": "create_project_export", + "description": "Report a project, accessible job, or visible tree/pole result. Reuse request_id on retry.", + "operationId": "submit_feedback", "parameters": [ { "in": "path", @@ -7180,46 +9064,24 @@ "title": "Project Id", "type": "string" } - }, - { - "in": "query", - "name": "include", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Include" - } - }, - { - "in": "query", - "name": "point_cloud_epsg", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Point Cloud Epsg" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackCreate" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectExportPublic" + "$ref": "#/components/schemas/FeedbackPublic" } } }, @@ -7241,16 +9103,15 @@ "HTTPBearer": [] } ], - "summary": "Create Project Export", + "summary": "Submit Feedback", "tags": [ - "projects" + "feedback" ] } }, - "/projects/{project_id}/exports/{export_id}": { + "/projects/{project_id}/feedback/{feedback_id}": { "get": { - "description": "Poll an export build; includes a presigned download_url once completed.", - "operationId": "get_project_export", + "operationId": "get_project_feedback", "parameters": [ { "in": "path", @@ -7264,11 +9125,11 @@ }, { "in": "path", - "name": "export_id", + "name": "feedback_id", "required": true, "schema": { "format": "uuid", - "title": "Export Id", + "title": "Feedback Id", "type": "string" } } @@ -7278,7 +9139,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectExportPublic" + "$ref": "#/components/schemas/FeedbackPublic" } } }, @@ -7300,9 +9161,9 @@ "HTTPBearer": [] } ], - "summary": "Get Project Export", + "summary": "Get Project Feedback", "tags": [ - "projects" + "feedback" ] } }, @@ -7572,7 +9433,7 @@ } }, { - "description": "Comma-separated chunk indices to filter observations to (e.g. '0,1,2'). When set, only poles with at least one observation in these chunks are returned, and observation rows in the response are limited to these chunks. Requires job_id.", + "description": "Comma-separated chunk indices; requires job_id", "in": "query", "name": "chunk_indices", "required": false, @@ -7585,7 +9446,7 @@ "type": "null" } ], - "description": "Comma-separated chunk indices to filter observations to (e.g. '0,1,2'). When set, only poles with at least one observation in these chunks are returned, and observation rows in the response are limited to these chunks. Requires job_id.", + "description": "Comma-separated chunk indices; requires job_id", "title": "Chunk Indices" } }, @@ -7617,7 +9478,7 @@ } }, { - "description": "Opaque keyset cursor. Pass an empty value to start cursor pagination.", + "description": "Opaque keyset cursor. Pass an empty value to start.", "in": "query", "name": "cursor", "required": false, @@ -7630,7 +9491,7 @@ "type": "null" } ], - "description": "Opaque keyset cursor. Pass an empty value to start cursor pagination.", + "description": "Opaque keyset cursor. Pass an empty value to start.", "title": "Cursor" } }, @@ -7671,38 +9532,104 @@ } }, { - "description": "Include best-camera pose data and any camera-pose filesystem fallback", + "description": "Include best-camera pose data", "in": "query", "name": "include_camera_poses", "required": false, "schema": { "default": false, - "description": "Include best-camera pose data and any camera-pose filesystem fallback", + "description": "Include best-camera pose data", "title": "Include Camera Poses", "type": "boolean" } }, { - "description": "Return only the total pole count without pole payloads", + "description": "Return only the total pole count", + "in": "query", + "name": "count_only", + "required": false, + "schema": { + "default": false, + "description": "Return only the total pole count", + "title": "Count Only", + "type": "boolean" + } + }, + { + "description": "Skip the COUNT query for later pages", + "in": "query", + "name": "skip_count", + "required": false, + "schema": { + "default": false, + "description": "Skip the COUNT query for later pages", + "title": "Skip Count", + "type": "boolean" + } + }, + { + "description": "Column to order by", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Column to order by", + "title": "Sort" + } + }, + { + "description": "'asc' or 'desc' (default: asc)", + "in": "query", + "name": "sort_dir", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "'asc' or 'desc' (default: asc)", + "title": "Sort Dir" + } + }, + { + "description": "Filter by pole type: 'utility' or 'other'", "in": "query", - "name": "count_only", + "name": "pole_type", "required": false, "schema": { - "default": false, - "description": "Return only the total pole count without pole payloads", - "title": "Count Only", - "type": "boolean" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by pole type: 'utility' or 'other'", + "title": "Pole Type" } }, { - "description": "Skip the COUNT query and return total=-1. Use for non-first pages when paginating in parallel.", + "description": "Include collection-wide filter facet counts", "in": "query", - "name": "skip_count", + "name": "include_facets", "required": false, "schema": { "default": false, - "description": "Skip the COUNT query and return total=-1. Use for non-first pages when paginating in parallel.", - "title": "Skip Count", + "description": "Include collection-wide filter facet counts", + "title": "Include Facets", "type": "boolean" } } @@ -7796,7 +9723,7 @@ } }, { - "description": "Opaque keyset cursor. Pass an empty value to start cursor pagination.", + "description": "Opaque keyset cursor. Pass an empty value to start.", "in": "query", "name": "cursor", "required": false, @@ -7809,7 +9736,7 @@ "type": "null" } ], - "description": "Opaque keyset cursor. Pass an empty value to start cursor pagination.", + "description": "Opaque keyset cursor. Pass an empty value to start.", "title": "Cursor" } }, @@ -7824,6 +9751,54 @@ "title": "Skip Count", "type": "boolean" } + }, + { + "description": "Column to order by", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Column to order by", + "title": "Sort" + } + }, + { + "description": "'asc' or 'desc' (default: desc)", + "in": "query", + "name": "sort_dir", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "'asc' or 'desc' (default: desc)", + "title": "Sort Dir" + } + }, + { + "description": "Include collection-wide span totals", + "in": "query", + "name": "include_totals", + "required": false, + "schema": { + "default": false, + "description": "Include collection-wide span totals", + "title": "Include Totals", + "type": "boolean" + } } ], "responses": { @@ -7861,7 +9836,6 @@ }, "/projects/{project_id}/trees": { "get": { - "description": "Return all trees for a project with merged observation data.", "operationId": "list_project_trees", "parameters": [ { @@ -7893,7 +9867,7 @@ } }, { - "description": "Comma-separated chunk indices to filter observations to (e.g. '0,1,2'). When set, only trees with at least one observation in these chunks are returned, and observation rows in the response are limited to these chunks. Requires job_id.", + "description": "Comma-separated chunk indices to filter observations to (e.g. '0,1,2'). Requires job_id.", "in": "query", "name": "chunk_indices", "required": false, @@ -7906,7 +9880,7 @@ "type": "null" } ], - "description": "Comma-separated chunk indices to filter observations to (e.g. '0,1,2'). When set, only trees with at least one observation in these chunks are returned, and observation rows in the response are limited to these chunks. Requires job_id.", + "description": "Comma-separated chunk indices to filter observations to (e.g. '0,1,2'). Requires job_id.", "title": "Chunk Indices" } }, @@ -7938,7 +9912,7 @@ } }, { - "description": "Opaque keyset cursor. Pass an empty value to start cursor pagination.", + "description": "Opaque keyset cursor. Pass an empty value to start.", "in": "query", "name": "cursor", "required": false, @@ -7951,7 +9925,7 @@ "type": "null" } ], - "description": "Opaque keyset cursor. Pass an empty value to start cursor pagination.", + "description": "Opaque keyset cursor. Pass an empty value to start.", "title": "Cursor" } }, @@ -7992,52 +9966,154 @@ } }, { - "description": "Include vegetation-analysis entries projected from raw payloads", + "description": "Include vegetation-analysis entries", "in": "query", "name": "include_veg_analyses", "required": false, "schema": { "default": false, - "description": "Include vegetation-analysis entries projected from raw payloads", + "description": "Include vegetation-analysis entries", "title": "Include Veg Analyses", "type": "boolean" } }, { - "description": "Include best-camera pose data and any camera-pose filesystem fallback", + "description": "Include best-camera pose data", "in": "query", "name": "include_camera_poses", "required": false, "schema": { "default": false, - "description": "Include best-camera pose data and any camera-pose filesystem fallback", + "description": "Include best-camera pose data", "title": "Include Camera Poses", "type": "boolean" } }, { - "description": "Return only the total tree count without tree payloads", + "description": "Return only the total tree count", "in": "query", "name": "count_only", "required": false, "schema": { "default": false, - "description": "Return only the total tree count without tree payloads", + "description": "Return only the total tree count", "title": "Count Only", "type": "boolean" } }, { - "description": "Skip the COUNT query and return total=-1. Use for non-first pages when paginating in parallel.", + "description": "Skip the COUNT query for later pages", "in": "query", "name": "skip_count", "required": false, "schema": { "default": false, - "description": "Skip the COUNT query and return total=-1. Use for non-first pages when paginating in parallel.", + "description": "Skip the COUNT query for later pages", "title": "Skip Count", "type": "boolean" } + }, + { + "description": "Column to order by", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Column to order by", + "title": "Sort" + } + }, + { + "description": "'asc' or 'desc' (default: asc)", + "in": "query", + "name": "sort_dir", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "'asc' or 'desc' (default: asc)", + "title": "Sort Dir" + } + }, + { + "description": "Match trees by display id prefix or species", + "in": "query", + "name": "search", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Match trees by display id prefix or species", + "title": "Search" + } + }, + { + "description": "Comma-separated clearance bands to include", + "in": "query", + "name": "bands", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Comma-separated clearance bands to include", + "title": "Bands" + } + }, + { + "description": "Comma-separated fall-in-risk states to include", + "in": "query", + "name": "fall_in_risk", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Comma-separated fall-in-risk states to include", + "title": "Fall In Risk" + } + }, + { + "description": "Include collection-wide filter facet counts", + "in": "query", + "name": "include_facets", + "required": false, + "schema": { + "default": false, + "description": "Include collection-wide filter facet counts", + "title": "Include Facets", + "type": "boolean" + } } ], "responses": { @@ -8209,6 +10285,140 @@ ] } }, + "/projects/{project_id}/trees/{tree_id}/address": { + "get": { + "description": "Read the stored address/status without making a geocoding request.", + "operationId": "get_project_tree_address", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "tree_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Tree Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetAddress" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Project Tree Address", + "tags": [ + "trees" + ] + }, + "post": { + "description": "Resolve and persist this tree's address on demand. Cached results are reused.\n\nIntended for individual user requests. External requests share Nominatim's\napplication-wide rate budget; this is not a bulk geocoding endpoint.", + "operationId": "request_project_tree_address", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "tree_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Tree Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetAddress" + } + } + }, + "description": "Successful Response" + }, + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetAddress" + } + } + }, + "description": "A concurrent lookup is in progress; poll GET." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetAddress" + } + } + }, + "description": "Temporary lookup failure; respect Retry-After." + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Request Project Tree Address", + "tags": [ + "trees" + ] + } + }, "/projects/{project_id}/unarchive": { "post": { "description": "Restore an archived project back to the live workspace.", @@ -8260,7 +10470,6 @@ }, "/upload": { "post": { - "description": "Accept video + metadata, persist them locally, and register or update a job.", "operationId": "upload", "requestBody": { "content": { @@ -8349,7 +10558,6 @@ }, "/upload/complete-presigned": { "post": { - "description": "Finalize a presigned upload and dispatch its processing path.\n\nMetadata files (flight logs, GPS tracks) are still uploaded via this endpoint\nsince they are small enough for multipart form. Raw uploads return as soon as\nserver-side preparation is queued; transcoded uploads complete synchronously.", "operationId": "complete_presigned_upload", "requestBody": { "content": { @@ -8398,7 +10606,6 @@ }, "/upload/complete-presigned-multipart": { "post": { - "description": "Finalize an S3 multipart upload and run the same finalize logic as\n``complete_presigned_upload``.", "operationId": "complete_presigned_multipart_upload", "requestBody": { "content": { @@ -8544,7 +10751,6 @@ "/upload/inspect-action-video": { "post": { "deprecated": true, - "description": "Deprecated: action-cam GPS extraction happens during server upload prep on\nthe raw object (worker/server_upload_prep.py); this endpoint requires a\nduplicate full upload and parses the file twice. No frontend callers remain.\n\nInspect an action-camera video to extract GPS coordinates for gating uploads.", "operationId": "inspect_action_video", "requestBody": { "content": { @@ -8591,7 +10797,6 @@ }, "/upload/inspect-flight-log": { "post": { - "description": "Inspect a flight log to extract GPS coordinates and optionally reverse-geocode a title.\n\nThis endpoint is intended to run as soon as the user selects the flight log file,\nso the frontend can block uploads until coordinates are confirmed.", "operationId": "inspect_flight_log", "requestBody": { "content": {