quivvo 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quivvo-0.1.0/.gitignore +4 -0
- quivvo-0.1.0/LICENSE +21 -0
- quivvo-0.1.0/PKG-INFO +152 -0
- quivvo-0.1.0/README.md +130 -0
- quivvo-0.1.0/pyproject.toml +44 -0
- quivvo-0.1.0/src/quivvo/__init__.py +26 -0
- quivvo-0.1.0/src/quivvo/_client.py +78 -0
- quivvo-0.1.0/src/quivvo/_errors.py +88 -0
- quivvo-0.1.0/src/quivvo/_resources.py +701 -0
- quivvo-0.1.0/src/quivvo/_sse.py +86 -0
- quivvo-0.1.0/src/quivvo/_transport.py +288 -0
- quivvo-0.1.0/src/quivvo/_version.py +1 -0
- quivvo-0.1.0/src/quivvo/_webhooks.py +30 -0
- quivvo-0.1.0/src/quivvo/py.typed +0 -0
- quivvo-0.1.0/src/quivvo/types.py +278 -0
- quivvo-0.1.0/tests/test_quivvo.py +600 -0
quivvo-0.1.0/.gitignore
ADDED
quivvo-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quivvo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
quivvo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: quivvo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Quivvo: Linux machines for AI agents.
|
|
5
|
+
Project-URL: Homepage, https://tryquivvo.com
|
|
6
|
+
Project-URL: Documentation, https://tryquivvo.com/docs/sdks
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai-agents,cloud-machines,firecracker,sandbox,sdk
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# quivvo
|
|
24
|
+
|
|
25
|
+
The Python SDK for [Quivvo](https://tryquivvo.com): Linux machines for AI agents, each a Firecracker VM with root, Docker and a persistent disk at `/data`. Python 3.9 or later, no dependencies.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install quivvo # or, from this repo: pip install ./sdk/python
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from quivvo import Quivvo
|
|
33
|
+
|
|
34
|
+
q = Quivvo() # reads QUIVVO_API_KEY
|
|
35
|
+
m = q.machines.create(name="dev")
|
|
36
|
+
r = m.exec("uname -a")
|
|
37
|
+
print(r["exit_code"], r["stdout"])
|
|
38
|
+
m.delete()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Auth
|
|
42
|
+
|
|
43
|
+
Pass a `qvk_` key as `Quivvo(api_key=...)` or set `QUIVVO_API_KEY`. Get one with `quivvo login --agent` or from the dashboard. It's sent as `Authorization: Bearer`. `base_url` falls back to `QUIVVO_API_URL`, then `https://tryquivvo.com`. `q.whoami()` shows the key's scopes and limits.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
Quivvo(api_key=None, base_url=None, max_retries=4, timeout=60.0)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## What comes back
|
|
50
|
+
|
|
51
|
+
The API's JSON, as plain dicts. Machines are `Machine`, a dict with methods (`m["state"]`, `m.exec(...)`, `m.stop()`). Calls that list return the list. `quivvo.types` has a TypedDict for each object, for your type checker. A machine is given by its id, its name, or the `Machine` itself.
|
|
52
|
+
|
|
53
|
+
## Errors
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from quivvo import QuivvoError, QuivvoNetworkError
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
q.machines.start("dev")
|
|
60
|
+
except QuivvoError as e: # the API answered with an error
|
|
61
|
+
print(e.status, e.code, e.message, e.request_id)
|
|
62
|
+
if e.owner_action: # only the workspace's owner can fix this
|
|
63
|
+
print(e.owner_action["message"], e.owner_action["url"])
|
|
64
|
+
except QuivvoNetworkError as e: # nothing answered
|
|
65
|
+
print(e.message, e.maybe_acted)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`QuivvoError` carries `status`, `code`, `message`, `retryable`, `retry_after_seconds`, `docs_url`, `request_id` and `owner_action`. Branch on `code`; [errors](https://tryquivvo.com/docs/errors) lists them. A command that exits non-zero is a result, not an error.
|
|
69
|
+
|
|
70
|
+
Retryable errors are retried up to `max_retries` times, after `retry_after_seconds` (or 0.5 s doubling to 8 s), never waiting more than 60 s. Reads, deletes, and lifecycle POSTs are retried, and lifecycle POSTs (create, start, stop, suspend, resume, restart, snapshot, fork, restore) send one `Idempotency-Key` for every attempt, so a retry can't act twice. Pass `idempotency_key=` to choose it. Exec and other POSTs are only repeated when the server says nothing ran: `rate_limited`, or `machine_not_running` while it boots.
|
|
71
|
+
|
|
72
|
+
## Streaming
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
with q.machines.exec_stream("dev", "make test") as events:
|
|
76
|
+
for ev in events:
|
|
77
|
+
if ev["event"] in ("stdout", "stderr"):
|
|
78
|
+
print(ev["data"], end="")
|
|
79
|
+
elif ev["event"] == "exit":
|
|
80
|
+
print("exit", ev["exit_code"])
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
There's no output cap. A chunk that isn't valid UTF-8 also has `data_base64`. Leaving the loop hangs up, which stops the command. A stream cut before `exit` raises `StreamLostError`.
|
|
84
|
+
|
|
85
|
+
For work longer than 280 seconds, start a job and follow it:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import sys
|
|
89
|
+
|
|
90
|
+
job = q.jobs.start("dev", "make build")["job"]
|
|
91
|
+
for chunk in q.jobs.follow("dev", job["id"]):
|
|
92
|
+
sys.stdout.buffer.write(chunk)
|
|
93
|
+
print(q.jobs.wait("dev", job["id"])["exit_code"])
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Webhooks
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from quivvo import verify_webhook
|
|
100
|
+
|
|
101
|
+
ok = verify_webhook(secret, raw_body, headers["Quivvo-Signature"]) # rejects a t over 5 minutes old
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Methods
|
|
105
|
+
|
|
106
|
+
| Method | API |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| `q.whoami()` | `GET /whoami` |
|
|
109
|
+
| `q.catalog()` | `GET /catalog` |
|
|
110
|
+
| `q.usage()` | `GET /usage` |
|
|
111
|
+
| `q.queue()` | `GET /queue` |
|
|
112
|
+
| `q.feedback(message, machine=, context=)` | `POST /feedback` |
|
|
113
|
+
| `q.machines.list(state=, all=)` | `GET /machines` |
|
|
114
|
+
| `q.machines.create(name, template=, size=, vcpus=, memory_mib=, disk_gib=, region=, snapshot=, setup=, env=, auto_suspend_minutes=, delete_after_minutes=, wait=)` | `POST /machines` |
|
|
115
|
+
| `q.machines.get(m)` | `GET /machines/:id` |
|
|
116
|
+
| `q.machines.update(m, name=, auto_suspend_minutes=, vcpus=, memory_mib=, disk_gib=, delete_after_minutes=)` | `PATCH /machines/:id` |
|
|
117
|
+
| `q.machines.delete(m, confirm=)` | `DELETE /machines/:id` |
|
|
118
|
+
| `q.machines.start/resume/restart(m, wait=True)`, `stop(m)`, `suspend(m)` | `POST /machines/:id/<action>` |
|
|
119
|
+
| `q.machines.exec(m, cmd, argv=, cwd=, env=, timeout_ms=, stdin=, tail_bytes=)` | `POST /machines/:id/exec` |
|
|
120
|
+
| `q.machines.exec_stream(m, cmd, ...)` | `POST /machines/:id/exec`, `Accept: text/event-stream` |
|
|
121
|
+
| `q.machines.status(m)`, `ps(m)` | `GET /machines/:id/status`, `/ps` |
|
|
122
|
+
| `q.machines.logs(m, lines=, format=)` | `GET /machines/:id/logs` |
|
|
123
|
+
| `q.machines.history(m, limit=)`, `exec_output(m, exec_id)` | `GET /machines/:id/history`, `/history/:exec/output` |
|
|
124
|
+
| `q.machines.connect(m)` | `POST /machines/:id/connect` |
|
|
125
|
+
| `q.machines.fork(m, count=, name=)` | `POST /machines/:id/fork` |
|
|
126
|
+
| `q.machines.restore(m, snapshot)` | `POST /machines/:id/restore` |
|
|
127
|
+
| `q.jobs.start(m, cmd, argv=, cwd=, env=, timeout_ms=)` | `POST /machines/:id/exec` with `background` |
|
|
128
|
+
| `q.jobs.list(m)`, `get(m, job, wait=)`, `wait(m, job, timeout=)` | `GET /machines/:id/jobs`, `/jobs/:job` |
|
|
129
|
+
| `q.jobs.logs(m, job, offset=, tail_bytes=)`, `follow(m, job, offset=)` | `GET /machines/:id/jobs/:job/logs` |
|
|
130
|
+
| `q.jobs.kill(m, job, signal=)` | `POST /machines/:id/jobs/:job/kill` |
|
|
131
|
+
| `q.files.read(m, path)`, `list(m, path)` | `GET /machines/:id/files` |
|
|
132
|
+
| `q.files.write(m, path, data, mode=, append=)` | `PUT /machines/:id/files` |
|
|
133
|
+
| `q.files.delete(m, path, recursive=)` | `DELETE /machines/:id/files` |
|
|
134
|
+
| `q.ports.list(m)`, `publish(m, port, public=)`, `unpublish(m, port)` | `/machines/:id/ports` |
|
|
135
|
+
| `q.snapshots.create(m, name)` | `POST /machines/:id/snapshots` |
|
|
136
|
+
| `q.snapshots.list()`, `get(snapshot)`, `delete(snapshot, confirm=)` | `GET /snapshots`, `DELETE /snapshots/:id` |
|
|
137
|
+
| `q.keys.list()`, `create(name, scopes=)`, `revoke(key)` | `/keys` |
|
|
138
|
+
| `q.webhooks.list()`, `create(url, events=)`, `delete(webhook)` | `/webhooks` |
|
|
139
|
+
|
|
140
|
+
A `Machine` has `refresh()`, `start()`, `stop()`, `suspend()`, `resume()`, `restart()`, `delete()`, `exec()` and `exec_stream()`. Start a machine from a snapshot with `q.machines.create(snapshot="base")`. `q.snapshots.get()` reads the list, as the API has no route for one snapshot.
|
|
141
|
+
|
|
142
|
+
## Tests
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
cd sdk/python && PYTHONPATH=src python3 -m unittest discover -s tests
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
[REST API reference](https://tryquivvo.com/docs/api) · [OpenAPI](https://tryquivvo.com/api/v1/openapi.json)
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT. See [LICENSE](LICENSE).
|
quivvo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# quivvo
|
|
2
|
+
|
|
3
|
+
The Python SDK for [Quivvo](https://tryquivvo.com): Linux machines for AI agents, each a Firecracker VM with root, Docker and a persistent disk at `/data`. Python 3.9 or later, no dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install quivvo # or, from this repo: pip install ./sdk/python
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from quivvo import Quivvo
|
|
11
|
+
|
|
12
|
+
q = Quivvo() # reads QUIVVO_API_KEY
|
|
13
|
+
m = q.machines.create(name="dev")
|
|
14
|
+
r = m.exec("uname -a")
|
|
15
|
+
print(r["exit_code"], r["stdout"])
|
|
16
|
+
m.delete()
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Auth
|
|
20
|
+
|
|
21
|
+
Pass a `qvk_` key as `Quivvo(api_key=...)` or set `QUIVVO_API_KEY`. Get one with `quivvo login --agent` or from the dashboard. It's sent as `Authorization: Bearer`. `base_url` falls back to `QUIVVO_API_URL`, then `https://tryquivvo.com`. `q.whoami()` shows the key's scopes and limits.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
Quivvo(api_key=None, base_url=None, max_retries=4, timeout=60.0)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## What comes back
|
|
28
|
+
|
|
29
|
+
The API's JSON, as plain dicts. Machines are `Machine`, a dict with methods (`m["state"]`, `m.exec(...)`, `m.stop()`). Calls that list return the list. `quivvo.types` has a TypedDict for each object, for your type checker. A machine is given by its id, its name, or the `Machine` itself.
|
|
30
|
+
|
|
31
|
+
## Errors
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from quivvo import QuivvoError, QuivvoNetworkError
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
q.machines.start("dev")
|
|
38
|
+
except QuivvoError as e: # the API answered with an error
|
|
39
|
+
print(e.status, e.code, e.message, e.request_id)
|
|
40
|
+
if e.owner_action: # only the workspace's owner can fix this
|
|
41
|
+
print(e.owner_action["message"], e.owner_action["url"])
|
|
42
|
+
except QuivvoNetworkError as e: # nothing answered
|
|
43
|
+
print(e.message, e.maybe_acted)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`QuivvoError` carries `status`, `code`, `message`, `retryable`, `retry_after_seconds`, `docs_url`, `request_id` and `owner_action`. Branch on `code`; [errors](https://tryquivvo.com/docs/errors) lists them. A command that exits non-zero is a result, not an error.
|
|
47
|
+
|
|
48
|
+
Retryable errors are retried up to `max_retries` times, after `retry_after_seconds` (or 0.5 s doubling to 8 s), never waiting more than 60 s. Reads, deletes, and lifecycle POSTs are retried, and lifecycle POSTs (create, start, stop, suspend, resume, restart, snapshot, fork, restore) send one `Idempotency-Key` for every attempt, so a retry can't act twice. Pass `idempotency_key=` to choose it. Exec and other POSTs are only repeated when the server says nothing ran: `rate_limited`, or `machine_not_running` while it boots.
|
|
49
|
+
|
|
50
|
+
## Streaming
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
with q.machines.exec_stream("dev", "make test") as events:
|
|
54
|
+
for ev in events:
|
|
55
|
+
if ev["event"] in ("stdout", "stderr"):
|
|
56
|
+
print(ev["data"], end="")
|
|
57
|
+
elif ev["event"] == "exit":
|
|
58
|
+
print("exit", ev["exit_code"])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
There's no output cap. A chunk that isn't valid UTF-8 also has `data_base64`. Leaving the loop hangs up, which stops the command. A stream cut before `exit` raises `StreamLostError`.
|
|
62
|
+
|
|
63
|
+
For work longer than 280 seconds, start a job and follow it:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import sys
|
|
67
|
+
|
|
68
|
+
job = q.jobs.start("dev", "make build")["job"]
|
|
69
|
+
for chunk in q.jobs.follow("dev", job["id"]):
|
|
70
|
+
sys.stdout.buffer.write(chunk)
|
|
71
|
+
print(q.jobs.wait("dev", job["id"])["exit_code"])
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Webhooks
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from quivvo import verify_webhook
|
|
78
|
+
|
|
79
|
+
ok = verify_webhook(secret, raw_body, headers["Quivvo-Signature"]) # rejects a t over 5 minutes old
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Methods
|
|
83
|
+
|
|
84
|
+
| Method | API |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `q.whoami()` | `GET /whoami` |
|
|
87
|
+
| `q.catalog()` | `GET /catalog` |
|
|
88
|
+
| `q.usage()` | `GET /usage` |
|
|
89
|
+
| `q.queue()` | `GET /queue` |
|
|
90
|
+
| `q.feedback(message, machine=, context=)` | `POST /feedback` |
|
|
91
|
+
| `q.machines.list(state=, all=)` | `GET /machines` |
|
|
92
|
+
| `q.machines.create(name, template=, size=, vcpus=, memory_mib=, disk_gib=, region=, snapshot=, setup=, env=, auto_suspend_minutes=, delete_after_minutes=, wait=)` | `POST /machines` |
|
|
93
|
+
| `q.machines.get(m)` | `GET /machines/:id` |
|
|
94
|
+
| `q.machines.update(m, name=, auto_suspend_minutes=, vcpus=, memory_mib=, disk_gib=, delete_after_minutes=)` | `PATCH /machines/:id` |
|
|
95
|
+
| `q.machines.delete(m, confirm=)` | `DELETE /machines/:id` |
|
|
96
|
+
| `q.machines.start/resume/restart(m, wait=True)`, `stop(m)`, `suspend(m)` | `POST /machines/:id/<action>` |
|
|
97
|
+
| `q.machines.exec(m, cmd, argv=, cwd=, env=, timeout_ms=, stdin=, tail_bytes=)` | `POST /machines/:id/exec` |
|
|
98
|
+
| `q.machines.exec_stream(m, cmd, ...)` | `POST /machines/:id/exec`, `Accept: text/event-stream` |
|
|
99
|
+
| `q.machines.status(m)`, `ps(m)` | `GET /machines/:id/status`, `/ps` |
|
|
100
|
+
| `q.machines.logs(m, lines=, format=)` | `GET /machines/:id/logs` |
|
|
101
|
+
| `q.machines.history(m, limit=)`, `exec_output(m, exec_id)` | `GET /machines/:id/history`, `/history/:exec/output` |
|
|
102
|
+
| `q.machines.connect(m)` | `POST /machines/:id/connect` |
|
|
103
|
+
| `q.machines.fork(m, count=, name=)` | `POST /machines/:id/fork` |
|
|
104
|
+
| `q.machines.restore(m, snapshot)` | `POST /machines/:id/restore` |
|
|
105
|
+
| `q.jobs.start(m, cmd, argv=, cwd=, env=, timeout_ms=)` | `POST /machines/:id/exec` with `background` |
|
|
106
|
+
| `q.jobs.list(m)`, `get(m, job, wait=)`, `wait(m, job, timeout=)` | `GET /machines/:id/jobs`, `/jobs/:job` |
|
|
107
|
+
| `q.jobs.logs(m, job, offset=, tail_bytes=)`, `follow(m, job, offset=)` | `GET /machines/:id/jobs/:job/logs` |
|
|
108
|
+
| `q.jobs.kill(m, job, signal=)` | `POST /machines/:id/jobs/:job/kill` |
|
|
109
|
+
| `q.files.read(m, path)`, `list(m, path)` | `GET /machines/:id/files` |
|
|
110
|
+
| `q.files.write(m, path, data, mode=, append=)` | `PUT /machines/:id/files` |
|
|
111
|
+
| `q.files.delete(m, path, recursive=)` | `DELETE /machines/:id/files` |
|
|
112
|
+
| `q.ports.list(m)`, `publish(m, port, public=)`, `unpublish(m, port)` | `/machines/:id/ports` |
|
|
113
|
+
| `q.snapshots.create(m, name)` | `POST /machines/:id/snapshots` |
|
|
114
|
+
| `q.snapshots.list()`, `get(snapshot)`, `delete(snapshot, confirm=)` | `GET /snapshots`, `DELETE /snapshots/:id` |
|
|
115
|
+
| `q.keys.list()`, `create(name, scopes=)`, `revoke(key)` | `/keys` |
|
|
116
|
+
| `q.webhooks.list()`, `create(url, events=)`, `delete(webhook)` | `/webhooks` |
|
|
117
|
+
|
|
118
|
+
A `Machine` has `refresh()`, `start()`, `stop()`, `suspend()`, `resume()`, `restart()`, `delete()`, `exec()` and `exec_stream()`. Start a machine from a snapshot with `q.machines.create(snapshot="base")`. `q.snapshots.get()` reads the list, as the API has no route for one snapshot.
|
|
119
|
+
|
|
120
|
+
## Tests
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
cd sdk/python && PYTHONPATH=src python3 -m unittest discover -s tests
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
[REST API reference](https://tryquivvo.com/docs/api) · [OpenAPI](https://tryquivvo.com/api/v1/openapi.json)
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quivvo"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python SDK for Quivvo: Linux machines for AI agents."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["ai-agents", "sandbox", "cloud-machines", "firecracker", "sdk"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://tryquivvo.com"
|
|
30
|
+
Documentation = "https://tryquivvo.com/docs/sdks"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.version]
|
|
33
|
+
path = "src/quivvo/_version.py"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/quivvo"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
include = ["src/quivvo", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
40
|
+
|
|
41
|
+
[tool.mypy]
|
|
42
|
+
strict = true
|
|
43
|
+
# The API's answers are JSON, typed by the TypedDicts in quivvo.types where they're returned.
|
|
44
|
+
warn_return_any = false
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Python SDK for Quivvo: Linux machines for AI agents.
|
|
2
|
+
|
|
3
|
+
from quivvo import Quivvo
|
|
4
|
+
|
|
5
|
+
q = Quivvo() # QUIVVO_API_KEY
|
|
6
|
+
m = q.machines.create(name="dev")
|
|
7
|
+
print(m.exec("uname -a")["stdout"])
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._client import Quivvo
|
|
11
|
+
from ._errors import QuivvoError, QuivvoNetworkError, StreamLostError
|
|
12
|
+
from ._resources import ExecStream, JobLogs, Machine
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from ._webhooks import verify_webhook
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ExecStream",
|
|
18
|
+
"JobLogs",
|
|
19
|
+
"Machine",
|
|
20
|
+
"Quivvo",
|
|
21
|
+
"QuivvoError",
|
|
22
|
+
"QuivvoNetworkError",
|
|
23
|
+
"StreamLostError",
|
|
24
|
+
"__version__",
|
|
25
|
+
"verify_webhook",
|
|
26
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from ._resources import Files, Jobs, Keys, Machines, Ports, Snapshots, Webhooks, _compact, _ref
|
|
9
|
+
from ._transport import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, Transport
|
|
10
|
+
from .types import Catalog, Feedback, QueueState, Usage, Whoami
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Quivvo:
|
|
14
|
+
"""A client for the Quivvo REST API (``{base_url}/api/v1``).
|
|
15
|
+
|
|
16
|
+
- ``api_key``: a ``qvk_`` key, sent as ``Authorization: Bearer``. Defaults to ``QUIVVO_API_KEY``.
|
|
17
|
+
- ``base_url``: defaults to ``QUIVVO_API_URL``, then ``https://tryquivvo.com``.
|
|
18
|
+
- ``max_retries``: extra attempts after the first, for retryable errors and dropped connections, where the docs
|
|
19
|
+
allow it. Default 4.
|
|
20
|
+
- ``timeout``: seconds a request may wait for the server, 60 by default. Calls the server holds open while it works
|
|
21
|
+
(exec, create, lifecycle, snapshots, job waits) get as long as they need on top.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
machines: Machines
|
|
25
|
+
jobs: Jobs
|
|
26
|
+
files: Files
|
|
27
|
+
ports: Ports
|
|
28
|
+
snapshots: Snapshots
|
|
29
|
+
keys: Keys
|
|
30
|
+
webhooks: Webhooks
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
api_key: Optional[str] = None,
|
|
35
|
+
base_url: Optional[str] = None,
|
|
36
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
37
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
38
|
+
) -> None:
|
|
39
|
+
key = api_key if api_key is not None else os.environ.get("QUIVVO_API_KEY")
|
|
40
|
+
url = base_url or os.environ.get("QUIVVO_API_URL") or DEFAULT_BASE_URL
|
|
41
|
+
self._transport = Transport(key or None, url, max_retries, timeout)
|
|
42
|
+
self.machines = Machines(self)
|
|
43
|
+
self.jobs = Jobs(self)
|
|
44
|
+
self.files = Files(self)
|
|
45
|
+
self.ports = Ports(self)
|
|
46
|
+
self.snapshots = Snapshots(self)
|
|
47
|
+
self.keys = Keys(self)
|
|
48
|
+
self.webhooks = Webhooks(self)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def base_url(self) -> str:
|
|
52
|
+
return self._transport.base_url
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
return f"Quivvo(base_url={self.base_url!r})"
|
|
56
|
+
|
|
57
|
+
def whoami(self) -> Whoami:
|
|
58
|
+
"""What this key may do: ``kind``, ``key``, ``email``, ``scopes``, ``workspace``, ``limits``, ``exec``,
|
|
59
|
+
``machines`` and ``queue``. Needs no scope."""
|
|
60
|
+
return self._transport.json("GET", "/whoami")
|
|
61
|
+
|
|
62
|
+
def catalog(self) -> Catalog:
|
|
63
|
+
"""Templates, sizes, regions, plans, prices and exec limits. Needs no key."""
|
|
64
|
+
return self._transport.json("GET", "/catalog")
|
|
65
|
+
|
|
66
|
+
def usage(self) -> Usage:
|
|
67
|
+
"""This month's cost by machine and day, and the credit balance. US dollars."""
|
|
68
|
+
return self._transport.json("GET", "/usage")
|
|
69
|
+
|
|
70
|
+
def queue(self) -> QueueState:
|
|
71
|
+
"""This workspace's place in the queue and its invite link: ``{"waiting": False}`` once it's let in."""
|
|
72
|
+
return self._transport.json("GET", "/queue")
|
|
73
|
+
|
|
74
|
+
def feedback(self, message: str, *, machine: Any = None, context: Any = None) -> Feedback:
|
|
75
|
+
"""Reports a problem to Quivvo: up to 4000 bytes, optionally the machine and any JSON ``context`` (a
|
|
76
|
+
``request_id`` helps). Leave out secrets."""
|
|
77
|
+
body = _compact(message=message, machine=_ref(machine) if machine is not None else None, context=context)
|
|
78
|
+
return self._transport.json("POST", "/feedback", json=body)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The two ways a call fails: the API answered with an error, or nothing answered at all."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QuivvoError(Exception):
|
|
9
|
+
"""The API answered with an error object.
|
|
10
|
+
|
|
11
|
+
``{"error": {"code", "message", "retryable", "retry_after_seconds", "docs_url", "request_id", "owner_action"}}``
|
|
12
|
+
|
|
13
|
+
Branch on ``code``: the message can change. When ``retryable`` is true, the same request can be sent again after
|
|
14
|
+
``retry_after_seconds`` (the client has already done that, up to ``max_retries``). ``owner_action``, when present, is
|
|
15
|
+
``{"url", "message"}``: something only the workspace's owner can do, signed in on the dashboard. Pass it on.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
status: int
|
|
19
|
+
code: str
|
|
20
|
+
message: str
|
|
21
|
+
retryable: bool
|
|
22
|
+
retry_after_seconds: Optional[float]
|
|
23
|
+
docs_url: Optional[str]
|
|
24
|
+
request_id: Optional[str]
|
|
25
|
+
owner_action: Optional[Dict[str, Any]]
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
status: int,
|
|
30
|
+
code: str,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
retryable: bool = False,
|
|
34
|
+
retry_after_seconds: Optional[float] = None,
|
|
35
|
+
docs_url: Optional[str] = None,
|
|
36
|
+
request_id: Optional[str] = None,
|
|
37
|
+
owner_action: Optional[Dict[str, Any]] = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status = status
|
|
41
|
+
self.code = code
|
|
42
|
+
self.message = message
|
|
43
|
+
self.retryable = retryable
|
|
44
|
+
self.retry_after_seconds = retry_after_seconds
|
|
45
|
+
self.docs_url = docs_url
|
|
46
|
+
self.request_id = request_id
|
|
47
|
+
self.owner_action = owner_action
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
out = f"{self.code}: {self.message}"
|
|
51
|
+
if self.request_id:
|
|
52
|
+
out += f" (request {self.request_id})"
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return f"QuivvoError(status={self.status}, code={self.code!r}, message={self.message!r}, request_id={self.request_id!r})"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class QuivvoNetworkError(Exception):
|
|
60
|
+
"""The API couldn't be reached, or stopped answering: DNS, connection refused or reset, a timeout.
|
|
61
|
+
|
|
62
|
+
``maybe_acted`` is true when the request may have reached the server and done something first, so sending it again
|
|
63
|
+
isn't automatically safe (an exec, say). Reads, deletes and lifecycle calls carrying an Idempotency-Key are retried
|
|
64
|
+
by the client before this is raised.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
message: str
|
|
68
|
+
maybe_acted: bool
|
|
69
|
+
|
|
70
|
+
def __init__(self, message: str, *, maybe_acted: bool = False) -> None:
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.message = message
|
|
73
|
+
self.maybe_acted = maybe_acted
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def retryable(self) -> bool:
|
|
77
|
+
"""True when nothing can have happened, so repeating the call is safe."""
|
|
78
|
+
return not self.maybe_acted
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class StreamLostError(QuivvoNetworkError):
|
|
82
|
+
"""A stream from ``exec_stream`` ended before its ``exit`` event, so the command's result is unknown.
|
|
83
|
+
|
|
84
|
+
``machines.history()`` shows what's known about it.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, message: str) -> None:
|
|
88
|
+
super().__init__(message, maybe_acted=True)
|