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.
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ build/
4
+ dist/
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)