<!-- Source: https://docs.geopera.com/api-reference/sdks/python/models · Markdown for LLMs -->

# Typed models

Every operation's request body and response payload has a typed [`attrs`](https://www.attrs.org) 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](/api-reference/sdks/python/reference). For the request shape on the wire, see [Concepts](/api-reference/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](#the-unset-sentinel) and [Reading an output model](#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.

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

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

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

```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](/api-reference/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`](#the-unset-sentinel) 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](/api-reference/sdks/python/reference) for the `sync` / `sync_detailed` / `asyncio` / `asyncio_detailed` variants, [Pagination](/api-reference/pagination) for the cursor convention, and [Authentication](/api-reference/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.
