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:

python
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

python
from geopera.models import CatalogSearchInput, CatalogSearchOutput

Model 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:

python
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:

python
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 datetime is the attribute datetime_, and next is next_.
  • camelCase wire keys become snake_case attributes. The wire field splitByDate is the attribute split_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:

python
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:

python
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:

python
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:

python
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():

python
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:

python
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:

python
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 dict

The 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.

python
from geopera.types import UNSET

CatalogSearchInput(host_name="earthsearch-aws").collections is UNSET   # True

UNSET 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:

python
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 list

When 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:

python
body = CatalogSearchInput(host_name="earthsearch-aws")
body.limit = UNSET          # drop the default 100 from the request
"limit" in body.to_dict()   # False

Helpers in geopera.types

geopera.types holds the shared primitives the models and operation modules are built on.

NamePurpose
UNSETThe omit-this-field sentinel (the singleton instance of Unset).
UnsetThe sentinel’s class — used in type hints (str \| None \| Unset) and isinstance checks.
FileWraps a binary stream for multipart file uploads.
Response[T]The full HTTP response wrapper returned by *_detailed calls.
FileTypes, RequestFilesInternal 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:

python
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 field

payload 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:

AttributeTypeDescription
status_codeHTTPStatusThe HTTP status of the response.
contentbytesThe raw, unparsed response body.
headersMutableMapping[str, str]Response headers.
parsedT \| NoneThe typed model, or None for an undocumented status.
python
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.

python
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.

python
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 None

The 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:

python
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      # CatalogSearchOutput

You can also construct the AuthenticatedClient directly when you are not using the fluent layer at all:

python
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 explicit 0, "", [], or None is meaningful; only is UNSET distinguishes “field omitted”.
  • None is not UNSET. Setting a field to None sends JSON null; leaving it UNSET omits the key. For nullable API fields the server respects this difference.
  • Renamed attributes. datetime_ and next_ map to the wire keys datetime and next; split_by_date maps to splitByDate. Use the Python name on a model; use the wire name in a dict body passed to the fluent client.
  • Non-UNSET defaults are sent. limit defaults to 100 and split_by_date to False, so they appear in to_dict() unless you set the field back to UNSET.
  • Open output models. Models with no declared fields (like CatalogSearchOutput) expose everything through additional_properties — use mapping access or to_dict(), not attribute access.
  • additional_properties is 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 the body and parse the response for you; call them directly only when handling raw dicts.