briq-cloud 0.1.1__py3-none-any.whl

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.
briq_cloud/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Python client for briq: sandboxes you create, run commands in, and read files from."""
2
+
3
+ from ._async import AsyncBriq, AsyncSandbox
4
+ from ._errors import BriqError, Code
5
+ from ._models import ExecResult, FileInfo, OutputChunk, SandboxInfo
6
+ from ._sync import Briq, Sandbox
7
+ from ._version import __version__
8
+
9
+ __all__ = [
10
+ "AsyncBriq",
11
+ "AsyncSandbox",
12
+ "Briq",
13
+ "BriqError",
14
+ "Code",
15
+ "ExecResult",
16
+ "FileInfo",
17
+ "OutputChunk",
18
+ "Sandbox",
19
+ "SandboxInfo",
20
+ "__version__",
21
+ ]
briq_cloud/_async.py ADDED
@@ -0,0 +1,300 @@
1
+ """The asynchronous client: the flows of ``_flows`` driven over ``httpx.AsyncClient``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ from collections.abc import Callable, Mapping, Sequence
8
+ from types import TracebackType
9
+ from typing import Any, TypeVar
10
+
11
+ import httpx
12
+
13
+ from . import _flows as f
14
+ from ._config import headers, resolve, timeout_of
15
+ from ._errors import BriqError
16
+ from ._models import ExecResult, FileInfo, OutputChunk, SandboxInfo
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ async def _pause(seconds: float) -> None: # a name of its own, so tests can skip the waits
22
+ await asyncio.sleep(seconds)
23
+
24
+
25
+ class AsyncBriq:
26
+ """A client for the briq API.
27
+
28
+ ``api_key`` falls back to ``BRIQ_API_KEY`` and ``base_url`` to ``BRIQ_API_URL``, then to the
29
+ public API. ``timeout`` is the seconds each request may take, a command and a sandbox's start
30
+ each add their own. ``http_client`` is an ``httpx.AsyncClient`` of your own (a proxy, a CA
31
+ bundle, a mounted transport): it is used as it is, except that no redirect is ever followed,
32
+ its ``base_url`` is ignored, and ``close`` leaves it open. ``transport`` is for tests.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ api_key: str | None = None,
38
+ *,
39
+ base_url: str | None = None,
40
+ timeout: float = 30.0,
41
+ transport: httpx.AsyncBaseTransport | None = None,
42
+ http_client: httpx.AsyncClient | None = None,
43
+ ) -> None:
44
+ self._key, self._origin = resolve(api_key, base_url)
45
+ self._timeout = timeout
46
+ # The environment's proxy and CA settings apply (``HTTP(S)_PROXY``, ``SSL_CERT_FILE``).
47
+ self._owns_http = http_client is None
48
+ self._http = http_client or httpx.AsyncClient(transport=transport)
49
+
50
+ async def close(self) -> None:
51
+ if self._owns_http:
52
+ await self._http.aclose()
53
+
54
+ async def __aenter__(self) -> AsyncBriq:
55
+ return self
56
+
57
+ async def __aexit__(
58
+ self,
59
+ exc_type: type[BaseException] | None,
60
+ exc: BaseException | None,
61
+ tb: TracebackType | None,
62
+ ) -> None:
63
+ await self.close()
64
+
65
+ async def create_sandbox(
66
+ self,
67
+ *,
68
+ name: str | None = None,
69
+ size: str | None = None,
70
+ image: str | None = None,
71
+ env: Mapping[str, str] | None = None,
72
+ secret_env: Mapping[str, str] | None = None,
73
+ sleep: str | None = None,
74
+ wait_timeout: float = 120.0,
75
+ idempotency_key: str | None = None,
76
+ ) -> AsyncSandbox:
77
+ """Make a sandbox and wait until it runs.
78
+
79
+ ``env`` values show in the sandbox's status; ``secret_env`` values are kept write-only
80
+ (the API must support it, else ``unsupported``). The request is sent once. When no
81
+ usable answer comes, the error is ``outcome_unknown``: the sandbox may exist, and the
82
+ same ``idempotency_key`` makes a second call return it instead of another.
83
+ """
84
+ info = await self._run(
85
+ f.create_sandbox(
86
+ name=name,
87
+ size=size,
88
+ env=env,
89
+ secret_env=secret_env,
90
+ sleep=sleep,
91
+ image=image,
92
+ wait_timeout=wait_timeout,
93
+ idempotency_key=idempotency_key,
94
+ )
95
+ )
96
+ return AsyncSandbox(self, info)
97
+
98
+ async def sandbox(self, id: str) -> AsyncSandbox:
99
+ """Reconnect to a sandbox by id. It must exist: ``not_found`` otherwise."""
100
+ return AsyncSandbox(self, await self._run(f.get_sandbox(id)))
101
+
102
+ # --- the driver ---
103
+
104
+ async def _send(self, request: f.Request) -> f.Reply:
105
+ built = self._http.build_request(
106
+ request.method,
107
+ self._origin + request.path,
108
+ params=request.query,
109
+ json=request.json,
110
+ content=request.content,
111
+ headers=headers(self._key, request.headers),
112
+ timeout=timeout_of(request.timeout or self._timeout),
113
+ )
114
+ try:
115
+ response = await self._http.send(built, stream=True, follow_redirects=False)
116
+ except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
117
+ raise f.NoAnswer(never_sent=True) from exc
118
+ except httpx.HTTPError as exc:
119
+ raise f.NoAnswer(never_sent=False) from exc
120
+ try:
121
+ body = bytearray()
122
+ too_large = False
123
+ async for chunk in response.aiter_bytes():
124
+ body += chunk
125
+ if len(body) > request.max_bytes:
126
+ too_large = True
127
+ break
128
+ except httpx.HTTPError as exc:
129
+ raise f.NoAnswer(never_sent=False) from exc
130
+ finally:
131
+ await response.aclose()
132
+ return f.Reply(
133
+ response.status_code,
134
+ dict(response.headers),
135
+ bytes(body),
136
+ too_large,
137
+ response.num_bytes_downloaded,
138
+ )
139
+
140
+ async def _perform(self, step: f.Request | f.Pause | f.Emit) -> Any:
141
+ if isinstance(step, f.Request):
142
+ return await self._send(step)
143
+ if isinstance(step, f.Pause):
144
+ await _pause(step.seconds)
145
+ else:
146
+ result = step.callback(step.chunk)
147
+ if inspect.isawaitable(result):
148
+ await result
149
+ return None
150
+
151
+ async def _run(self, flow: f.Flow[T]) -> T:
152
+ """Drive a flow: perform what it yields, send back the answer, throw in what fails."""
153
+ answer: Any = None
154
+ error: Exception | None = None
155
+ try:
156
+ while True:
157
+ try:
158
+ step = flow.throw(error) if error else flow.send(answer)
159
+ except StopIteration as done:
160
+ return done.value # type: ignore[no-any-return]
161
+ answer, error = None, None
162
+ try:
163
+ answer = await self._perform(step)
164
+ except Exception as exc:
165
+ error = exc
166
+ except BaseException as interrupt: # a cancel, a timeout, an interrupt
167
+ await self._unwind(flow, interrupt)
168
+ finally:
169
+ flow.close()
170
+
171
+ async def _unwind(self, flow: f.Flow[T], interrupt: BaseException) -> None:
172
+ """A cancel (or interrupt) reached the driver: let the flow clean up, shielded from it.
173
+
174
+ The flow gets it thrown in, may yield more steps to undo what it started (destroy a
175
+ sandbox, stop a command), and ends by raising it again. Each step runs as a task of its
176
+ own, so a second cancel does not cut the cleanup short.
177
+ """
178
+ thrown: BaseException | None = interrupt
179
+ answer: Any = None
180
+ while True:
181
+ try:
182
+ step = flow.throw(thrown) if thrown else flow.send(answer)
183
+ except StopIteration:
184
+ raise interrupt from None
185
+ thrown, answer = None, None
186
+ task = asyncio.ensure_future(self._perform(step))
187
+ while not task.done():
188
+ try:
189
+ await asyncio.wait({task})
190
+ except asyncio.CancelledError:
191
+ continue # the first cancel is what is re-raised; this one waits too
192
+ if task.cancelled():
193
+ continue
194
+ if (failure := task.exception()) is not None:
195
+ if not isinstance(failure, Exception):
196
+ raise failure
197
+ thrown = failure
198
+ else:
199
+ answer = task.result()
200
+
201
+
202
+ class AsyncSandbox:
203
+ """One sandbox. ``id`` is all that is needed to reconnect to it with ``AsyncBriq.sandbox``."""
204
+
205
+ def __init__(self, client: AsyncBriq, info: SandboxInfo) -> None:
206
+ self._client = client
207
+ self.info = info
208
+
209
+ @property
210
+ def id(self) -> str:
211
+ return self.info.id
212
+
213
+ async def refresh(self) -> SandboxInfo:
214
+ """Read the sandbox's state again."""
215
+ self.info = await self._client._run(f.get_sandbox(self.id))
216
+ return self.info
217
+
218
+ async def exec(
219
+ self,
220
+ command: str | Sequence[str],
221
+ *,
222
+ cwd: str | None = None,
223
+ env: Mapping[str, str] | None = None,
224
+ stdin: str | None = None,
225
+ timeout: float = 60.0,
226
+ on_output: Callable[[OutputChunk], Any] | None = None,
227
+ ) -> ExecResult:
228
+ """Run a command and wait for it.
229
+
230
+ A string runs in a shell, a list is the program and its arguments. With ``on_output`` the
231
+ output reaches it as it is written, and the command may run up to 24 hours. A command that
232
+ outlives ``timeout`` is stopped: ``timed_out`` is true and ``exit_code`` is ``None``.
233
+ A command is sent once; when its end is not known the error is ``outcome_unknown``.
234
+ """
235
+ return await self._client._run(
236
+ f.exec_command(
237
+ self.id,
238
+ command,
239
+ cwd=cwd,
240
+ env=env,
241
+ stdin=stdin,
242
+ timeout=timeout,
243
+ on_output=on_output,
244
+ )
245
+ )
246
+
247
+ async def write_file(self, path: str, data: bytes, *, mode: int | None = None) -> None:
248
+ """Write a file, and any directory above it that is missing. ``mode`` is e.g. ``0o755``."""
249
+ await self._client._run(f.write_file(self.id, path, data, mode))
250
+
251
+ async def read_file(self, path: str) -> bytes:
252
+ """The bytes of a file. ``file_not_found`` when it is not there."""
253
+ return await self._client._run(f.read_file(self.id, path))
254
+
255
+ async def list_files(self, path: str) -> list[FileInfo]:
256
+ """The entries of a directory. Needs ``python3`` in the sandbox."""
257
+ return await self._client._run(f.list_files(self.id, path))
258
+
259
+ async def remove(self, path: str, *, recursive: bool = False) -> None:
260
+ """Remove a file, or with ``recursive`` a directory. A path that is not there is fine."""
261
+ await self._client._run(f.remove(self.id, path, recursive))
262
+
263
+ async def preview_url(self, port: int, *, public: bool = False) -> str:
264
+ """A URL for a port of the sandbox. Private: only whoever holds the URL can open it."""
265
+ return await self._client._run(f.preview_url(self.id, port, public))
266
+
267
+ async def stop(self) -> SandboxInfo:
268
+ """Stop it; the disk is kept and nothing is billed until ``start``."""
269
+ self.info = await self._client._run(f.stop_sandbox(self.id))
270
+ return self.info
271
+
272
+ async def start(self, *, wait_timeout: float = 120.0) -> SandboxInfo:
273
+ """Start a stopped or sleeping sandbox and wait until it runs. Any call wakes it too."""
274
+ self.info = await self._client._run(f.start_sandbox(self.id, wait_timeout))
275
+ return self.info
276
+
277
+ async def set_sleep(self, after: str) -> SandboxInfo:
278
+ """How long without use before it sleeps: ``"30m"``, ``"3h"``, ``"7d"`` or ``"never"``."""
279
+ self.info = await self._client._run(f.set_sleep(self.id, after))
280
+ return self.info
281
+
282
+ async def destroy(self) -> None:
283
+ """Destroy it and wait until it is gone. Destroying one that is gone is fine."""
284
+ await self._client._run(f.destroy(self.id))
285
+
286
+ async def __aenter__(self) -> AsyncSandbox:
287
+ return self
288
+
289
+ async def __aexit__(
290
+ self,
291
+ exc_type: type[BaseException] | None,
292
+ exc: BaseException | None,
293
+ tb: TracebackType | None,
294
+ ) -> None:
295
+ """Destroy it when the block ends."""
296
+ try:
297
+ await self.destroy()
298
+ except BriqError:
299
+ if exc is None:
300
+ raise
briq_cloud/_config.py ADDED
@@ -0,0 +1,59 @@
1
+ """The key and the API origin: arguments first, then the environment, then the public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from urllib.parse import urlsplit
7
+
8
+ import httpx
9
+
10
+ from ._errors import BriqError
11
+ from ._version import __version__
12
+
13
+ DEFAULT_URL = "https://api.briq.cloud"
14
+ USER_AGENT = f"briq-cloud-python/{__version__}"
15
+
16
+
17
+ def resolve(api_key: str | None, base_url: str | None) -> tuple[str, str]:
18
+ """The key and the origin to use. A bad one fails here, before anything is sent."""
19
+ key = api_key or os.environ.get("BRIQ_API_KEY") or ""
20
+ if not key:
21
+ raise BriqError("auth", "A briq API key is required: pass api_key or set BRIQ_API_KEY")
22
+ if any(c.isspace() or ord(c) < 32 or ord(c) > 126 for c in key):
23
+ raise BriqError("auth", "The API key holds a character a key never has")
24
+ return key, origin_of(base_url or os.environ.get("BRIQ_API_URL") or DEFAULT_URL)
25
+
26
+
27
+ def origin_of(url: str) -> str:
28
+ """``scheme://host[:port]`` of an API URL. It is nothing more: the client builds the paths."""
29
+ try:
30
+ parts = urlsplit(url)
31
+ valid = (
32
+ parts.scheme in ("http", "https")
33
+ and bool(parts.hostname)
34
+ and not parts.username
35
+ and not parts.password
36
+ and not parts.query
37
+ and not parts.fragment
38
+ and parts.path in ("", "/")
39
+ and parts.port != 0
40
+ )
41
+ except ValueError:
42
+ valid = False
43
+ if not valid:
44
+ raise BriqError("invalid_argument", "The API URL must be an HTTP(S) origin")
45
+ return f"{parts.scheme}://{parts.netloc}"
46
+
47
+
48
+ def headers(key: str, extra: dict[str, str]) -> dict[str, str]:
49
+ return {
50
+ "authorization": f"Bearer {key}",
51
+ "user-agent": USER_AGENT,
52
+ "accept": "application/json, */*",
53
+ **extra,
54
+ }
55
+
56
+
57
+ def timeout_of(seconds: float) -> httpx.Timeout:
58
+ """A deadline for each step of a request; connecting is never given more than ten seconds."""
59
+ return httpx.Timeout(seconds, connect=min(seconds, 10.0))
briq_cloud/_errors.py ADDED
@@ -0,0 +1,153 @@
1
+ """The one exception the client raises, and how an HTTP answer becomes one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Mapping
8
+ from typing import Any, Literal
9
+
10
+ #: What went wrong, as a stable word. The same words as the TypeScript client's.
11
+ #:
12
+ #: - ``auth``: no key, a wrong key, or a key that may not do this.
13
+ #: - ``not_found``: the sandbox does not exist (or is not yours).
14
+ #: - ``file_not_found``: the file or directory does not exist.
15
+ #: - ``quota``: a limit stopped it: credits, plan, spend cap, rate limit, size.
16
+ #: - ``unavailable``: briq could not take the call; it was not applied. Safe to try again.
17
+ #: - ``invalid_argument``: the call is wrong; trying again changes nothing.
18
+ #: - ``provider``: briq answered with a refusal that fits none of the others.
19
+ #: - ``outcome_unknown``: the call was sent and its result is not known (a command may have run,
20
+ #: a sandbox may exist). The client never repeats such a call itself.
21
+ #: - ``timeout``: a deadline of the client ended the call.
22
+ #: - ``unsupported``: this API or image cannot do what was asked.
23
+ #: - ``aborted``: kept for parity with the TypeScript client, which cancels with an abort signal.
24
+ Code = Literal[
25
+ "auth",
26
+ "not_found",
27
+ "file_not_found",
28
+ "quota",
29
+ "unavailable",
30
+ "invalid_argument",
31
+ "provider",
32
+ "outcome_unknown",
33
+ "timeout",
34
+ "unsupported",
35
+ "aborted",
36
+ ]
37
+
38
+
39
+ class BriqError(Exception):
40
+ """A call to briq failed. ``code`` says how; the other fields say what briq answered."""
41
+
42
+ def __init__(
43
+ self,
44
+ code: Code,
45
+ message: str,
46
+ *,
47
+ status: int | None = None,
48
+ api_code: str | None = None,
49
+ request_id: str | None = None,
50
+ retry_after: int | None = None,
51
+ details: Any = None,
52
+ idempotency_key: str | None = None,
53
+ sandbox_id: str | None = None,
54
+ ) -> None:
55
+ super().__init__(message)
56
+ self.code: Code = code
57
+ self.message = message
58
+ #: The HTTP status, when briq answered.
59
+ self.status = status
60
+ #: The API's own error code (``payment_required``, ``plan_limit``, ``invalid_state``...).
61
+ self.api_code = api_code
62
+ #: The API's id for the failed request: quote it when asking for help.
63
+ self.request_id = request_id
64
+ #: On a rate limit (429): the seconds until the window ends.
65
+ self.retry_after = retry_after
66
+ #: What the API said about the limit or the operation, as codes, ids and numbers only
67
+ #: (``{"limit": "size", "plan": "hobby"}``, ``{"op": "exec"}``). The text a provider
68
+ #: gave is never kept: it is not for a caller's logs.
69
+ self.details = details
70
+ #: On a create whose result is not known: the key it was sent with. Pass it as
71
+ #: ``idempotency_key`` to repeat the call; the API then returns the sandbox it made.
72
+ self.idempotency_key = idempotency_key
73
+ #: The sandbox the error is about, when it is known (a create that could not clean up).
74
+ self.sandbox_id = sandbox_id
75
+
76
+ def __str__(self) -> str:
77
+ return f"{self.message} ({self.code})"
78
+
79
+
80
+ def status_code(status: int, api_code: str | None = None) -> Code:
81
+ """The code for an HTTP status, as the TypeScript client maps it."""
82
+ if status in (402, 429, 507, 413) or api_code == "plan_limit":
83
+ return "quota"
84
+ if status in (401, 403):
85
+ return "auth"
86
+ if status == 404:
87
+ return "not_found"
88
+ if status >= 500:
89
+ return "unavailable"
90
+ if status in (400, 422):
91
+ return "invalid_argument"
92
+ return "provider"
93
+
94
+
95
+ _ID = re.compile(r"^[\w.-]{1,64}$")
96
+ #: A value of ``details`` worth keeping: a code, an id, a plan or an operation name.
97
+ _WORD = re.compile(r"^[\w.:-]{1,64}$")
98
+
99
+
100
+ def request_id_of(value: object) -> str | None:
101
+ """A request id worth repeating in an error: the API's own, shaped as ours."""
102
+ return value if isinstance(value, str) and _ID.match(value) else None
103
+
104
+
105
+ def safe_details(details: Any) -> Any:
106
+ """The codes, ids and numbers of an error's ``details``; the text of a provider is dropped."""
107
+ if not isinstance(details, dict):
108
+ return None
109
+ kept = {
110
+ key: value
111
+ for key, value in details.items()
112
+ if isinstance(key, str)
113
+ and _WORD.match(key)
114
+ and key != "providerSaid"
115
+ and (
116
+ isinstance(value, (bool, int, float))
117
+ or (isinstance(value, str) and _WORD.match(value) is not None)
118
+ )
119
+ }
120
+ return kept or None
121
+
122
+
123
+ def envelope(content: bytes) -> dict[str, Any]:
124
+ """The ``error`` object of an error answer; empty when the body is not ours (a proxy's page)."""
125
+ try:
126
+ error = json.loads(content).get("error")
127
+ except (ValueError, AttributeError):
128
+ return {}
129
+ return error if isinstance(error, dict) else {}
130
+
131
+
132
+ def refusal(
133
+ status: int,
134
+ headers: Mapping[str, str],
135
+ content: bytes,
136
+ code: Code | None = None,
137
+ ) -> BriqError:
138
+ """An error answer as a ``BriqError``: ``code`` by status unless the caller knows better."""
139
+ error = envelope(content)
140
+ said_code, said = error.get("code"), error.get("message")
141
+ api_code = said_code if isinstance(said_code, str) else None
142
+ message = said if isinstance(said, str) else f"HTTP {status}"
143
+ retry = headers.get("retry-after", "")
144
+ return BriqError(
145
+ code or status_code(status, api_code),
146
+ message,
147
+ status=status,
148
+ api_code=api_code,
149
+ request_id=request_id_of(headers.get("x-request-id"))
150
+ or request_id_of(error.get("requestId")),
151
+ retry_after=int(retry) if status == 429 and re.fullmatch(r"\d{1,4}", retry) else None,
152
+ details=safe_details(error.get("details")),
153
+ )