Metadata-Version: 2.4
Name: medsplain
Version: 1.4.0
Summary: Official Python client for the Medsplain TX medical text simplification API
Author: Medsplain TX
License: MIT
License-File: LICENSE
Keywords: api,client,medical,medsplain,sdk,text-simplification
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Healthcare Industry
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Requires-Dist: httpx>=0.24
Provides-Extra: dev
Requires-Dist: anyio>=4.0; extra == 'dev'
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: mypy>=1.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.1; extra == 'dev'
Requires-Dist: twine>=4.0; extra == 'dev'
Description-Content-Type: text/markdown

# Medsplain TX — Python SDK

Official Python client for the **Medsplain TX** medical text simplification API. Send
clinical / medical text and get back a plain-language explanation.

The SDK ships **three clients**, one per credential type — each with an async twin:

| Client | Async twin | Credential | Use it to… |
| --------------------- | -------------------------- | ------------------------------------- | ------------------------------------------------- |
| `Medsplain`           | `AsyncMedsplain`           | `mds_live_…` API key (`x-api-key`)    | Simplify medical text (`translate`, health check).|
| `OrganizationClient`  | `AsyncOrganizationClient`  | `mds_org_secret_…` secret key         | Manage your organization and its API keys.        |
| `AdminClient`         | `AsyncAdminClient`         | Cognito JWT access token (`Bearer`)   | Platform admin: organizations, payments, admins, config. |

## Installation

```bash
pip install medsplain
```

> Until this is published to PyPI, install from source — see [Local development](#local-development).

## Quick start

```python
from medsplain import Medsplain

client = Medsplain(api_key="mds_live_...")

result = client.translate("Patient presents with acute myocardial infarction.")
print(result.simplified_text)
print(f"{result.source_chars} chars in, {result.output_chars} chars out")
```

The client is a context manager, which closes the connection pool for you:

```python
with Medsplain(api_key="mds_live_...") as client:
    result = client.translate("CBC shows leukocytosis with left shift.")
    print(result.simplified_text)
```

## Async

Every client has an async twin with the same methods, same models, and the same retry
policy — the only difference is `await`, `async with`, and `aclose()` instead of
`close()`:

```python
import asyncio
from medsplain import AsyncMedsplain

async def main():
    async with AsyncMedsplain(api_key="mds_live_...") as client:
        result = await client.translate("CBC shows leukocytosis with left shift.")
        print(result.simplified_text)

asyncio.run(main())
```

`AsyncAdminClient.login()`, `.get_invite_status()`, and `.accept_invite()` are
coroutines too — `admin = await AsyncAdminClient.login(...)`.

Async requests can of course be issued concurrently:

```python
async with AsyncMedsplain(api_key="mds_live_...") as client:
    results = await asyncio.gather(*(client.translate(t) for t in texts))
```

Mind your plan's rate limit when you do — a burst of concurrent calls is exactly what
trips `RateLimitError`.

## Configuration

| Argument      | Env var              | Default                  | Notes                                              |
| ------------- | -------------------- | ------------------------ | -------------------------------------------------- |
| `api_key`     | `MEDSPLAIN_API_KEY`  | —                        | Required. Your `mds_live_` key.                    |
| `base_url`    | `MEDSPLAIN_BASE_URL` | `PRODUCTION_BASE_URL`    | API root, **without** the `/v1` prefix. See [Environments](#environments). |
| `timeout`     | —                    | `30.0`                   | Per-request timeout in seconds.                    |
| `max_retries` | —                    | `2`                      | See [Retries](#retries).                           |

### Environments

There is **one package** for every environment. Which deployment you talk to is a
runtime choice, not a build or an install channel:

| Constant | Host |
| --- | --- |
| `PRODUCTION_BASE_URL` *(default)* | `https://medsplain.ionixxtech.com` |
| `DEVELOPMENT_BASE_URL` | `https://medsplaindev.ionixxtech.com` |

```python
from medsplain import Medsplain, DEVELOPMENT_BASE_URL

prod = Medsplain(api_key="mds_live_...")                                # production
dev  = Medsplain(api_key="mds_live_...", base_url=DEVELOPMENT_BASE_URL) # development
```

Or set `MEDSPLAIN_BASE_URL` once and pass nothing — handy for CI, and the only way to
point at a local backend:

```bash
export MEDSPLAIN_BASE_URL=http://localhost:8000   # or :8001 if you ran `python app.py`
```

> **`AdminClient` has no default environment.** It requires `base_url` explicitly (or
> `MEDSPLAIN_BASE_URL`) and raises `MedsplainError` otherwise. A Cognito token can be
> valid against more than one deployment, so — unlike the API-key clients — the
> credential alone doesn't tell you which one you're pointed at, and
> `delete_organization` / `delete_admin` cascade to every API key they own. A script
> that forgets to say where it's aiming should stop, not guess.

### Retries

A request is only replayed when replaying it **cannot duplicate work**. Losing a
response is annoying; silently creating a second payment is worse.

| Failure | `GET` / `DELETE` / `PUT` | `POST` / `PATCH` |
| ------------------------------------------------------ | ------- | ------- |
| `429` — server declined to process it                   | retried | retried |
| Connect failure, never delivered (`ConnectError`, `ConnectTimeout`, `PoolTimeout`) | retried | retried |
| `5xx` — may have committed work before failing          | retried | **not** retried |
| Mid-flight failure (`ReadTimeout`, dropped connection)  | retried | **not** retried |

Backoff is exponential (0.5s, 1s, 2s… capped at 8s), or the server's `retry_after`
when it supplies one. Set `max_retries=0` to disable retries entirely.

```python
import os
client = Medsplain(
    api_key=os.environ["MEDSPLAIN_API_KEY"],
    base_url="https://your-medsplain-host.example.com",
    timeout=60.0,
)
```

## API

### `translate(text, *, model=None) -> TranslationResult`
Simplify medical text into plain language via `POST /v1/translate`. `text` must be
non-empty and at most **5000 characters** (the SDK checks this before sending; the
server answers `413` for anything longer).

> `model` is **deprecated** and raises a `DeprecationWarning`. The server selects the
> model itself and ignores what you pass; the argument will be removed in a future
> release.

### `health_check() -> HealthStatus`
Verify the API is reachable (`GET /health-check`).

### `TranslationResult`
| Field             | Type   | Description                                       |
| ----------------- | ------ | ------------------------------------------------- |
| `original_text`   | `str`  | Cleaned input text the server processed.          |
| `simplified_text` | `str`  | The plain-language simplification.                |
| `source_chars`    | `int`  | Character count of the input.                     |
| `output_chars`    | `int`  | Character count of the output.                    |
| `is_medical_text` | `bool` | Whether the input was detected as medical.        |
| `is_conversation` | `bool` | Whether the input was detected as conversational. |
| `raw`             | `dict` | Full, unmodified response payload.                |

## Error handling

Every error inherits from `MedsplainError`. HTTP failures raise an `APIStatusError`
subclass chosen by status code:

```python
from medsplain import (
    Medsplain, AuthenticationError, RateLimitError,
    PermissionDeniedError, APIStatusError,
)

client = Medsplain(api_key="mds_live_...")
try:
    result = client.translate("...")
except AuthenticationError:
    ...                       # 401 — key missing/invalid/expired/revoked
except RateLimitError as e:
    print("retry after", e.retry_after)   # 429 — rate limit or token quota
except PermissionDeniedError:
    ...                       # 403 — no active plan / subscription
except APIStatusError as e:
    print(e.status_code, e.code, e.message)
```

| Exception                   | Status | Meaning                                                              |
| --------------------------- | ------ | -------------------------------------------------------------------- |
| `BadRequestError`           | 400    | Malformed request / validation error.                                |
| `AuthenticationError`       | 401    | API key or token missing, invalid, expired, or revoked.              |
| `PermissionDeniedError`     | 403    | No active plan or subscription; insufficient role.                   |
| `NotFoundError`             | 404    | Resource not found.                                                  |
| `ConflictError`             | 409    | Duplicate resource or state conflict — check `.code`.                |
| `GoneError`                 | 410    | Admin invitation already used or expired.                            |
| `PayloadTooLargeError`      | 413    | Text exceeds the server's 5000-character limit.                      |
| `UnprocessableEntityError`  | 422    | Readable input with no usable content (`EMPTY_TEXT`).                |
| `RateLimitError`            | 429    | Rate limit hit or token quota exhausted.                             |
| `ServerError`               | 5xx    | Server-side failure (including 503 — retried automatically).         |
| `APIConnectionError`        | —      | The request never reached the server.                                |

Every `APIStatusError` carries `.status_code`, `.code`, `.message`, `.retry_after`, and
`.body` — the full parsed payload. Some errors put actionable detail in there: a `400`
`API_KEY_LIMIT_EXCEEDED` from `create_api_key`, for instance, lists the expired keys you
could delete to free a slot.

## Organization management

`OrganizationClient` manages your organization and the API keys your applications use.
It authenticates with your **organization secret key** (`mds_org_secret_…`) — a different
credential from the `mds_live_` key above — sent in the `X-Organization-Secret-Key` header.

```python
from medsplain import OrganizationClient

with OrganizationClient(
    org_id="org_000000000001",
    secret_key="mds_org_secret_...",
) as org:
    # Inspect the organization
    info = org.get_organization()
    print(info.plan_type, info.total_tokens_used)

    # Create a key — the full mds_live_ value is returned ONLY here
    created = org.create_api_key(name="production", description="prod server")
    print(created.api_key)          # store this securely; never shown again

    # List, fetch, disable, delete
    keys = org.list_api_keys(is_active=True)
    one = org.get_api_key(created.id)
    org.set_api_key_status(created.id, is_active=False)
    org.delete_api_key(created.id)
```

### Configuration

| Argument     | Env var                     | Default                 | Notes                                       |
| ------------ | --------------------------- | ----------------------- | ------------------------------------------- |
| `org_id`     | `MEDSPLAIN_ORG_ID`          | —                       | Required. e.g. `org_000000000001`.          |
| `secret_key` | `MEDSPLAIN_ORG_SECRET_KEY`  | —                       | Required. Your `mds_org_secret_` key.       |
| `base_url`   | `MEDSPLAIN_BASE_URL`        | `http://localhost:8000` | API root, **without** the `/v1` prefix.     |

`timeout` and `max_retries` work exactly as on `Medsplain`. With both env vars set you can
construct it with no arguments: `OrganizationClient()`. (The SDK does not load `.env` files —
export the variables yourself.) `AsyncOrganizationClient` takes the same arguments.

### Methods

| Method | API call | Returns |
| ------ | -------- | ------- |
| `get_organization()` | `GET /v1/organizations/{org_id}` | `Organization` |
| `create_api_key(name, *, description=None, expires_at=None)` | `POST …/api-keys` | `ApiKeyWithSecret` |
| `list_api_keys(*, is_active=None, limit=50, offset=0)` | `GET …/api-keys` | `ApiKeyList` |
| `get_api_key(key_id)` | `GET …/api-keys/{key_id}` | `ApiKey` |
| `set_api_key_status(key_id, *, is_active)` | `PATCH …/api-keys/{key_id}/status` | `ApiKey` |
| `delete_api_key(key_id)` | `DELETE …/api-keys/{key_id}` | `dict` |

`expires_at` accepts a `datetime` or an ISO-8601 string. Errors map to the same exception
hierarchy as `Medsplain` (e.g. an invalid secret key raises `AuthenticationError`, an
organization with no active plan raises `PermissionDeniedError`).

### Result models

`Organization` — `id`, `name`, `owner_email`, `access_request_id` (set only when the org
was created from an approved API-access request, else `None`), `plan_type`, `token_limit`
(`int`, `"Unlimited"`, or `None`), `total_tokens_used`, `max_api_keys`, `api_keys_created`,
`subscription_start_date`, `subscription_end_date`, `is_active`, `created_at`,
`updated_at`, `raw`.

`ApiKey` — `id`, `organization_id`, `key_prefix`, `name`, `description`,
`total_tokens_used`, `last_used_at`, `is_active`, `expires_at`, `created_at`, `raw`.

`ApiKeyWithSecret` — all `ApiKey` fields plus `api_key` (the full `mds_live_` key, returned
only by `create_api_key`).

`ApiKeyList` — `api_keys` (list of `ApiKey`), `total`, `raw`.

> Datetime fields are returned as raw ISO-8601 strings (or `None`), and every model keeps
> the untouched payload in `raw`.

## Platform administration

`AdminClient` manages the platform itself — organizations, payments, admin users, and
system config. It authenticates with a **Cognito JWT access token** for an admin user
(`Authorization: Bearer …`). A few operations require **super-admin** privileges; the
server enforces the role and returns `403` (`PermissionDeniedError`) otherwise.

```python
from medsplain import AdminClient

# Option 1 — log in (calls POST /v1/login)
admin = AdminClient.login("admin@example.com", "password", base_url="https://host")
print(admin.tokens["refresh_token"])     # full Cognito token set is on .tokens

# Option 2 — bring your own token
admin = AdminClient(access_token="eyJ...", base_url="https://host")

with admin:
    # Organizations
    org = admin.create_organization(name="Acme Health", owner_email="ops@acme.com")
    print(org.secret_key)                 # mds_org_secret_... — give this to the org owner
    admin.list_organizations(is_active=True)
    admin.update_organization(org.id, name="Acme Health Inc")
    admin.rotate_organization_secret(org.id)

    # Payments — status="paid" activates the org and applies the plan
    pay = admin.create_payment(org.id, amount=499, currency="usd",
                               status="paid", plan_type="pro")
    admin.list_payments(organization_id=org.id)
    admin.update_payment(org.id, pay.id, status="refunded")

    # Admin users (super-admin) and config
    admin.list_admins()
    admin.create_admin("newadmin@example.com")      # super-admin only — emails an invite
    admin.list_config()
    admin.update_config("rate_limit_api_key_max_requests", "120")  # super-admin only

    admin.delete_organization(org.id)
```

Access tokens expire after roughly an hour. `refresh()` swaps the stored refresh token for
a fresh access token and updates the client in place:

```python
from medsplain import AuthenticationError

try:
    admin.list_organizations()
except AuthenticationError:
    admin.refresh()               # uses the username + refresh_token from login()
    admin.list_organizations()
```

If you built the client with a bring-your-own token, pass both explicitly:
`admin.refresh(username="admin@example.com", refresh_token="...")`. A `401` from `refresh()`
means the refresh token itself is dead (global sign-out, password reset) — log in again.

### Configuration

| Argument       | Env var                  | Default                 | Notes                                       |
| -------------- | ------------------------ | ----------------------- | ------------------------------------------- |
| `access_token` | `MEDSPLAIN_ADMIN_TOKEN`  | —                       | Required. Admin Cognito JWT access token.   |
| `base_url`     | `MEDSPLAIN_BASE_URL`     | `http://localhost:8000` | API root, **without** the `/v1` prefix.     |

`AdminClient.login(username, password, *, base_url=…)` returns a ready client and stores the
full token set (`access_token`, `refresh_token`, `id_token`, `is_verified`) on `.tokens`,
plus the login name on `.username` so `refresh()` works with no arguments.

### Admin invitations

`create_admin(email)` does **not** grant access on its own — it creates an *unbound* admin
row (`cognito_sub is None`) and emails a single-use invitation link valid for **7 days**.
The invitee becomes an admin only after they sign up with that email, verify it by OTP, and
accept:

```python
from medsplain import AdminClient, GoneError

# Super-admin side
created = admin.create_admin("newadmin@example.com")
print(created.invite_sent, created.invite_accepted)   # True, False
admin.resend_admin_invite("newadmin@example.com")     # fresh 7-day token; old link dies

# Invitee side — public, no admin token needed (the link's token is the credential)
try:
    status = AdminClient.get_invite_status(token, base_url="https://host")
except GoneError as e:
    print(e.code)            # INVITE_USED | INVITE_EXPIRED

if not status.user_exists:
    ...                      # sign up with status.email (email + password)
elif not status.email_verified:
    ...                      # run the /v1/verify-email OTP flow
if status.can_accept:
    AdminClient.accept_invite(token, base_url="https://host")
```

`get_invite_status` and `accept_invite` are **classmethods** — the invitee has no admin
token yet, so there is no client to construct.

### Methods

| Method | API call | Role | Returns |
| ------ | -------- | ---- | ------- |
| `login(username, password, *, base_url=…)` *(classmethod)* | `POST /v1/login` | — | `AdminClient` |
| `refresh(username=None, refresh_token=None)` | `POST /v1/refresh-token` | — | `RefreshedTokens` |
| `create_organization(*, name, owner_email)` | `POST /v1/admin/organizations` | admin | `OrganizationWithSecret` |
| `list_organizations(*, is_active=None, limit=50, offset=0)` | `GET /v1/admin/organizations` | admin | `OrganizationList` |
| `update_organization(org_id, *, name=None, owner_email=None, is_active=None)` | `PATCH /v1/admin/organizations/{org_id}` | admin | `Organization` |
| `delete_organization(org_id)` | `DELETE /v1/admin/organizations/{org_id}` | admin | `dict` |
| `rotate_organization_secret(org_id)` | `POST …/{org_id}/rotate-secret` | admin | `RotateSecretResult` |
| `create_payment(org_id, *, amount, currency, status, plan_type, …)` | `POST …/{org_id}/payments` | admin | `Payment` |
| `update_payment(org_id, payment_id, *, status=None, …)` | `PATCH …/{org_id}/payments/{payment_id}` | admin | `Payment` |
| `list_payments(*, organization_id=None, payment_status=None, limit=50, offset=0)` | `GET /v1/admin/payments` | admin | `PaymentList` |
| `list_admins(*, limit=50, offset=0)` | `GET /v1/admin/admins` | admin | `AdminUserList` |
| `create_admin(email)` | `POST /v1/admin/admins` | super-admin | `AdminUser` |
| `resend_admin_invite(email)` | `POST /v1/admin/admins/resend-invite` | super-admin | `ResendInviteResult` |
| `get_invite_status(token, *, base_url=…)` *(classmethod)* | `GET /v1/admin/invite/{token}/status` | — (token) | `AdminInviteStatus` |
| `accept_invite(token, *, base_url=…)` *(classmethod)* | `POST /v1/admin/invite/{token}/accept` | — (token) | `dict` |
| `set_admin_status(email, *, is_active)` | `PATCH /v1/admin/admins` | super-admin | `AdminUser` |
| `delete_admin(email)` | `DELETE /v1/admin/admins` | super-admin | `dict` |
| `list_config()` | `GET /v1/admin/config` | admin | `ConfigList` |
| `update_config(key, value)` | `PATCH /v1/admin/config` | super-admin | `ConfigEntry` |

`create_payment` accepts `status` ∈ `pending|paid|failed|refunded`, `plan_type` ∈
`basic|pro|enterprise`, and `paid_at` as a `datetime` or ISO-8601 string. `update_payment`
accepts `status` ∈ `paid|failed|refunded` only — a payment never goes back to `pending`.
`list_admins` caps `limit` at **100**; the other list calls cap it at **1000**.

The admin-side models (`OrganizationWithSecret`, `OrganizationList`, `RotateSecretResult`,
`Payment`, `PaymentList`, `AdminUser`, `AdminUserList`, `AdminInviteStatus`,
`ResendInviteResult`, `RefreshedTokens`, `ConfigEntry`, `ConfigList`) follow the same
dataclass style — raw ISO-8601 datetimes and a `raw` payload field.

`AdminUser` — `id`, `email`, `cognito_sub` (`None` while the invitation is pending),
`is_active`, `created_at`, `updated_at`, `invite_sent` (only on the `create_admin`
response), `raw`, plus an `invite_accepted` convenience property.

`AdminInviteStatus` — `valid`, `email`, `expires_at`, `user_exists`, `email_verified`,
`raw`, plus a `can_accept` property (`user_exists and email_verified`).

`ResendInviteResult` — `status` (`"success"` | `"email_failed"`), `email`, `invite_sent`,
`expires_in_days`, `raw`.

`RefreshedTokens` — `access_token`, `id_token`, `expires_in`, `token_type`, `raw`.

## Local development

```bash
cd sdk
python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -e ".[dev]"

pytest                              # run tests
ruff check .                        # lint
mypy src/                           # type-check
python -m build                     # build wheel + sdist into dist/
```

### Releasing

Bump the version in **three** hardcoded places — keep them in step: `pyproject.toml`,
`__version__` in `src/medsplain/__init__.py`, and `USER_AGENT` in
`src/medsplain/_base.py`. Record the change in [CHANGELOG.md](CHANGELOG.md).

TestPyPI is a rehearsal of the *upload and install mechanics* — the same artifact that
will go to PyPI, uploaded somewhere harmless first to confirm it builds, renders, and
installs. It is **not** a channel for a dev-pointing build:

```bash
python -m build                                  # one artifact, for both indexes

twine upload --repository testpypi dist/*        # 1) rehearse
pip install --index-url https://test.pypi.org/simple/ \
            --extra-index-url https://pypi.org/simple/ medsplain   # deps come from real PyPI

twine upload dist/*                              # 2) publish for real
```

Never ship a build whose `DEFAULT_BASE_URL` differs from the one on PyPI. Two artifacts
with the same version and different backends is how "works on my machine" gets
manufactured — and neither index lets you overwrite a version to undo it. Environment is
the caller's runtime decision; see [Environments](#environments).

Request bodies and query params are built by the pure functions in
`src/medsplain/_payloads.py`, shared by the sync and async clients. Put validation and
field-omission rules there, not in a client method, so both surfaces stay identical.

## License

MIT — see [LICENSE](LICENSE).
