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/scripts/check_dist.py b/scripts/check_dist.py index eb85b04..9f2f37c 100644 --- a/scripts/check_dist.py +++ b/scripts/check_dist.py @@ -69,7 +69,8 @@ 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}", f".dist-info/licenses/{required}")) + for name in wheel_contents ): raise SystemExit(f"wheel is missing {required}") 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" 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(