diff --git a/docs/geoprocessing-job-lifecycle.md b/docs/geoprocessing-job-lifecycle.md new file mode 100644 index 0000000..4762d01 --- /dev/null +++ b/docs/geoprocessing-job-lifecycle.md @@ -0,0 +1,50 @@ +# Geoprocessing job lifecycle + +## Server contract first + +`HonuaGeoprocessing` operates registered OGC API Processes. It does not upload +or execute arbitrary Python code. + +| Item | Contract | +| --- | --- | +| Capability | `process.ogc-api-processes` | +| Submit | `POST /ogc/processes/processes/{processId}/execution` | +| Status | `GET /ogc/processes/jobs/{jobId}` | +| Results | `GET /ogc/processes/jobs/{jobId}/results` | +| Cancel | `DELETE /ogc/processes/jobs/{jobId}` (best effort) | +| Auth | `HonuaClient` API key, bearer token, or refreshable auth provider | +| Fixture | `honua-samples/jobs/geoprocessing-job-lifecycle.json#job-page-fixture:geometry-buffer-v1` | +| Maturity | Source preview; not yet published to PyPI | + +Inspect the process description before constructing `inputs`; those schemas are +defined by the registered server process. The focused example therefore takes +an explicit process id and JSON input object rather than embedding a payload +that only works for one deployment: + +```bash +python examples/geoprocessing_job_lifecycle.py \ + --process-id geometry.buffer \ + --inputs-json '{"inputGeoJson":"{}","distance":100}' +``` + +That command belongs to the example script. The product `honua` CLI currently +has no process/job subcommands. + +The example calls the real +[`HonuaGeoprocessing.submit_inputs`](reference/honua-sdk/clients.md#honua_sdk.geoprocessing.HonuaGeoprocessing.submit_inputs), +[`wait`](reference/honua-sdk/clients.md#honua_sdk.geoprocessing.HonuaGeoprocessing.wait), and +[`results`](reference/honua-sdk/clients.md#honua_sdk.geoprocessing.HonuaGeoprocessing.results) +methods. The wait has a deadline and performs a best-effort dismiss on timeout. +Use `dismiss(job_id)` only from an explicit operator cancellation action. + +The shared, server-first JS/Python/.NET task contract, expected status sequence, +and semantic assertion live in `honua-samples/jobs/geoprocessing-job-lifecycle.json`. + +## Custom Python batch jobs + +Custom-code authoring is a separate production project. The current server +contract is AWS-Batch-only and pins runtime, allowlisted repository, full commit +SHA, `module:function` entrypoint, dependency manifest, declared scope, and a +server-assigned output prefix. The Python SDK has no custom-code authoring or +submission API, and no current Studio Python editor/publish flow is admitted. +Do not substitute `submit_inputs()` for that missing product surface. diff --git a/docs/reference/honua-sdk/clients.md b/docs/reference/honua-sdk/clients.md index 7e514b6..6ed95f1 100644 --- a/docs/reference/honua-sdk/clients.md +++ b/docs/reference/honua-sdk/clients.md @@ -31,3 +31,11 @@ and [Retries and timeouts](../../retries-and-timeouts.md) for the retry policy, ::: honua_sdk.AsyncHonuaClient ::: honua_sdk.HonuaGeocodingClient ::: honua_sdk.AsyncHonuaGeocodingClient + +## OGC API Processes + +The process clients operate process definitions already registered on Honua. +They do not provide a custom-code upload, packaging, or local execution API. + +::: honua_sdk.geoprocessing.HonuaGeoprocessing +::: honua_sdk.geoprocessing.AsyncHonuaGeoprocessing diff --git a/examples/README.md b/examples/README.md index c036adf..9dbd8c1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,6 +26,7 @@ The checked-in examples target the seeded `test_service` layer used by local Hon | FastAPI spatial service | App developer exposing async Honua-backed API routes | `fastapi`, `uvicorn` | `uvicorn examples.fastapi_spatial_service:create_app --factory --reload` | local `/features` and `/summary` routes | | Async feature service | App developer fronting Honua with a pooled async client | `fastapi`, `uvicorn` | `uvicorn examples.async_feature_service.service:create_app --factory --reload` | local `/services` and `/features` routes | | Protocol clients | SDK developer checking protocol wrappers | core SDK, optional `honua-sdk[grpc]` and `honua-sdk[geopandas]` | `python examples/protocol_clients.py` | printed protocol response examples | +| Geoprocessing job lifecycle | Developer submitting one registered OGC process and collecting its result | core SDK | `python examples/geoprocessing_job_lifecycle.py --process-id --inputs-json ` | job receipt and result JSON | ## Validation diff --git a/examples/geoprocessing_job_lifecycle.py b/examples/geoprocessing_job_lifecycle.py new file mode 100644 index 0000000..052a203 --- /dev/null +++ b/examples/geoprocessing_job_lifecycle.py @@ -0,0 +1,88 @@ +"""Submit one registered OGC process and wait for its bounded result. + +This example deliberately accepts the process id and input object from the +caller. Process input schemas are server-defined; inventing a universal buffer +payload here would make the example look portable when it is not. +""" + +from __future__ import annotations + +import argparse +import json +import os +from collections.abc import Mapping +from typing import Any + +import httpx + +from honua_sdk import HonuaClient + + +def run_job( + base_url: str, + process_id: str, + inputs: Mapping[str, Any], + *, + api_key: str | None = None, + poll_interval: float = 0.5, + deadline_seconds: float = 30.0, + transport: httpx.BaseTransport | None = None, +) -> dict[str, Any]: + """Submit, wait, and fetch results for one registered process. + + ``deadline_seconds`` bounds polling. The SDK performs a best-effort + dismiss when that wait expires. An operator-triggered cancellation should + call ``client.geoprocessing().dismiss(job_id)`` explicitly. + """ + + options: dict[str, Any] = {"api_key": api_key, "timeout": 10.0} + if transport is not None: + options["transport"] = transport + + with HonuaClient(base_url, **options) as client: + geoprocessing = client.geoprocessing() + submitted = geoprocessing.submit_inputs(process_id, dict(inputs)) + terminal = geoprocessing.wait( + submitted, + poll_interval=poll_interval, + timeout=deadline_seconds, + ) + return geoprocessing.results(terminal.job_id) + + +def _object_json(value: str) -> dict[str, Any]: + parsed = json.loads(value) + if not isinstance(parsed, dict): + raise argparse.ArgumentTypeError("--inputs-json must decode to a JSON object") + return parsed + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Submit a registered Honua OGC process and collect its result.", + ) + parser.add_argument( + "--base-url", + default=os.environ.get("HONUA_BASE_URL", "http://127.0.0.1:8080"), + ) + parser.add_argument("--api-key", default=os.environ.get("HONUA_API_KEY")) + parser.add_argument("--process-id", required=True) + parser.add_argument("--inputs-json", required=True, type=_object_json) + parser.add_argument("--poll-interval", type=float, default=0.5) + parser.add_argument("--deadline-seconds", type=float, default=30.0) + args = parser.parse_args() + + result = run_job( + args.base_url, + args.process_id, + args.inputs_json, + api_key=args.api_key, + poll_interval=args.poll_interval, + deadline_seconds=args.deadline_seconds, + ) + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mkdocs.yml b/mkdocs.yml index 57d3bef..6a72891 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - Protocol examples: protocol-examples.md - Protocol parity: protocol-parity.md - Authentication: auth.md + - Geoprocessing job lifecycle: geoprocessing-job-lifecycle.md - Compatibility: compatibility.md - SDK capability coverage: sdk-coverage.md - Troubleshooting: troubleshooting.md diff --git a/tests/test_geoprocessing_job_lifecycle_example.py b/tests/test_geoprocessing_job_lifecycle_example.py new file mode 100644 index 0000000..6812698 --- /dev/null +++ b/tests/test_geoprocessing_job_lifecycle_example.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json + +import httpx + +from examples.geoprocessing_job_lifecycle import run_job + + +def test_focused_example_submits_polls_and_fetches_results() -> None: + requests: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append((request.method, request.url.path)) + if request.method == "POST": + assert json.loads(request.content) == { + "inputs": {"inputGeoJson": "{}", "distance": 100}, + "response": "document", + } + return httpx.Response( + 201, + headers={"Location": "http://example.test/ogc/processes/jobs/job-1"}, + json={ + "jobID": "job-1", + "processID": "geometry.buffer", + "status": "accepted", + }, + ) + if request.url.path == "/ogc/processes/jobs/job-1": + return httpx.Response( + 200, + json={ + "jobID": "job-1", + "processID": "geometry.buffer", + "status": "successful", + }, + ) + if request.url.path == "/ogc/processes/jobs/job-1/results": + return httpx.Response( + 200, + json={"outputFeatureLayer": {"value": {"type": "FeatureCollection", "features": []}}}, + ) + raise AssertionError(f"unexpected request: {request.method} {request.url.path}") + + result = run_job( + "http://example.test", + "geometry.buffer", + {"inputGeoJson": "{}", "distance": 100}, + poll_interval=0.0, + deadline_seconds=1.0, + transport=httpx.MockTransport(handler), + ) + + assert result["outputFeatureLayer"]["value"]["type"] == "FeatureCollection" + assert requests == [ + ("POST", "/ogc/processes/processes/geometry.buffer/execution"), + ("GET", "/ogc/processes/jobs/job-1"), + ("GET", "/ogc/processes/jobs/job-1/results"), + ]