Calling operations
The Python SDK gives you a typed client with one method per operation, grouped by resource, so client.catalog.search(body) is all you need to make a call; detailed=True hands you the full typed Response when you want the status code, and the per-operation modules underneath stay available as a lower-level escape hatch.
The resource.action pattern
Construct a client once, then call operations as client.<resource>.<action>(body). The resource and action come straight from the operation ID — catalog.search becomes client.catalog.search, and billing.credits.balance becomes client.billing.credits.balance:
from geopera import Geopera
client = Geopera(token="gpra_...")
result = client.catalog.search({"host_name": "earthsearch-aws", "limit": 10})
print(result) # -> CatalogSearchOutput (typed)Every operation is a POST under the hood — the SDK posts your body to /v1/op/<operation_id> — but you never see that. Because the verb (search, get, place, estimate, …) lives inside the operation name, you never construct a URL, choose an HTTP method, or build a path; you call the method that names the thing you want to do. Browse the full catalogue at /api-reference/operations.
Body: a dict or the typed input model
The body is either a plain dict, as above, or the operation’s typed input model — both are accepted, and both route through exactly the same call:
from geopera import Geopera
from geopera.models import CatalogSearchInput
client = Geopera(token="gpra_...")
result = client.catalog.search(
CatalogSearchInput(
host_name="earthsearch-aws",
collections=["sentinel-2-l2a"],
limit=10,
)
)When you pass a dict, the client converts it to the operation’s input model with that model’s from_dict before sending, so an unknown or mistyped field is caught at conversion time rather than silently dropped. When you pass the model directly, you get editor autocomplete and type checking on every field. Use the model when you are writing code by hand; use a dict when the body is data you already have (loaded from JSON, assembled from user input, copied from the API reference).
The return value is the operation’s parsed output. For most operations that is a typed output model (CatalogSearchOutput, TaskingEstimateOutput, …). A handful of operations return an untyped JSON object, in which case the parsed value is a plain dict; the operations catalogue lists the exact output model for each operation.
Deeply nested operations
Operation IDs with more than two segments nest the same way: each dot in the ID is one more attribute access. orders.archive.place is client.orders.archive.place, and orders.tasking.estimate is client.orders.tasking.estimate:
order = client.orders.archive.place(
{
"host_name": "earthsearch-aws",
"item_ids": ["S2B_MSIL2A_20240101..."],
}
)
estimate = client.orders.tasking.estimate(
{
"host_name": "planet",
"geometry": {"type": "Point", "coordinates": [-122.4, 37.8]},
"datetime": "2026-07-01T00:00:00Z/2026-07-31T23:59:59Z",
}
)There is no depth limit and no registration step: attribute access builds up a dotted path lazily, and the path is resolved to a module only when you call the node. client.billing.credits.balance and client.billing.credits.transactions share the same client.billing.credits node; calling it is what selects the operation.
Operations with no input
Some operations take no meaningful input. Pass an empty dict (or omit the argument entirely — None is treated as an empty body):
balance = client.billing.credits.balance({})
# equivalent:
balance = client.billing.credits.balance()Internally these operations accept the Empty model, and the client builds it for you from {} (or from None). You never need to import Empty when you call through client.<resource>.<action>.
detailed=True: the typed Response without leaving the fluent client
By default client.<resource>.<action>(body) returns the parsed payload — exactly what you want most of the time. When you need the HTTP status code or response headers, pass detailed=True and you get the full typed Response back, without dropping down to the module layer:
from geopera import Geopera
from geopera.models import CatalogSearchOutput, Problem
client = Geopera(token="gpra_...")
resp = client.catalog.search(
{"host_name": "earthsearch-aws", "collections": ["sentinel-2-l2a"]},
detailed=True,
)
if resp.status_code == 200:
out: CatalogSearchOutput = resp.parsed
print("ok:", out)
elif isinstance(resp.parsed, Problem):
print(resp.status_code, resp.parsed.title, "-", resp.parsed.detail)detailed=True on the fluent client and sync_detailed on a module return the same Response object (described in full below) — detailed=True is simply the ergonomic way to reach it. Without it you get resp.parsed; with it you get resp.
Per-call header overrides
The fluent call forwards any extra keyword argument whose name the underlying operation accepts. In practice the one you may use is x_api_key, which sets the x-api-key header for that single call (handy when an operation routes to a third-party host that needs its own key, while your Geopera token stays in the client):
result = client.catalog.search(
{"host_name": "some-host", "collections": ["sentinel-2-l2a"]},
x_api_key="host-specific-key",
)Keyword arguments the operation does not declare are ignored rather than raising, so passing an option an operation does not support is a no-op, not an error. Most operations need nothing beyond body.
The lower-level escape hatch: per-operation modules
Under the typed client sits a flat catalogue of per-operation modules — one module per operation under geopera.api.operations, each wrapping a single operation call and exposing four callables. Reach for this layer when you want to import an operation as a plain function (for dependency injection or testing), keep sync and asyncio forms side by side, or pin an exact import path. Anything you can do here you can also do through the fluent client; this is the typed plumbing it calls.
These functions take an AuthenticatedClient, not a Geopera. The Geopera client exposes its underlying AuthenticatedClient as client.client, so you can reuse the same configured connection:
from geopera import Geopera
from geopera.api.operations import catalog_search
from geopera.models import CatalogSearchInput
client = Geopera(token="gpra_...")
result = catalog_search.sync(
client=client.client,
body=CatalogSearchInput(
host_name="earthsearch-aws",
collections=["sentinel-2-l2a"],
limit=10,
),
)
print(result) # -> CatalogSearchOutput (typed)See the client setup page for how to build a standalone AuthenticatedClient if you want to use this layer on its own.
The module path: dots (and dashes) become underscores
There is exactly one module per operation, and the module name is the operation ID with every dot — and every dash — replaced by an underscore:
| operationId | Module |
|---|---|
catalog.search | geopera.api.operations.catalog_search |
orders.tasking.estimate | geopera.api.operations.orders_tasking_estimate |
analytics.calculate_index | geopera.api.operations.analytics_calculate_index |
billing.credits.balance | geopera.api.operations.billing_credits_balance |
The input and output models (CatalogSearchInput, CatalogSearchOutput, Problem, HTTPValidationError, Empty, …) all live in geopera.models.
The four callables
Importing an operation module gives you four entry points:
| Callable | Sync/async | Returns |
|---|---|---|
sync | blocking | the parsed result, a parsed error, or None |
sync_detailed | blocking | a Response wrapper |
asyncio | awaitable | the parsed result, a parsed error, or None |
asyncio_detailed | awaitable | a Response wrapper |
sync and asyncio are the convenience forms: they hand you the parsed payload directly. The *_detailed forms hand you a Response object so you can inspect the HTTP status code and headers alongside the parsed body. Internally sync is literally sync_detailed(...).parsed, and asyncio is (await asyncio_detailed(...)).parsed, so the two always agree on what was parsed.
All four take keyword-only arguments. The two you will always use are client= and (for operations that accept input) body=. Operations also accept the optional x_api_key= keyword for a per-call header override, the same one the fluent client forwards.
Building the body model at this layer
Unlike the fluent client, the module functions do not convert a dict for you — body= must be the typed input model (or, for input-less operations, Empty()). Construct the model from your data with Model.from_dict({...}) if you are starting from a dict:
from geopera.api.operations import catalog_search
from geopera.models import CatalogSearchInput
# from keyword arguments
body = CatalogSearchInput(host_name="earthsearch-aws", limit=10)
# or from a dict you already have
raw = {"host_name": "earthsearch-aws", "limit": 10}
body = CatalogSearchInput.from_dict(raw)
result = catalog_search.sync(client=client.client, body=body)Reading the raw response with sync_detailed
Use sync_detailed (or detailed=True on the fluent client) when you need the HTTP status code — for example to branch on success versus a business error. It returns a Response with these fields:
| Field | Type | Notes |
|---|---|---|
status_code | http.HTTPStatus | compares cleanly against an int, e.g. == 200 |
parsed | the parsed model or None | same value sync would have returned |
content | bytes | the raw response body |
headers | mapping | response headers |
from geopera import Geopera
from geopera.api.operations import catalog_search
from geopera.models import CatalogSearchInput, CatalogSearchOutput, Problem
client = Geopera(token="gpra_...")
resp = catalog_search.sync_detailed(
client=client.client,
body=CatalogSearchInput(host_name="earthsearch-aws", collections=["sentinel-2-l2a"]),
)
if resp.status_code == 200:
data: CatalogSearchOutput = resp.parsed
print("results:", data)
elif isinstance(resp.parsed, Problem):
print("error:", resp.parsed.title, "-", resp.parsed.detail)Each operation documents the exact set of statuses it parses: a 200 success model, Problem for 401 / 403 / 404 / 500, and HTTPValidationError for 422. Errors are RFC 9457 problem+json; the SDK parses business and permission failures into a Problem model and 422 input-validation failures into HTTPValidationError. See errors for the full problem schema and the catalogue of error types.
Async variants
asyncio and asyncio_detailed are the awaitable equivalents and accept the same keyword arguments. Use the underlying client as an async context manager so its connection pool is cleaned up:
import asyncio
from geopera import AuthenticatedClient
from geopera.api.operations import orders_tasking_estimate
from geopera.models import TaskingEstimateInput
async def main():
async with AuthenticatedClient(
base_url="https://api.geopera.com", token="gpra_..."
) as client:
estimate = await orders_tasking_estimate.asyncio(
client=client,
body=TaskingEstimateInput(
host_name="planet",
geometry={"type": "Point", "coordinates": [-122.4, 37.8]},
datetime="2026-07-01T00:00:00Z/2026-07-31T23:59:59Z",
),
)
print(estimate)
asyncio.run(main())asyncio_detailed returns the same Response wrapper as its sync counterpart, so the status-code branching pattern above carries over unchanged. The fluent client (client.<resource>.<action>) is synchronous; when you need async, call the module’s asyncio / asyncio_detailed against client.client (or a standalone AuthenticatedClient).
Worked example: search, then handle every outcome
This walks the full happy-path-plus-errors flow with sync_detailed so each HTTP outcome is handled explicitly. The same Response is what client.catalog.search(body, detailed=True) returns, so this pattern applies to either entry point.
from geopera import Geopera
from geopera.api.operations import catalog_search
from geopera.models import (
CatalogSearchInput,
CatalogSearchOutput,
HTTPValidationError,
Problem,
)
client = Geopera(token="gpra_...")
resp = catalog_search.sync_detailed(
client=client.client,
body=CatalogSearchInput(
host_name="earthsearch-aws",
collections=["sentinel-2-l2a"],
limit=25,
),
)
match resp.parsed:
case CatalogSearchOutput() as out:
print("ok:", out)
case HTTPValidationError() as verr:
# 422 — the body failed schema validation
print("invalid input:", verr.detail)
case Problem() as prob:
# 401 / 403 / 404 / 500 — auth, scope, or business errors
print(f"{resp.status_code} {prob.title}: {prob.detail}")
case None:
# undocumented status and raise_on_unexpected_status was False
print("unexpected:", resp.status_code, resp.content)A 401 or 403 Problem usually means a missing or wrong token, or a token lacking the scope for this operation — see authentication and scopes. The token you pass is the bearer credential itself; there is no exchange or refresh step — a gpra_... API key (or a browser sign-in session token used the same way) goes straight into the Authorization: Bearer header.
Worked example: chaining operations
Operations compose by feeding one result into the next. Here a catalog search picks items, those item IDs place an archive order, and the order ID checks status — three fluent calls, no URLs:
from geopera import Geopera
client = Geopera(token="gpra_...")
# 1. find imagery
hits = client.catalog.search(
{
"host_name": "earthsearch-aws",
"collections": ["sentinel-2-l2a"],
"limit": 5,
}
)
# 2. place an archive order for the matched items
item_ids = [feature["id"] for feature in hits["features"]]
order = client.orders.archive.place(
{"host_name": "earthsearch-aws", "item_ids": item_ids}
)
# 3. read back the credit balance the order drew down
balance = client.billing.credits.balance({})
print("remaining credits:", balance)If any step can fail in a way you want to branch on, swap that one call for detailed=True and inspect resp.status_code before continuing.
Gotchas
- Prefer
client.<resource>.<action>. The typed client is the recommended surface for almost everything, including the rawResponse(viadetailed=True). Reach for the per-operation modules when you want the async forms, an importable function, or a fixed import path. detailed=Trueis fluent;sync_detailedis the module. Both yield the sameResponse. You do not need to leave the fluent client just to read a status code.- The fluent client converts dicts; the modules do not.
client.catalog.search({...})accepts adictand builds the input model for you.catalog_search.sync(client=..., body=...)requires the actual model — build it withCatalogSearchInput.from_dict({...})if you only have a dict. - The modules want
client.client. The per-operation functions take anAuthenticatedClient. From aGeoperainstance, passclient.client. - No positional arguments on the modules. Every per-operation callable is keyword-only. Always pass
client=andbody=. body=is required even when empty at the module layer. Input-less operations takebody=Empty()when called through a module, but{}(or nothing) through the fluent client.synchides the status code. If you need to distinguish, say, a 403 from a 404, usedetailed=True/sync_detailed/asyncio_detailedand readresp.status_code— both error statuses parse to aProblem.Noneis a real return value. With the defaultraise_on_unexpected_status=False, an undocumented status yieldsparsed=Nonerather than an exception. Setraise_on_unexpected_status=Trueon the client to turn those intoerrors.UnexpectedStatusinstead.status_codeis anHTTPStatus. It compares equal to the correspondingint(resp.status_code == 200), so you rarely need to importHTTPStatusyourself.- A few outputs are untyped. Most operations parse 200 into a typed output model, but some return a raw JSON object, so
resp.parsedis a plaindictfor those. Check the operations catalogue for the exact output type. - Some operation IDs already contain an underscore. The dot-and-dash-to-underscore transform is mechanical, but a handful of operation IDs (such as
analytics.calculate_indexandusage.recalculate_storage) already include an underscore in the last segment. The module name keeps that underscore —analytics_calculate_index,usage_recalculate_storage— so it is not the result of a dot. If an import does not resolve as you expect, confirm the exact module name ingeopera.api.operationsrather than assuming the segment boundaries.
Related
- Operations catalogue — every operation ID and its input/output models
- Errors — the
problem+jsonschema returned asProblem - Scopes — which token scopes each operation requires
- Concepts — the RPC-over-HTTP model behind every call