Metadata-Version: 2.4
Name: medsplain
Version: 1.5.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 covers exactly the **two credentials issued to your organization** — each
client has 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         | Read your organization and manage its API keys.   |

> Platform administration — creating organizations, recording payments, managing admin
> users and system config — is **not** part of this SDK. Those endpoints require an
> internal admin credential and are not intended for organization use.

## Installation

```bash
pip install medsplain
```

## 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())
```

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

### Retries

A request is only replayed when replaying it **cannot duplicate work**. Losing a
response is annoying; silently creating a second API key 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`.                |
| `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`.

## 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
rm -rf dist/                                     # ALWAYS — see below
python -m build                                  # one artifact, for both indexes
twine check dist/*                               # gate: metadata + README render

twine upload --repository testpypi dist/*        # 1) rehearse
twine upload dist/*                              # 2) publish for real
```

Verify the TestPyPI upload in a *throwaway* venv, never this one — an editable install
here would shadow whatever you downloaded. Dependencies resolve from real PyPI:

```bash
python -m venv /tmp/verify
/tmp/verify/bin/pip install --index-url https://test.pypi.org/simple/ \
    --extra-index-url https://pypi.org/simple/ medsplain
/tmp/verify/bin/python -c "import medsplain as m; print(m.__version__, m.DEFAULT_BASE_URL)"
```

> **Clean `dist/` before every build.** `python -m build` adds to the directory, it does
> not replace it, so a stale `dist/` accumulates old versions — and `twine upload dist/*`
> uploads *everything* it finds. That means failing on versions already published, and
> permanently releasing any half-finished version still sitting there. Neither index lets
> you delete or overwrite a version afterwards. Starting from an empty `dist/` makes
> `dist/*` mean exactly what you just built.

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