Typed models
Every operation’s request body and response payload has a typed attrs class in geopera.models, so you can build inputs and read outputs as typed Python objects with editor autocompletion and static checking instead of juggling raw dictionaries.
You are never forced to use them. The fluent Geopera client accepts a plain dict for any body and converts it to the right model for you, so models are an opt-in convenience: reach for them when you want type safety, autocompletion, and UNSET-aware narrowing; pass a dict when you want to move fast. This page covers building inputs, reading outputs, the UNSET sentinel for optional fields, the shared helpers in geopera.types, and the py.typed marker that makes all of it visible to your type checker.
For how a model is actually sent — client.<resource>.<action>(body) on the fluent client, or sync / sync_detailed / asyncio on an operation module — see the Python SDK reference. For the request shape on the wire, see Concepts: every operation is POST /v1/op/{operation_id} with a JSON body. There are no GET operations and therefore no GET-only models — list and get semantics live in the operation name, never in the model.
Dicts vs. models
The fluent client turns a dict into the operation’s typed input model under the hood, so these two calls are equivalent:
from geopera import Geopera
from geopera.models import CatalogSearchInput
client = Geopera(token="gpra_...")
# Plain dict — converted to CatalogSearchInput for you
client.catalog.search({
"host_name": "earthsearch-aws",
"collections": ["sentinel-2-l2a"],
"limit": 10,
})
# Typed model — identical request on the wire
client.catalog.search(CatalogSearchInput(
host_name="earthsearch-aws",
collections=["sentinel-2-l2a"],
limit=10,
))When you pass a dict, the fluent node calls the input model’s from_dict() for you, so the dict keys are the wire names (datetime, next, splitByDate), not the Python attribute names (datetime_, next_, split_by_date). When you pass a model, you use the Python attribute names and the model renames them on serialization. The rest of this page is about the typed-model path; if you only ever pass dicts, you still want to read The UNSET sentinel and Reading an output model, because responses are always returned as typed models.
Where models live
from geopera.models import CatalogSearchInput, CatalogSearchOutputModel class names are the PascalCase form of the operation’s input or output schema. An operation named catalog.search has a CatalogSearchInput request body and (where the response is typed) a CatalogSearchOutput. There is one model per input and output schema, and every model lives in geopera.models regardless of how deeply it is nested.
Nested schemas get their own classes with a suffixed name. CatalogSearchInput.query is typed CatalogSearchInputQueryType0, and CatalogSearchInput.intersects is CatalogSearchInputIntersectsType0. The Type0 suffix comes from the field being a union (object, null, or omitted), where the object branch is “type 0”. A list-of-objects field gets an ...Item class instead — ArchiveEstimateInput.captures is a list[ArchiveEstimateInputCapturesItem]. All of these import from geopera.models the same way.
Enum-valued fields are plain str enums, so they accept either the enum member or its string value:
from geopera.models import ItemType
ItemType.SATELLITE_CAPTURE # <ItemType.SATELLITE_CAPTURE: 'satellite_capture'>
str(ItemType.SATELLITE_CAPTURE) # "satellite_capture"Building an input model
Required fields are positional or keyword constructor arguments. Optional fields default to UNSET (covered below) and may be omitted. Here is the real shape of CatalogSearchInput:
from geopera.models import CatalogSearchInput
body = CatalogSearchInput(
host_name="earthsearch-aws", # required
collections=["sentinel-2-l2a"],
bbox=[151.0, -34.0, 152.0, -33.0],
datetime_="2024-01-01T00:00:00Z/2024-12-31T23:59:59Z",
limit=25, # default is 100
)host_name is the only required field on this model; everything else is optional. Some optional fields carry a non-UNSET default that the constructor applies for you: CatalogSearchInput.limit defaults to 100, and ArchiveEstimateInput.split_by_date defaults to False. A default like this is sent on the wire unless you explicitly set the field back to UNSET.
Field renaming
Field names follow Python conventions, so a JSON key is remapped to a safe attribute name two ways:
- Keyword / builtin collisions get a trailing underscore. The wire field
datetimeis the attributedatetime_, andnextisnext_. - camelCase wire keys become snake_case attributes. The wire field
splitByDateis the attributesplit_by_date.
In both cases you use the Python name in your code; the model writes the wire name on to_dict() and reads it on from_dict(). (Remember the inverse for dict bodies passed to the fluent client: those use the wire names.)
Serializing with to_dict()
to_dict() returns the JSON-ready body. Optional fields left at UNSET are dropped entirely, and renamed attributes are written back to their wire names:
body = CatalogSearchInput(host_name="earthsearch-aws", limit=5)
body.to_dict()
# {"host_name": "earthsearch-aws", "limit": 5}You rarely call to_dict() yourself — the fluent client and the operation modules do it when you pass the model as body. Call it directly when you want to inspect, log, or store the exact JSON that will be sent.
Nested models
When a field is itself a typed object, construct the nested model and assign it. CatalogSearchInputQueryType0 and CatalogSearchInputIntersectsType0 are open dict-backed models — you populate them with item assignment:
from geopera.models import (
CatalogSearchInput,
CatalogSearchInputIntersectsType0,
)
geometry = CatalogSearchInputIntersectsType0()
geometry["type"] = "Point"
geometry["coordinates"] = [151.2, -33.9]
body = CatalogSearchInput(
host_name="earthsearch-aws",
intersects=geometry,
limit=10,
)When to_dict() runs, a nested model field is serialized recursively, so geometry.to_dict() is embedded under "intersects" automatically.
Lists of nested models
A list[...Item] field is built as an ordinary Python list of the item model. Each item’s to_dict() is called in turn when the parent serializes:
from geopera.models import (
ArchiveEstimateInput,
ArchiveEstimateInputCapturesItem,
)
body = ArchiveEstimateInput(
captures=[
ArchiveEstimateInputCapturesItem(),
ArchiveEstimateInputCapturesItem(),
],
split_by_date=True,
)
body.to_dict()
# {"captures": [ {...}, {...} ], "splitByDate": true}Reading an output model
Responses are always returned as typed models, even when you sent a plain dict. Required fields are plain attributes; optional fields are UNSET when the server omits them. For example, ApiKeyCreateOutput exposes id, prefix, name, permissions, and created_at as required, with key and expires_at optional:
result # an ApiKeyCreateOutput
print(result.id)
print(result.prefix) # e.g. "gpra"
print(result.permissions) # list[str]
if result.key is not UNSET:
# The full secret is only present on creation — store it now
save_secret(result.key)Parsing a dict with from_dict()
If you have a raw dictionary — for example a body you stored, logged, or received out of band — rehydrate it into a typed model with the classmethod from_dict():
from geopera.models import ApiKeyCreateOutput
result = ApiKeyCreateOutput.from_dict({
"id": "key_123",
"prefix": "gpra",
"name": "ci-runner",
"permissions": ["catalog:read", "orders:write"],
"created_at": "2026-06-20T00:00:00Z",
})
print(result.name) # "ci-runner"from_dict() accepts any Mapping and is the inverse of to_dict(): round-tripping a model through both is lossless. Note that from_dict() is tolerant — it does not raise on a missing optional field (it stays UNSET) and it does not raise on extra keys (they go to additional_properties, below). A genuinely missing required key, however, raises KeyError.
Unknown and additional fields
Every model carries an additional_properties dict for keys the schema does not name explicitly — this is how new API fields stay readable without an SDK upgrade. Models support mapping access for those extras:
result["some_new_field"] # __getitem__
"some_new_field" in result # __contains__
result.additional_keys # list[str] of extra keys
del result["some_new_field"] # __delitem__additional_properties is populated by from_dict() from any leftover keys, and is merged back in by to_dict(). It is not a constructor argument — set extras with item assignment (result["x"] = ...) after construction.
Some response models, such as CatalogSearchOutput, declare no fixed fields at all — they are entirely backed by additional_properties. You read the parsed payload through mapping access (result["features"], result["context"]) or with result.to_dict(), never through attribute access:
result # a CatalogSearchOutput with no declared fields
for feature in result["features"]:
print(feature["id"])
raw = result.to_dict() # the full parsed payload as a dictThe UNSET sentinel
Optional fields default to a singleton sentinel, UNSET, imported from geopera.types. It is an instance of the Unset class and is distinct from None: None means “send JSON null”, while UNSET means “omit this field entirely”. This distinction matters whenever an API field is nullable.
from geopera.types import UNSET
CatalogSearchInput(host_name="earthsearch-aws").collections is UNSET # TrueUNSET is falsy (bool(UNSET) is False), so if not body.collections: is true when the field is unset — but to distinguish “unset” from “explicitly empty/null” you must compare identity:
if body.collections is UNSET:
... # field omitted from the request entirely
elif body.collections is None:
... # field sent as JSON null
elif not body.collections:
... # an explicit empty list []
else:
... # a concrete non-empty listWhen you read an output model, the same rule applies: an optional attribute is UNSET precisely when the server did not include that key. Always test with is UNSET rather than truthiness so an explicit None, 0, "", or [] is not misread. To send JSON null deliberately, assign None; to drop a field that currently has a value (including a non-UNSET default like limit=100), assign UNSET back to it:
body = CatalogSearchInput(host_name="earthsearch-aws")
body.limit = UNSET # drop the default 100 from the request
"limit" in body.to_dict() # FalseHelpers in geopera.types
geopera.types holds the shared primitives the models and operation modules are built on.
| Name | Purpose |
|---|---|
UNSET | The omit-this-field sentinel (the singleton instance of Unset). |
Unset | The sentinel’s class — used in type hints (str \| None \| Unset) and isinstance checks. |
File | Wraps a binary stream for multipart file uploads. |
Response[T] | The full HTTP response wrapper returned by *_detailed calls. |
FileTypes, RequestFiles | Internal httpx upload tuple aliases; you rarely touch these directly. |
File for uploads
For operations that accept a binary payload, wrap the stream in File. It carries the payload plus an optional filename and MIME type, and serializes to the tuple httpx expects for multipart/form-data:
from geopera.types import File
with open("scene.tif", "rb") as fh:
upload = File(
payload=fh,
file_name="scene.tif",
mime_type="image/tiff",
)
# pass `upload` to the operation that declares a File-typed fieldpayload is required; file_name and mime_type both default to None. The underlying to_tuple() returns (file_name, payload, mime_type); you do not normally call it directly — the operation module does.
Response[T]
Response[T] is the generic envelope returned by the *_detailed flavour of each operation (and by the fluent client when you pass detailed=True). It exposes the raw HTTP context alongside the parsed model:
| Attribute | Type | Description |
|---|---|---|
status_code | HTTPStatus | The HTTP status of the response. |
content | bytes | The raw, unparsed response body. |
headers | MutableMapping[str, str] | Response headers. |
parsed | T \| None | The typed model, or None for an undocumented status. |
print(resp.status_code) # HTTPStatus.OK
print(resp.headers["x-request-id"])
print(resp.parsed) # the typed output model (or None)The plain (non-detailed) flavour returns resp.parsed directly when you do not need headers or the status code. On error statuses, parsed holds the typed problem model — see Errors, which are RFC 9457 problem+json. content is always the raw bytes even when parsed is None, which is the value to inspect when a status is undocumented.
Type checking with py.typed
The package ships a py.typed marker, so mypy, pyright, and your editor’s language server read the SDK’s inline type hints directly. You get autocompletion on every model field, type errors when a required argument is missing or mistyped, and accurate UNSET / None narrowing — no stub package or extra configuration required.
CatalogSearchInput(limit=25)
# type checker: missing required argument "host_name"Because optional fields are typed as a union ending in Unset (for example list[str] | None | Unset), a type checker will flag code that treats an optional value as a plain list without first narrowing it with is UNSET or isinstance(x, Unset). That narrowing is exactly what the UNSET checks above provide.
Worked example
A complete flow with the fluent client: build a typed input, send it, and read the typed response with its UNSET-aware fields. Pagination reuses the next cursor.
from geopera import Geopera
from geopera.models import CatalogSearchInput
# The bearer token is a session token (from signing in to Geopera) or a
# minted API key (prefix gpra_). There is no token-exchange step.
client = Geopera(token="gpra_...")
body = CatalogSearchInput(
host_name="earthsearch-aws",
collections=["sentinel-2-l2a"],
bbox=[151.0, -34.0, 152.0, -33.0],
datetime_="2024-01-01T00:00:00Z/2024-12-31T23:59:59Z",
limit=25,
)
# Returns the parsed CatalogSearchOutput; pass detailed=True for Response[T]
result = client.catalog.search(body)
# CatalogSearchOutput is dict-backed — read via mapping access
for feature in result["features"]:
print(feature["id"])
# Page forward by feeding the cursor back into the same body
next_token = result["context"].get("next") if "context" in result else None
while next_token:
body.next_ = next_token
result = client.catalog.search(body)
for feature in result["features"]:
print(feature["id"])
next_token = result["context"].get("next") if "context" in result else NoneThe same flow through the low-level escape hatch
If you need the async variants or the full Response[T], drop to the operation module. client.client exposes the underlying AuthenticatedClient the fluent client built, so you do not construct a second one:
from geopera import Geopera
from geopera.api.operations import catalog_search
from geopera.models import CatalogSearchInput
client = Geopera(token="gpra_...")
body = CatalogSearchInput(host_name="earthsearch-aws", limit=25)
resp = catalog_search.sync_detailed(client=client.client, body=body)
print(resp.status_code) # HTTPStatus.OK
result = resp.parsed # CatalogSearchOutputYou can also construct the AuthenticatedClient directly when you are not using the fluent layer at all:
from geopera import AuthenticatedClient
client = AuthenticatedClient(base_url="https://api.geopera.com", token="gpra_...")See the Python SDK reference for the sync / sync_detailed / asyncio / asyncio_detailed variants, Pagination for the cursor convention, and Authentication for token types and scopes.
Gotchas
- Compare with
is UNSET, never truthiness. An explicit0,"",[], orNoneis meaningful; onlyis UNSETdistinguishes “field omitted”. Noneis notUNSET. Setting a field toNonesends JSONnull; leaving itUNSETomits the key. For nullable API fields the server respects this difference.- Renamed attributes.
datetime_andnext_map to the wire keysdatetimeandnext;split_by_datemaps tosplitByDate. Use the Python name on a model; use the wire name in a dict body passed to the fluent client. - Non-
UNSETdefaults are sent.limitdefaults to100andsplit_by_datetoFalse, so they appear into_dict()unless you set the field back toUNSET. - Open output models. Models with no declared fields (like
CatalogSearchOutput) expose everything throughadditional_properties— use mapping access orto_dict(), not attribute access. additional_propertiesis not a constructor argument. Set extra keys with item assignment after construction;from_dict()populates it from leftover keys automatically.- Responses are typed even from dict requests. Passing a dict body changes only the input side; the result is still the typed output model.
- You usually don’t call
to_dict()/from_dict()yourself. The fluent client and the operation modules serialize thebodyand parse the response for you; call them directly only when handling raw dicts.