Metadata-Version: 2.5
Name: temper-hq
Version: 0.5.0
Summary: Python client for the Temper API: secure computers for the agents that act for your users
Project-URL: Homepage, https://github.com/temper-hq
Project-URL: Documentation, https://docs.temper.im/getting-started/sdks/
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: ai-agent,microvm,sandbox,sdk,temper
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Provides-Extra: attach
Requires-Dist: websockets>=15; extra == 'attach'
Description-Content-Type: text/markdown

# temper-hq

Python SDK for the Temper control plane REST API: a synchronous client built on httpx, with responses as immutable dataclasses.

Documentation: [SDK guide](https://docs.temper.im/getting-started/sdks/) · [API reference](https://docs.temper.im/api/)

```bash
pip install temper-hq
pip install 'temper-hq[attach]'   # optional: adds websockets for processes.attach
```

The import name is `temper_hq`. Requires Python 3.10+.

## Quick start

```python
from temper_hq import Temper, TemperError

# Without api_key, the client reads TEMPER_API_KEY; without base_url, it reads TEMPER_BASE_URL,
# then falls back to https://api.temper.im
with Temper(api_key="temper_sk_...", base_url="https://api.temper.im") as client:
    env = client.environments.create(end_user_id="user-42", resources={"cpus": 2, "memory_mib": 4096})
    print(env.id, env.state)                     # stays pending while no host is online

    for e in client.environments.iter(state="running"):   # pages automatically
        print(e.id)

    client.environments.suspend(env.id)
    client.environments.wake(env.id)             # call when a request arrives: also records activity
    client.environments.keep_awake(env.id, 3600)

    # Agent packages: paths and file objects are uploaded in streamed chunks
    up = client.agent.upload_package("1.4.0", "build/agent.squashfs")
    rollout = client.agent.start_rollout(version="1.4.0", percent=10, batch_size=5)
    client.agent.widen_rollout(rollout.id, 50)
    print(client.agent.get_rollout(rollout.id).environments)   # progress per environment

    # Browser leases (valid for 15 minutes)
    lease = client.browser.acquire_lease(environment_id=env.id)
    client.browser.release_lease(lease.id)
    # Releasing a write lease with save first returns releasing; it moves to released (saved) once the host
    # sends back the login state, and settles as discarded if nothing arrives within 60 seconds

    # Commands and files (if the environment is not running, it is woken first, waiting up to 90 seconds)
    r = client.environments.exec(env.id, "python3 --version", timeout_secs=30)
    print(r.exit_code, r.stdout, r.stderr)
    client.files.write_text(env.id, "notes/todo.md", "# todo\n", mkdir=True)
    print(client.files.read_text(env.id, "notes/todo.md"))
    client.files.upload(env.id, "data.csv", "local/data.csv")     # streamed
    client.files.download(env.id, "out/report.pdf", "report.pdf")  # streamed; renamed only once complete
    for entry in client.files.list(env.id, "notes").entries:
        print(entry.type, entry.name, entry.size)

    # Background process: follow output with long polling...
    proc = client.processes.spawn(env.id, "npm test")
    for chunk in client.processes.follow_output(env.id, proc.id):
        print(chunk.data, end="")
    # ...or follow it live over WebSocket and write to stdin (needs websockets)
    repl = client.processes.spawn(env.id, argv=["python3", "-i"], stdin=True)
    with client.processes.attach(env.id, repl.id) as a:
        a.write("print(1 + 1)\n")
        a.close_stdin()
        exit = a.wait(lambda c: print(c.data, end=""))   # None if the connection dropped: reconnect with a.next as since

    for ev in client.environments.iter_events(env.id, kind="agent_upgrade"):
        print(ev.at, ev.detail)

    try:
        client.environments.create(end_user_id="user-42")
    except TemperError as e:
        print(e.status, e.code, e.message)       # 409 environment_exists ...
```

## API

| Namespace | Methods |
| --- | --- |
| `client.environments` | `create`, `create_batch`, `get`, `get_config`, `set_config`, `wait_for`, `list`, `iter`, `suspend`, `resume`, `wake`, `keep_awake`, `destroy`, `events`, `iter_events`, `exec`, `update_idle`, `create_wakeup`, `list_wakeups`, `cancel_wakeup`, `list_schedules`, `cancel_schedule`, `update_agent_settings` |
| `client.files` | `read` (bytes), `read_text`, `iter_bytes`, `download`, `write` (bytes / file object / iterator), `write_text`, `upload` (local path), `list`, `delete` |
| `client.processes` | `spawn`, `list`, `get`, `output` (long polling), `follow_output`, `signal`, `delete`, `attach` (WebSocket, synchronous) |
| `client.agent` | `upload_package`, `list_packages`, `set_default_version`, `start_rollout`, `list_rollouts`, `get_rollout`, `widen_rollout`, `pause_rollout`, `resume_rollout`, `rollback_rollout` |
| `client.browser` | `create_profile`, `list_profiles`, `get_profile`, `delete_profile`, `acquire_lease`, `list_leases`, `get_lease`, `renew_lease`, `release_lease`, `takeover`, `handback`, `get_limits` |
| `client.connections` | `authorize`, `list`, `get`, `revoke`, `list_oauth_clients`, `set_oauth_client` |
| `client.policies` | `get_default`, `set_default`, `delete_default`, `get_for_environment`, `set_for_environment`, `delete_for_environment`, `check` |
| `client.approvals` | `list`, `iter`, `get`, `approve`, `deny`, `list_grants`, `revoke_grant` |
| `client.audit` | `list`, `iter`, `export`, `iter_export` (NDJSON, line by line), `chains` |
| `client.metering` | `usage`, `list_limits`, `set_limit`, `delete_limit` |
| `client.secrets` | `list`, `set`, `delete`, `list_for_environment`, `set_for_environment`, `delete_for_environment` (credentials: values are never returned, and the VM only sees a placeholder); `get_egress`, `set_egress`, `get_environment_egress`, `set_environment_egress`, `add_environment_egress_host`, `remove_environment_egress_host` (egress allowlist) |
| `client.team` | `me`, `list_members`, `add_member`, `set_member_role`, `remove_member`, `list_api_keys`, `create_api_key`, `revoke_api_key`, `get_webhook`, `set_webhook` (adding, changing or removing members, issuing keys and changing the webhook require a console session; calling them with an API key raises `session_required`) |
| `client.templates` | `create_version`, `get_version`, `list`, `delete_version`, `wait_for_version` (environment templates: system packages and tools on top of the default runtime) |

```python
from datetime import datetime, timedelta, timezone

from temper_hq import until_scope

link = client.connections.authorize(end_user_id="user-42", connectors=["gmail", "calendar"],
                                    return_url="https://app.example.com/connected")
# After the end user consents, they are redirected to return_url?status=ok&connection_id=conn_...
# (on failure: status=error&error=...)

client.policies.set_default([{"category": "send", "effect": "ask", "max_scope": "session"}])
for a in client.approvals.iter(status="pending"):
    client.approvals.approve(a.id, until_scope(datetime.now(timezone.utc) + timedelta(hours=1)))
```

Build a time-limited grant with `until_scope(dt)` (must be in the future). It is written as
`{"kind": "until", "until": <unix seconds>}` in the decision request, and called `time_limited` in stored grants and in `max_scope`.
Build session and task scopes with `session_scope(approval)` / `task_scope(approval)`; the id must belong to the approval
request itself, otherwise `invalid_scope` is raised.
`client.policies.check(environment_id, connector=...)` is a read-only evaluation of whether an action would be allowed,
need approval or be denied, and why. It does not create an approval request or use up a grant.
An audit export returns at most 100000 rows per call. If the range holds more, it raises `export_too_large`; split the
export by `from_` / `to`.
An environment over its monthly limit (`client.metering.set_limit`) is suspended, `environment.over_limit` gives the
reason, and resume, wake and exec raise `limit_exceeded`.
`client.browser.takeover(lease_id)` returns a one-time `view_url` for the end user; `handback` returns control to the agent.

`client.environments.create(..., idempotency_key=...)` makes retries after a timeout safe: retrying with the same key
returns the environment created the first time. `client.environments.wait_for(id, state)` polls until the environment
reaches `state` (for example `"running"` after `resume`; `timeout` defaults to 120 seconds). `get_config` / `set_config`
read and replace the environment's non-secret env vars and files.

The client only sends the fields you pass (request bodies reject unknown fields, and defaults are decided by the server).

The exec and file methods wake the environment first and wait up to 90 seconds for it (`environment_not_running` if it
does not come up; `environment_not_placed` if no host is assigned within 15 seconds, for example when no host is online).
Their default timeouts are therefore longer than the client's `timeout`: `exec` uses 90 seconds + `timeout_secs` + 30
seconds of slack, which you can override with `timeout=`. For file reads and writes, the timeout is the longest wait
between two sends or receives; it does not limit how long the whole transfer takes.

### Errors

Every 4xx / 5xx response raises `TemperError` with `status`, `code` and `message`. Control plane errors are always JSON,
and `code` is the code in `{"error": ...}` (`not_found`, `invalid_state_transition`, `lease_limit_reached`,
`environment_not_running` and so on; this includes errors raised by the framework, such as `invalid_body`,
`unsupported_media_type` and `method_not_allowed`).
For non-JSON error responses (from a proxy or gateway in front), `code` is `http_<status>` and `message` is the response body.
A rejected `attach` handshake also raises `TemperError` (websockets exposes the HTTP status and response body).
Network errors are httpx / websockets exceptions and are not wrapped.

### Webhooks

```python
from temper_hq import verify_webhook, WebhookVerificationError

event = verify_webhook(secret, request_body_bytes, request.headers.get("Temper-Signature"))
print(event.type, event.environment_id, event.data)
```

Always verify against the raw request body bytes. The default tolerance for clock skew is 5 minutes, and the header may
carry several `v1` signatures (while a secret is being rotated).

### Proxies

httpx reads the system proxy settings by default (including the macOS system proxy). If you get a 502 when connecting to
a local control plane, set `NO_PROXY=127.0.0.1,localhost` or pass `http_client=httpx.Client(trust_env=False)`.
The websockets connection used by `processes.attach` also honours `NO_PROXY`, or you can pass `proxy=None`.

## Changelog

See [CHANGELOG.md](CHANGELOG.md).

## License

Apache-2.0. See LICENSE.
