Metadata-Version: 2.5
Name: biocompile
Version: 0.1.0a1
Summary: Python client for the Biocompile Public API
Project-URL: Homepage, https://www.biocompile.com
Project-URL: Documentation, https://www.biocompile.com/docs/api-overview/
Author: Biocompile
License-Expression: Apache-2.0
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Description-Content-Type: text/markdown

# Biocompile Python SDK

A small, dependable client for the [Biocompile API](https://www.biocompile.com/docs/api-overview/)
(v1). It handles the token exchange, refresh, retries and error mapping, and
returns the API's JSON as plain `dict`s. Python 3.10+, one dependency (`httpx`).

```bash
pip install --pre biocompile    # pre-release while the API is in alpha
```

## Quickstart

### Personal access token

Create a personal access token (`bcp_...`) in Biocompile under your account
settings, choosing the scopes the script needs.

```python
from biocompile import Client

with Client(
    base_url="https://app.biocompile.com",
    personal_access_token="bcp_...",
    organization_id="org_...",       # optional; required for Knowledge Hub file calls
    scopes=["projects:read", "runs:read"],  # optional; narrows the token further
) as client:
    for org in client.organizations.iter():
        print(org["id"], org["name"])

    for run in client.runs.iter("org_..."):
        print(run["slug"], run["status"])
```

### Service account

A service account authenticates with its user id and a key (`bcs_...`). It is
always bound to one organization.

```python
client = Client(client_id="<service account id>", client_secret="bcs_...")
client.files.upload("results/table.tsv", absolute_path="/projects/alpha/table.tsv")
```

### From the environment

```python
client = Client.from_env()
```

reads `BIOCOMPILE_BASE_URL` (default `https://app.biocompile.com`),
`BIOCOMPILE_PAT`, or `BIOCOMPILE_CLIENT_ID` + `BIOCOMPILE_CLIENT_SECRET`, and
`BIOCOMPILE_ORGANIZATION_ID`. Keyword arguments override the environment.

## How authentication works

The client never sends your long-lived credential to the API. On the first call
it exchanges it at `POST /api/v1/oauth/token` for a bearer token that lives
about ten minutes, caches that token, and exchanges again about a minute before
it expires. If the API still answers `401` (say, the token was revoked
server-side), the client exchanges once more and retries the call once; a
`credential_revoked` answer is raised straight away. Pass `token=` instead to
use an access token you obtained yourself; it is never refreshed.

Credentials are held in a wrapper that prints as `***`, and neither the client's
`repr` nor any error message contains them.

## Resources

Organization-scoped methods take the organization id first; pass `None` to use
the client's `organization_id`.

| Area | Methods | Scope |
| --- | --- | --- |
| `client.organizations` | `list`, `iter`, `get` | `projects:read` |
| `client.projects` | `list`, `iter`, `get` | `projects:read` |
| | `create(org, name, ...)`, `update(org, project_id, ...)`, `archive(org, project_id)` | `projects:write` |
| `client.contributions` | `list(org, project_id, status=...)`, `iter`, `get` | `entities:read` |
| | `accept`, `withdraw`, `request_revision(org, project_id, contribution_id, instructions)` | `contributions:review` |
| `client.entities` | `list(org, entity_type, **filters)`, `iter` | `entities:read` |
| `client.observations`, `client.hypotheses` | `list`, `iter`, `get(org, slug)` | `entities:read` |
| `client.executables` | `list`, `iter`, `catalog`, `get` | `runs:read` |
| | `invoke(org, executable_id, payload)`, `create_plan(...)` | `runs:execute` |
| `client.plans` | `refresh(org, plan_id)`, `invoke(org, plan_id, payload)` | `runs:execute` |
| | `approve_spending(org, plan_id, binding)`, and `direct_user_intent=`, `network_access=`, `network_acknowledged=` on the invoke calls | `runs:execute` + `spending:approve` |
| `client.runs` | `list`, `iter`, `get`, `logs`, `iter_logs`, `download_artifact(org, run, artifact, dest)` | `runs:read` |
| | `cancel` | `runs:execute` |
| `client.files` | `list`, `iter`, `get`, `download(file_id, dest)` | `files:read` |
| | `upload(path, ...)`, and the steps `init_upload`, `upload_chunk`, `complete_upload`, `upload_status`, `cancel_upload` | `files:write` |
| | `create_folder(name, path=...)`, `move(file_id, new_path)`, `rename(file_id, name)`, `update(file_id, description=..., tags=...)`, `delete(file_id)` | `files:write` |
| `client.workbenches` | `list_action_types`, `get_action_type`, `get`, `list_changes` | `entities:read` |
| | `open(org, project_slug)`, `stage(org, workbench, intents, expected_revision_id=...)`, `submit(...)` | `entities:write` |
| `client.conversations` | `create`, `list`, `iter`, `get` (transcript), `send`, `get_turn`, `send_and_wait` | `agent:converse` |
| | `create(..., full_auto=True)` | `agent:converse` + `spending:approve` |
| `client.webhooks` | `list`, `create`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `iter_deliveries`, `redeliver` | `webhooks:manage` |

`iter*` helpers page through a list for you, following `pagination.next_cursor`
where the API offers a cursor and `skip`/`limit` (or `page`) otherwise. Filters
are passed as keyword arguments named as in the API (`status=`, `project_slug=`...).

Knowledge Hub file calls act in the organization the token is pinned to: a
service account always is, and a PAT is when the client has `organization_id`.

### Uploads and downloads

```python
status = client.files.upload(
    "reads.fastq",
    absolute_path="/projects/alpha/reads.fastq",
    on_conflict="rename",
    progress=lambda sent, total: print(f"{sent}/{total}"),
)
print(status["file_id"])

client.files.download(status["file_id"], "copy.fastq")
client.runs.download_artifact("org_...", "run_abc", "art_1", "outputs/")  # server filename
```

`upload` uses the resumable protocol: it sends each chunk with its SHA-256,
skips chunks the server already holds, completes the upload and (unless
`wait=False`) polls until processing finishes. An interrupted upload resumes
with `client.files.upload(path, upload_id="resumable_...")`; a terminal failure
raises `UploadFailed` with the server's status on `.upload`. Downloads stream to
a `.part` file and are renamed into place only when complete; `dest` may be a
path, a directory, or a binary file object.

### Changing a project: workbenches

Writes are staged in a workbench and submitted as a contribution that a person
reviews in Biocompile; nothing changes in the project until they accept it
(see [Propose changes through the API](https://www.biocompile.com/docs/api-workbenches/)).

```python
workbench = client.workbenches.open("org_...", "rp_a7x3", title="Nightly import")
staged = client.workbenches.stage(
    "org_...",
    workbench["workbench_id"],
    [{"operation": "create", "collection": "hypotheses",
      "payload": {"statement": "Protein X activates pathway Y."},
      "rationale": "Seen in three independent screens."}],
    expected_revision_id=workbench["revision_id"],
    idempotency_key="import-2026-10-27-batch-1",   # makes a retry safe
)
result = client.workbenches.submit(
    "org_...", workbench["workbench_id"],
    expected_revision_id=staged["revision_id"],
    title="Nightly import", summary="Three hypotheses from the screening run.",
)
print(result["review_url"])
```

### Reviewing contributions and approving spending

A contribution waits for a person to review it. A personal access token (or an
application acting for you) with `contributions:review` decides as you would in
the app; service accounts can read contributions but not decide
(see [Review contributions through the API](https://www.biocompile.com/docs/api-contributions/)).

```python
for contribution in client.contributions.iter("org_...", "rp_a7x3", status="awaiting_approval"):
    result = client.contributions.accept("org_...", "rp_a7x3", contribution["contribution_id"],
                                         reason="Checked against the screen.")
    if result["status"] == "conflicted":   # nothing was applied
        client.contributions.request_revision("org_...", "rp_a7x3", result["contribution_id"],
                                              "Rebase on the current project.")
```

With `spending:approve` a token approves the credit spending of its own plan,
for the plan's current quote only:

```python
plan = client.executables.create_plan("org_...", "exe_...", input={"samples": "/projects/alpha/samples.csv"})
approval = plan["approval"]
client.plans.approve_spending("org_...", plan["plan_id"], approval.get("spending_binding") or approval["binding"])
run = client.plans.invoke("org_...", plan["plan_id"], idempotency_key="screen-2026-10-30")
```

Grant `spending:approve` and `contributions:review` only to credentials that
should make those decisions without anyone opening the app
(see [Scopes](https://www.biocompile.com/docs/api-scopes/)).

### Conversations with the agent

```python
conversation = client.conversations.create("org_...", title="Weekly scan", project_slug="rp_cardio01")
turn = client.conversations.send_and_wait(
    "org_...", conversation["conversation_id"],
    "Summarize this week's new papers on the target.",
    idempotency_key="weekly-scan-2026-10-27",
    timeout=900,
)
print(turn["status"])          # completed, blocked, failed or cancelled
for event in turn["events"]:
    for part in event["parts"]:
        if part["type"] == "text":
            print(event["author"], part["text"])
```

`send_and_wait` sends the message, then polls `get_turn` with `after=` and
returns the final turn with all its events. A turn in `requires_action` waits
for a person in the app, so polling continues through it unless
`return_on_requires_action=True`. After `timeout` seconds it raises
`TurnTimeout` (the last state is on `.turn`).

`create(..., full_auto=True)` (needs `spending:approve`, and an organization
that allows Auto Mode) lets the agent answer its own checkpoints, spending
included, under your Auto Mode policy; the conversation's `autonomy_mode` is
then `auto`. Contributions, project creation and open questions still stop.

### Webhooks

`client.webhooks.create(org, url, events)` returns the signing `secret` once;
store it. Verify each delivery against the **raw** body before parsing it:

```python
from biocompile import WebhookVerificationError
from biocompile.webhooks import verify_signature


def receive(headers, raw_body: bytes) -> int:
    try:
        event = verify_signature(SECRET, headers, raw_body)  # tolerance_seconds=300
    except WebhookVerificationError:
        return 400
    queue_for_processing(headers["webhook-id"], event)  # dedupe on webhook-id; answer fast
    return 204
```

It implements [Standard Webhooks](https://www.standardwebhooks.com/): an
HMAC-SHA256 over `webhook-id.webhook-timestamp.body` with the base64 key after
`whsec_`, compared in constant time against every `v1,` entry in
`webhook-signature`, and refuses timestamps more than `tolerance_seconds`
from now.

## Errors

Every error derives from `biocompile.BiocompileError`. HTTP errors are
`ApiError` subclasses carrying `.status`, `.code` (the API's stable problem
code, e.g. `insufficient_scope`), `.detail`, `.problem` (the full RFC 9457
document) and `.retry_after`:

| Class | When |
| --- | --- |
| `AuthenticationError` | 401 (`credential_revoked`, expired token), or the token endpoint rejected the credential (`invalid_grant`, `invalid_client`) |
| `PermissionDenied` | 403: `insufficient_scope` (see `.missing_scopes`), `organization_mismatch`, `not_public`; also `invalid_scope` / `invalid_target` from the token endpoint |
| `NotFound` | 404 |
| `RateLimited` | 429 after retries ran out (`.retry_after`) |
| `ServiceUnavailable` | 503, e.g. `revocation_unavailable`, after retries ran out |
| `ApiError` | any other non-2xx status |
| `TransportError` | no HTTP response at all (DNS, connection, timeout) |
| `TurnTimeout` | `conversations.send_and_wait` ran out of time (`.turn`) |
| `WebhookVerificationError` | `webhooks.verify_signature` refused a delivery |

```python
from biocompile import PermissionDenied

try:
    client.runs.cancel("org_...", "run_abc")
except PermissionDenied as exc:
    if exc.code == "insufficient_scope":
        print("token is missing:", ", ".join(exc.missing_scopes))
```

## Retries and rate limits

* `429` and `503` are retried up to `max_retries` times (default 3), waiting for
  the server's `Retry-After` (or a short jittered backoff when it sends none).
  A `Retry-After` longer than `max_retry_wait` (default 60 s) is raised at once
  instead of blocking the script.
* Non-idempotent `POST`s are only retried when the server refused them before
  doing anything: any `429`, or a `503` the API's admission step raised
  (`revocation_unavailable`, `rate_limit_unavailable`). A `POST` that carries
  an idempotency key the server deduplicates on (`idempotency_key=` on
  `workbenches.stage` and `conversations.send`, or a `client_turn_id`) is
  retried like a `GET`. Use the `idempotency_key` field on invocations if you
  add retries of your own.
* Connection failures (nothing reached the server) are retried the same way.
* `client.rate_limit` holds the `RateLimit-Limit`/`-Remaining`/`-Reset` values
  of the most recent response that carried them.

## Documentation and license

Guides and the full API reference: <https://www.biocompile.com/docs/api-overview/>.
Licensed under the Apache License 2.0 (see `LICENSE`).
