Metadata-Version: 2.4
Name: ovok-sdk
Version: 0.1.0.dev27
Summary: Python SDK for integrating Ovok Core healthcare services.
Author: Actimi
Author-email: Actimi <info@actimi.com>
License-Expression: AGPL-3.0-only
License-File: LICENSE
Requires-Python: >=3.10
Project-URL: Repository, https://github.com/Actimi/ovok-python-sdk
Project-URL: Documentation, https://docs.ovok.com/python-sdk
Description-Content-Type: text/markdown

# ovok-sdk (Python)

Python SDK for the Ovok API. It follows the `@ovok/core/next` TypeScript contract: the same namespaces and
routes, with method names in snake_case (`searchOne` is `search_one`). It talks to the Ovok API directly and
uses only the Python standard library.

## Install

```bash
pip install ovok-sdk
```

Or with uv:

```bash
uv add ovok-sdk
```

`pip install ovok-sdk` installs final releases only. Release candidates (`0.1.0rc<n>`) and
development builds (`0.1.0.dev<N>`) are pre-releases on PyPI:

```bash
pip install --pre ovok-sdk
pip install "ovok-sdk==0.1.0.dev<N>"
```

Python 3.10 or later.

## Create a client

```python
from ovok_sdk import Ovok

ovok = Ovok("https://api.sandbox.ovok.com", "your-tenant")
```

| Option | Default | Notes |
|---|---|---|
| `base_url` | | Required. An absolute `http(s)` URL. |
| `tenant_code` | | Required. Picks the tenant at sign-in and names the storage key. |
| `fhir_path` | `"fhir"` | The FHIR base under `base_url`, such as `"fhir/R4"`. |
| `storage` | `MemoryStorage()` | Anything with `get_item`, `set_item` and `remove_item`. |
| `client_id` | | Your client application's ID. |
| `client_secret` | | Makes this a machine client. Requires `client_id`. |
| `transport` | `UrllibTransport()` | A callable from `OvokRequest` to `OvokResponse`, for proxies or tests. |
| `timeout` | `30.0` | Seconds per request. |
| `max_network_retries` | `2` | For GET, HEAD, PUT, DELETE and conditional creates only. |
| `on_unauthenticated` | | Called with an `UnauthenticatedReason` when a refresh token is refused. |

A bad option raises `OvokConfigError` from the constructor, not from the first request.

## Sign users in

```python
result = ovok.auth.login(profile_type="Practitioner", email="ada@example.com", password=password)

if result.status == "mfa-required":
    result = ovok.auth.verify_mfa(result.challenge, code_from_authenticator_app)

session = ovok.auth.current()
ovok.auth.logout()
```

- **Login flow:** `login` runs the tenant login with PKCE and signs in to the configured `tenant_code`.
  `profile_type` is `"Patient"`, `"Practitioner"` or `"RelatedPerson"`.
- **Session storage:** the session is saved under `ovok:<tenant_code>:session`, as JSON in the same shape the
  TypeScript SDK writes, so the SDKs can share one store.
- **Refresh:** access tokens refresh a minute before they expire, and concurrent callers share one refresh.
  - A refused refresh token (400, 401 or 403) signs the user out and calls `on_unauthenticated`.
  - Any other failure keeps the session and raises, so a later call can retry.
- **Logout:** `logout` always clears the local session, even when the server cannot be reached.
- **Also on `ovok.auth`:** `register`, `register_practitioner`, `social_login`, `me`, `change_password`,
  `reset_password`, `complete_password_reset`, `delete_account`, and `sessions.list` / `sessions.revoke`.

```python
from ovok_sdk import PatientRegistration

ovok.auth.register(PatientRegistration("ada@example.com", password, password, "Ada", "Lovelace"))
```

## Machine clients

On a server, pass `client_id` and `client_secret` from a client application. The client fetches its own token
from `/oauth2/token` and renews it before it expires. A machine client cannot sign users in: those methods raise
`OvokConfigError` before sending anything.

```python
import os

ovok = Ovok(
    os.environ["OVOK_BASE_URL"],
    os.environ["OVOK_TENANT_CODE"],
    client_id=os.environ["OVOK_CLIENT_ID"],
    client_secret=os.environ["OVOK_CLIENT_SECRET"],
)
```

## FHIR

```python
patient = ovok.fhir.read("Patient", patient_id)

page = ovok.fhir.search("Observation", {"patient": f"Patient/{patient_id}", "_count": 50})
for observation in page.auto_paging_iter():
    ...

ovok.fhir.create(observation, if_none_exist="identifier=urn:device|42")
ovok.fhir.update(patient)
ovok.fhir.patch("Patient", patient_id, [{"op": "replace", "path": "/active", "value": False}])
ovok.fhir.delete("Patient", patient_id)
ovok.fhir.batch(bundle)
first = ovok.fhir.search_one("Patient", {"identifier": "urn:mrn|123"})
```

- **Search parameters:** a list value repeats its parameter, which FHIR reads as AND, as in
  `{"date": ["ge2026-01-01", "le2026-02-01"]}`. For OR, write one comma-separated string.
- **Resources** are plain dicts in FHIR's own JSON shape.

## Patient and measurements

`ovok.patient` provides FHIR-backed `appointments`, `schedules`, `slots`,
`measurements`, `care_plans`, `questionnaires`, `questionnaire_responses`, `devices` and
`notifications`. It also supports `latest_observations()` and `dashboard()`. List methods
return resource lists; `dashboard()` reads the signed-in profile and its latest observations,
measurement history and care plans.

`ovok.measurements` builds and saves observations for the measurement types in
`MeasurementTypeKey`. Measurement fields use snake_case, while generated FHIR resources
retain FHIR's camelCase JSON fields:

```python
from ovok_sdk import MeasurementTypeKey

result = ovok.measurements.save({
    "patient_id": "patient-id",
    "measurement": {
        "measurement_type_key": MeasurementTypeKey.heart_rate,
        "heart_rate": 72,
    },
})
```

The namespace also supports `build`, `save_observations`, `latest`, `last_for_patient`,
`codes_for` and `offline_queue.flush`. Saves use conditional FHIR entries, link related
observations, split large bundles, and queue retryable failures in the configured storage.
Pass `identifier_systems` and `replace=True` to update an observation already saved under
one of those reading identifiers.

## Questionnaires and content

`ovok.questionnaires.populate(questionnaire_id)` populates a questionnaire.
`ovok.questionnaire_responses` supports `submit`, `submit_with_observations` and `extract`.
`ovok.content` provides `search`, `retrieve`, `create`, `update`, `delete`, `map`,
`copy_from_parent` and `copy_all_from_parent` for language-scoped CMS content.

## Documents and integrations

`ovok.documents` supports `list`, upload creation and replacement, metadata updates,
`delete`, `download_url` and `public_download_url`. Download URLs are constructed locally;
pass query values such as width, height and fit when needed.

`ovok.video_calls` groups `appointments`, `access` and `emails`, and provides
`notify_active`. `ovok.ai` provides `translate`, `fhir_search` and chat sessions/messages.
`ovok.wearables` groups provider and connection operations. `ovok.bots.execute(identifier,
input)` runs a bot.

## Platform namespaces

- `ovok.projects.list()` lists projects the signed-in user administers. Its `members` namespace supports
  `list`, `invite`, `update` and `remove`; `features` supports `retrieve` and `update`; `settings` supports
  `retrieve`, `update` and `update_text`.
- `ovok.accounts.list()` reads the signed-in user's accounts.
- `ovok.locales.retrieve()` and `update()` manage project locales. `ovok.localizations` supports `list`,
  `retrieve`, `update` and `delete`; `localizations.i18next` reads and writes the translation document.
- `ovok.email.templates.list()` reads the available email templates.
- `ovok.invites` exposes `record_shares`, `care`, `practitioners` and `patients` flows, each with `create` and
  `accept`. `invites.accept(body)` selects the record-share or care flow from the signed-in profile.
- `ovok.sharing` exposes `invites.create` / `redeem`, `shares.list` / `revoke`, and `settings.retrieve` /
  `update`.

## Slim and Signals

`ovok.slim` groups the Slim API resources. `patients` supports `list`, `retrieve`, `create`, `update`, `delete`,
`everything` and `current_vitals`, with patient `thresholds.retrieve` / `update`. `devices` supports CRUD,
`assign_to_project`, `live`, `live_telemetry`, `telemetry`, and effective or last observation ID reads. Concurrent
identical live reads share one request. `notifications` supports `list` and `recent`; its `notes` resource supports
`list`, `create`, `update`, `patch` and `delete`.

Other Slim resources are `thresholds` (`retrieve`, `update`), `trends` (`graphs`, `realtime`), `sleep.statistics`,
`locations` (CRUD and `list`), `zones` (`list`, `retrieve`, `device_counts`), `exports.patient_pdf`,
`change_history.list`, `projects` (`retrieve`, `update`, `users`, `current_users`, `device_counts`, `patient_counts`,
and `children.list` / `create` / `delete`), `policies` (CRUD and `list`), `organizations` (CRUD),
`practitioners.invite`, `users.delete` and `health()`.

```python
devices = ovok.slim.devices.live({"page": 0, "count": 20})
resident = ovok.slim.patients.retrieve("patient-id")
unreachable = ovok.signals.undeliverable.list()
```

`ovok.signals` also supports `enable(patient_id)` and `gateway.request(method, path, query=..., body=...)`. Gateway
paths are lists of safe path segments. `undeliverable.repair()` repairs the residents returned by `list()`;
`repair({"patientIds": [...]})` limits the repair.

## CMS

`ovok.cms.legal_page(slug)`, `translations(locale)`, `documents.list(collection, params)` / `retrieve(collection, slug,
params)`, and `release_notes.list(params)` read published content. CMS requests include the client's `tenant_code`;
collection queries support `locale`, `page`, `limit` and `sort`.

## Health and consent

- `ovok.health.check()` reads the healthcheck. It always answers 200, so read `ok`.
- `ovok.consent`: `legal.retrieve()`, `legal.set(accepted)`, `medical.retrieve()`, `medical.set(accepted)` and
  `request_signature(consent_id)`.

## Paging

A list call returns a `Page` with `data`, `has_more` and `total` (`None` when the server does not say).
`auto_paging_iter()` walks every item from that page on, fetching later pages as it goes, and `next_page()`
fetches one page.

## Errors

Every failure is an `OvokError` with `status`, `code`, `type`, `message`, `trace_id`, `request_id`,
`retry_after_ms` and `field_errors`. Catch a subclass to handle one case:

| Class | When |
|---|---|
| `OvokValidationError` | 400, 422, or a request the SDK refuses to send |
| `OvokAuthenticationError` | 401 |
| `OvokPermissionError` | 403: signed in, but not allowed |
| `OvokNotFoundError` | 404 |
| `OvokConflictError` | 409 |
| `OvokRateLimitError` | 429 |
| `OvokServiceError` | 5xx |
| `OvokConnectionError` | No answer: network failure or timeout |
| `OvokConfigError` | A bad option, or an action this client cannot take |

Quote `trace_id` when you report a problem. The `x-trace-id`, `x-request-id` and `Retry-After` headers win over
the same details in the body.

## Documentation

See the [Python SDK documentation](https://docs.ovok.com/python-sdk) for guides and the
[API reference](https://docs.ovok.com/python-sdk/reference), generated from the docstrings.

## Development

```bash
uv run python -m unittest discover -s tests
uv run ruff check
uv run --group docs python scripts/generate_reference.py
```

`ruff check` fails on a missing or malformed docstring. `generate_reference.py` writes the API
reference pages to `docs/reference/`; see [AGENTS.md](AGENTS.md) for the docs rule.

Tests never reach a server: `tests/fake_transport.py` queues replies, and `tests/route_harness.py` turns a
namespace call into a line such as `"GET v1/me/consent/legal"` (`request_line`) or its JSON body
(`request_body`).

## Releases

The release workflow publishes to PyPI on every push:

| Branch | Git tag | PyPI version |
|---|---|---|
| `development` | `v0.1.0-dev.<N>+<sha7>` | `0.1.0.dev<N>` |
| `staging` | `v0.1.0-rc.<n>` | `0.1.0rc<n>` |
| `main` | `v0.1.0` | `0.1.0` |

`N` is the commit count of `development`. The base version is `version` in `pyproject.toml`. Bump it
after a final release; the workflow refuses to publish dev or rc builds of a version that is already
released.

## License

[GNU Affero General Public License v3.0](LICENSE).
