Metadata-Version: 2.5
Name: modelstudio-sdk
Version: 1.0.0
Summary: Python SDK for the Model Studio REST API
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.10
Requires-Dist: httpx<1.0,>=0.25.0
Requires-Dist: pydantic<3.0,>=2.0
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pandas>=1.5.0; extra == 'dev'
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest-httpx>=0.30.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.4.0; extra == 'dev'
Provides-Extra: pandas
Requires-Dist: pandas>=1.5.0; extra == 'pandas'
Description-Content-Type: text/markdown

# Model Studio SDK

Typed Python client for the [Model Studio](https://gitlab.com/orbitalinsight/elements/model-studio) REST API.

```bash
pip install modelstudio-sdk          # or: pip install 'modelstudio-sdk[pandas]'
```

```python
from modelstudio import ModelStudioClient

client = ModelStudioClient.from_env()
project = client.project("<project-uuid>")

for d in project.datasets():                 # DatasetModel records
    print(d.name, d.version, d.has_uncommitted_changes)
```

---

## Configuration

`from_env()` reads:

| Variable | Required | Purpose |
|---|---|---|
| `MODEL_STUDIO_API_URL` | yes | API root, e.g. `https://model-studio-api.elements.dev.privateer.com` |
| `MODEL_STUDIO_JWT` | in practice | Keycloak bearer token |
| `MODEL_STUDIO_USER_ID` | no | Dev-mode `X-User-Id` for an API running without OIDC |

Your **organization comes from the JWT**, by token introspection. There is no organization
header or parameter, and a token carrying no organization gets a `403`.

```bash
eval "$(scripts/get-token.sh)"     # fetches a JWT and exports both variables
```

---

## Four things that will surprise you

The API changed shape in ways that no amount of guessing will recover. In rough order of
how often they bite:

### 1. Everything is addressed through its parent

There is no flat route to any project-scoped resource — no `/api/v1/datasets/{id}`, and no
lookup that resolves a dataset from its id alone.

```python
ds = client.project(project_id).dataset(dataset_id)
ds = client.dataset(project_id, dataset_id)      # identical shorthand
```

A wrong `project_id` returns `404`, not `403` — org scoping is enforced by making a
resource you cannot see indistinguishable from one that does not exist.

### 2. A dataset needs an ontology before it can exist

Categories live in **versioned ontologies**, not on datasets. A dataset pins exactly one
ontology *version*, and that version's dense `1..N` ordinals *are* the class index used by
annotations, mask runs and exported COCO alike.

```python
lineage = project.ontologies.list(in_use=False)[0]
dataset_model = project.create_dataset(
    name="harbor-train",
    ontology_version_id=lineage.ontology_version_id,   # required
)
ds = project.dataset(str(dataset_model.id))            # the resource you call methods on
```

`list()` defaults to `in_use=True`, which **hides lineages with no pinned datasets** —
including one you just created. Pass `in_use=False` for a create-then-pick flow. Every new
project gets a `Default` lineage, so there is always something to pin.

Category ids are per-version: every edit except a colour change mints a new version with
fresh ids. Re-read after any edit rather than caching them.

### 3. Training reads a commit, not the dataset

Datasets are never locked — they stay editable forever. Immutability lives in **commits**,
which are git-style snapshots that a run pins.

```python
commit = ds.commits.create(message="rebalanced train/val")   # blocks until READY
experiment = project.experiment(exp_id)
run_model = experiment.runs.create(CreateRunRequest(
    name="baseline",
    model_architecture="faster-rcnn",
    dataset_id=ds.dataset_id,
    dataset_commit_id=commit.id,        # required before submit
    spec={"train": {"num_epochs": 50}},
))
run = experiment.run(str(run_model.id))
run.submit()
```

`submit()` raises `ValidationError` until a **`READY`** commit is pinned. There is no
auto-commit. Only one commit may be `BUILDING` at a time, and while one is it blocks every
mutation on that dataset.

### 4. Most mutations are asynchronous

Roughly nineteen whole-dataset mutations return `202 Accepted` and a queued operation. The
SDK **blocks by default** and hands back the result:

```python
summary = ds.filter(request)                 # blocks, returns result_summary

op = ds.filter(request, wait=False)          # or drive it yourself
op.status      # QUEUED / RUNNING / SUCCEEDED / FAILED / CANCELED
op.progress    # 0-100
op.wait(timeout=600)
op.cancel()
op.retry()                                   # one retry per operation
```

Only one non-import operation may be active per dataset. A second one raises
`OperationConflictError`, which names the operation already in flight:

```python
try:
    ds.redistribute({"train": 0.8, "val": 0.2})
except OperationConflictError as exc:
    ds.operation(exc.op_id).wait()           # poll the blocker, don't spin
```

Imports gate per *split* instead, so imports into different splits run concurrently — and
alongside a whole-dataset mutation.

---

## Working with data

### Listing

Listings are paginated and typed. `iter_*` walks every page for you.

```python
page = ds.images(split_id=split_id, size=100, search="harbor")
page.content[0].annotation_count
page.has_next

for image in ds.iter_images(split_id=split_id):
    ...
```

`ds.images()` is the one **read** gated on the operations control plane — it raises
`OperationConflictError` while a mutation is in flight rather than serving a
half-populated listing.

### Importing

Two phases. Validate first; the import needs the resulting `validation_id`.

```python
split = ds.split(split_id)

validation = split.validate_import("s3", S3ImportRequest(
    connection_id=conn_id, bucket="my-bucket", prefix="datasets/harbor/"))

if validation.can_proceed:
    split.import_from("s3", "object-detection-coco", S3ImportRequest(
        connection_id=conn_id, bucket="my-bucket", prefix="datasets/harbor/",
        validation_id=validation.validation_id))
```

Sources are `s3`, `labelbox` and `dms`; dataset types are `object-detection-coco` and
`semantic-segmentation-indexed`. A `validation_id` is reaped after roughly 15 minutes.

### Metrics

Two tiers, both served from a snapshot cache and never computed on the request path — so
they cannot time out and are not blocked by a running mutation.

```python
overview = ds.overview()          # Tier 1: cheap, always live
if overview.is_empty:
    ...                           # first-ever call; a background compute was enqueued
overview.payload.num_images
overview.is_stale                 # a mutation landed after this snapshot

ds.compute_deep_stats()           # Tier 2 stays empty until you ask for it
ds.deep_split_metrics()
```

### Live events

One SSE stream per dataset, multiplexing six channels. It is also the **only** list surface
for operations — there is no REST list route.

Every subscribe replays the latest snapshot of each channel, so no seed request is needed.
That is what makes `snapshot()` terminate — it reads one frame per channel and disconnects:

```python
state = ds.snapshot()                      # current state of all six channels
for row in state["operations"]["operations"]:
    print(row["op_type"], row["status"], row["progress"])
```

`events()` **follows** the stream and does not return on its own, so bound it:

```python
for event in ds.events(channels=["operations"], max_events=10):
    print(event.event, event.json())
```

The two `metrics_*` channels are freshness signals only and carry no values.

### Logs

```python
for line in run.stream_logs("main", tail_lines=200):
    print(line)
```

Failures on a log stream arrive *inside* the stream (the response commits `200` before the
work starts) and are raised as the equivalent typed exception.

---

## Beyond training

```python
# Export a checkpoint, then ship it
action_model = run.actions.create(CreatePostRunActionRequest(
    name="onnx", action_type="export", checkpoint_id=checkpoint_id))
action = run.action(str(action_model.id))
action.submit()

outputs = project.deployable_outputs(target="pono")
deployment_model = project.deployments.create(CreateDeploymentRequest(
    name="release-v1", target="pono",
    action_output_id=outputs[0].output_id, pono_device_id="alpha-01"))
project.deployment(str(deployment_model.id)).submit()
```

Action types are `export`, `evaluate`, `prune`, `distill` and `inference`. Deployment
targets are `pono` and `elements`.

Flat platform surfaces hang off the client: `client.schemas`, `client.storage`,
`client.integrations`, `client.dms`, `client.media`, `client.pretrained_weights`,
`client.registration_catalog`.

---

## Errors

Exceptions map from the API's `{"error", "message"}` envelope, dispatching on the **error
code first** and the status second — several `409`s and `429`s carry a branchable code
instead of the generic status name.

```python
from modelstudio import ValidationError, OperationConflictError, ServerBusyError

try:
    run.submit()
except ValidationError as exc:
    print(exc)                 # renders the per-field `details` breakdown
```

| Exception | Status | Notes |
|---|---|---|
| `BadRequestError` | 400 | |
| `AuthenticationError` | 401 | |
| `ForbiddenError` | 403 | Also: token carries no organization |
| `NotFoundError` | 404 | Or the resource belongs to another org — indistinguishable by design |
| `ConflictError` | 409 | |
| `OperationConflictError` | 409 | Carries `.op_id` — the operation already in flight |
| `SchemaConflictError` | 409 | Check `.already_current`: a byte-identical republish is a no-op |
| `OntologyScopeConflictError` | 409 | Carries `.impact` — re-render a picker straight from the error |
| `OntologyForkRequiredError` | 409 | Fork the version and re-issue |
| `ValidationError` | 422 | Carries `.details` |
| `ServerBusyError` | 429 | A bounded pool is full; retry with backoff |
| `BadGatewayError` / `ServiceUnavailableError` | 502 / 503 | Upstream errored vs. unreachable-or-unconfigured |
| `TransportError` | — | The request never reached the API |
| `TimeoutError` | — | The SDK stopped waiting; the work continues server-side |

Never branch on `message` — it is prose and will change.

---

## pandas

Optional, behind the `pandas` extra. Nested objects are flattened into dotted columns.

```python
ds.images_df(split_id=split_id)     # -> image.file_name, image.width, ...
ds.annotations_df()
ds.categories_df()
ds.commits_df()
```

---

## Reference and development

- **[docs/api-reference.md](docs/api-reference.md)** — every SDK method and the route it calls
- **[contracts/API_PIN.md](contracts/API_PIN.md)** — which API build the SDK is aligned to, and how to re-sync
- **Tutorials** — runnable notebooks, canonical in
  [model-studio-notebooks](https://gitlab.com/orbitalinsight/elements/model-studio/model-studio-notebooks)
  under `tutorials/`, since that repo ships them to users. Clone it alongside this one and
  `./develop.sh --mode docker` mounts them.

```bash
./develop.sh --mode setup             # conda env + deps
./develop.sh --mode test              # unit tests
./develop.sh --mode lint              # ruff + mypy + the contract check
./develop.sh --mode test-integration  # live tests against elements-dev
./develop.sh --mode notebook-server   # Jupyter Server for the custom notebook UI
```

`scripts/check_contract.py` validates every route the SDK calls against a committed
OpenAPI snapshot. It is what catches upstream drift before a notebook does — run it, and
re-snapshot with `scripts/fetch_openapi.sh`, whenever the API moves.

## Requirements

Python 3.10+, `httpx`, `pydantic` v2. Sync only — notebooks are synchronous.
