boax-py 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
+ dist/
4
+ build/
boax_py-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.5
2
+ Name: boax-py
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for Boax, the self-hosted sandbox and microVM platform.
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: Programming Language :: Python :: 3 :: Only
7
+ Classifier: Typing :: Typed
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+
11
+ # boax
12
+
13
+ Python SDK for Boax, the self-hosted sandbox and microVM
14
+ platform. Python 3.9 or newer, standard library only.
15
+
16
+ Your code asks for a profile, capabilities and a placement policy. Boax
17
+ picks the pool; the SDK never needs to know which backend runs the sandbox.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ pip install boax
23
+ ```
24
+
25
+ Until the package is published, install from a checkout: `pip install ./sdk/python`.
26
+
27
+ ## Quickstart
28
+
29
+ ```python
30
+ from boax import Boax
31
+
32
+ # Defaults: BOAX_API_KEY and BOAX_URL (or http://localhost:8080).
33
+ client = Boax()
34
+
35
+ # Waits until the sandbox is running; pass wait=False to return at once.
36
+ sandbox = client.sandboxes.create(
37
+ profile="small",
38
+ placement={"strategy": "private-first", "external": "never"},
39
+ )
40
+
41
+ result = sandbox.exec("uname -sm") # a string runs via /bin/sh -c
42
+ print(result.exit_code, result.stdout)
43
+ sandbox.exec(["ls", "-la", "/work"], cwd="/work", timeout_seconds=30) # a list runs directly
44
+
45
+ sandbox.files.write("/work/a.txt", "hi")
46
+ text = sandbox.files.read_text("/work/a.txt")
47
+
48
+ sandbox.delete()
49
+ ```
50
+
51
+ A sandbox is also a context manager that deletes itself on exit:
52
+
53
+ ```python
54
+ with client.sandboxes.create(profile="small") as sandbox:
55
+ print(sandbox.exec("uname -a").stdout)
56
+ ```
57
+
58
+ ## Streaming output
59
+
60
+ ```python
61
+ import sys
62
+
63
+ for ev in sandbox.exec_stream("make test"):
64
+ if ev.type == "stdout":
65
+ sys.stdout.write(ev.data)
66
+ elif ev.type == "stderr":
67
+ sys.stderr.write(ev.data)
68
+ elif ev.type == "exit":
69
+ print("exit code", ev.exit_code)
70
+ else:
71
+ print("backend error:", ev.error)
72
+ ```
73
+
74
+ The command starts when `exec_stream` is called. The iterator ends after
75
+ `exit` or `error`. If the connection drops before either arrives, it raises a
76
+ `connection_error`.
77
+
78
+ ## Errors
79
+
80
+ Every failure is a `BoaxError` with `code`, `status`, `details` and
81
+ `request_id`. Branch on `code`, not on the message.
82
+
83
+ ```python
84
+ from boax import BoaxError
85
+
86
+ try:
87
+ client.sandboxes.create(profile="gpu-large")
88
+ except BoaxError as err:
89
+ if err.code != "quota_exceeded":
90
+ raise
91
+ print("over quota on", err.details.get("dimension"), "request", err.request_id)
92
+ ```
93
+
94
+ Server codes include `invalid_request`, `unauthenticated`, `permission_denied`,
95
+ `not_found`, `conflict`, `quota_exceeded`, `no_eligible_pool`,
96
+ `unsupported_capability`, `unsupported_operation`, `provider_unavailable`,
97
+ `invalid_state`, `rate_limited`, `suspended` and `internal`. The SDK adds
98
+ `timeout` and `connection_error` (with `status` 0). Waiting raises
99
+ `invalid_state` if the sandbox fails or terminates first.
100
+
101
+ **Retries.** GET, PUT and DELETE requests, and sandbox creation, are retried
102
+ up to `max_retries` times (default 3). Retries happen on 503, on 429
103
+ `rate_limited` (never `quota_exceeded`) and on connection errors, with
104
+ exponential backoff that respects `Retry-After`. Each `create` call sends its
105
+ own `Idempotency-Key`, so a retried create never makes a second sandbox. Pass
106
+ `idempotency_key` to keep the key stable across process restarts. Exec
107
+ and other POSTs are never retried.
108
+
109
+ ## What's covered
110
+
111
+ | Namespace | Methods |
112
+ |---|---|
113
+ | `sandboxes` | `create`, `get`, `list`, `iterate`, `update`, `delete`, `wait` |
114
+ | `Sandbox` handle | `exec`, `exec_stream`, `files.list/read/read_text/write`, `stop`, `start`, `pause`, `resume`, `snapshot`, `fork`, `expose_port`, `explain_placement`, `refresh`, `update`, `wait`, `delete` |
115
+ | `snapshots` | `list`, `get`, `delete` |
116
+ | `projects` | `list`, `create`, `get`, `delete` |
117
+ | `api_keys` | `list`, `create`, `revoke` |
118
+ | `quotas`, `usage` | `get` |
119
+ | `audit_events` | `list`, `iterate` |
120
+ | `policies` | `list`, `put`, `delete` |
121
+ | `capabilities`, `profiles`, `templates`, `classifications` | `list` |
122
+ | `admin` | `organizations.{list,create,get,update,suspend,resume}`, `projects.set_quota`, `pools.{list,get,create,update,drain,undrain,delete}`, `agents.list`, `trust_report`, `verify_audit`, `terminate_sandbox`, `reconcile` |
123
+ | client | `me`, `version` |
124
+
125
+ Keyword arguments are snake_case. Records come back as plain dicts keyed exactly
126
+ like the API's JSON (camelCase), typed with `TypedDict` in `boax.types`.
127
+ `ExecResult`, `ExecEvent` and `Page` are dataclasses.
128
+
129
+ `list` returns one `Page` (`items`, `next_cursor`); `iterate` walks every page:
130
+
131
+ ```python
132
+ for sb in client.sandboxes.iterate(status="running", labels={"team": "ml"}):
133
+ print(sb.id)
134
+ ```
135
+
136
+ ## Options
137
+
138
+ ```python
139
+ Boax(
140
+ api_key="dly_...", # default: BOAX_API_KEY
141
+ base_url="https://...", # default: BOAX_URL, then http://localhost:8080
142
+ timeout=120.0, # socket timeout in seconds; waits and streams set their own
143
+ max_retries=3,
144
+ headers={}, # extra headers on every request
145
+ )
146
+ ```
147
+
148
+ Plain `exec` uses the client timeout unless you pass `timeout_seconds`. For long
149
+ commands, pass `timeout_seconds` or use `exec_stream`, which has no socket
150
+ timeout.
151
+
152
+ ## Development
153
+
154
+ ```sh
155
+ python3 -m unittest discover -s tests # runs against an in-process http.server
156
+ ```
@@ -0,0 +1,146 @@
1
+ # boax
2
+
3
+ Python SDK for Boax, the self-hosted sandbox and microVM
4
+ platform. Python 3.9 or newer, standard library only.
5
+
6
+ Your code asks for a profile, capabilities and a placement policy. Boax
7
+ picks the pool; the SDK never needs to know which backend runs the sandbox.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pip install boax
13
+ ```
14
+
15
+ Until the package is published, install from a checkout: `pip install ./sdk/python`.
16
+
17
+ ## Quickstart
18
+
19
+ ```python
20
+ from boax import Boax
21
+
22
+ # Defaults: BOAX_API_KEY and BOAX_URL (or http://localhost:8080).
23
+ client = Boax()
24
+
25
+ # Waits until the sandbox is running; pass wait=False to return at once.
26
+ sandbox = client.sandboxes.create(
27
+ profile="small",
28
+ placement={"strategy": "private-first", "external": "never"},
29
+ )
30
+
31
+ result = sandbox.exec("uname -sm") # a string runs via /bin/sh -c
32
+ print(result.exit_code, result.stdout)
33
+ sandbox.exec(["ls", "-la", "/work"], cwd="/work", timeout_seconds=30) # a list runs directly
34
+
35
+ sandbox.files.write("/work/a.txt", "hi")
36
+ text = sandbox.files.read_text("/work/a.txt")
37
+
38
+ sandbox.delete()
39
+ ```
40
+
41
+ A sandbox is also a context manager that deletes itself on exit:
42
+
43
+ ```python
44
+ with client.sandboxes.create(profile="small") as sandbox:
45
+ print(sandbox.exec("uname -a").stdout)
46
+ ```
47
+
48
+ ## Streaming output
49
+
50
+ ```python
51
+ import sys
52
+
53
+ for ev in sandbox.exec_stream("make test"):
54
+ if ev.type == "stdout":
55
+ sys.stdout.write(ev.data)
56
+ elif ev.type == "stderr":
57
+ sys.stderr.write(ev.data)
58
+ elif ev.type == "exit":
59
+ print("exit code", ev.exit_code)
60
+ else:
61
+ print("backend error:", ev.error)
62
+ ```
63
+
64
+ The command starts when `exec_stream` is called. The iterator ends after
65
+ `exit` or `error`. If the connection drops before either arrives, it raises a
66
+ `connection_error`.
67
+
68
+ ## Errors
69
+
70
+ Every failure is a `BoaxError` with `code`, `status`, `details` and
71
+ `request_id`. Branch on `code`, not on the message.
72
+
73
+ ```python
74
+ from boax import BoaxError
75
+
76
+ try:
77
+ client.sandboxes.create(profile="gpu-large")
78
+ except BoaxError as err:
79
+ if err.code != "quota_exceeded":
80
+ raise
81
+ print("over quota on", err.details.get("dimension"), "request", err.request_id)
82
+ ```
83
+
84
+ Server codes include `invalid_request`, `unauthenticated`, `permission_denied`,
85
+ `not_found`, `conflict`, `quota_exceeded`, `no_eligible_pool`,
86
+ `unsupported_capability`, `unsupported_operation`, `provider_unavailable`,
87
+ `invalid_state`, `rate_limited`, `suspended` and `internal`. The SDK adds
88
+ `timeout` and `connection_error` (with `status` 0). Waiting raises
89
+ `invalid_state` if the sandbox fails or terminates first.
90
+
91
+ **Retries.** GET, PUT and DELETE requests, and sandbox creation, are retried
92
+ up to `max_retries` times (default 3). Retries happen on 503, on 429
93
+ `rate_limited` (never `quota_exceeded`) and on connection errors, with
94
+ exponential backoff that respects `Retry-After`. Each `create` call sends its
95
+ own `Idempotency-Key`, so a retried create never makes a second sandbox. Pass
96
+ `idempotency_key` to keep the key stable across process restarts. Exec
97
+ and other POSTs are never retried.
98
+
99
+ ## What's covered
100
+
101
+ | Namespace | Methods |
102
+ |---|---|
103
+ | `sandboxes` | `create`, `get`, `list`, `iterate`, `update`, `delete`, `wait` |
104
+ | `Sandbox` handle | `exec`, `exec_stream`, `files.list/read/read_text/write`, `stop`, `start`, `pause`, `resume`, `snapshot`, `fork`, `expose_port`, `explain_placement`, `refresh`, `update`, `wait`, `delete` |
105
+ | `snapshots` | `list`, `get`, `delete` |
106
+ | `projects` | `list`, `create`, `get`, `delete` |
107
+ | `api_keys` | `list`, `create`, `revoke` |
108
+ | `quotas`, `usage` | `get` |
109
+ | `audit_events` | `list`, `iterate` |
110
+ | `policies` | `list`, `put`, `delete` |
111
+ | `capabilities`, `profiles`, `templates`, `classifications` | `list` |
112
+ | `admin` | `organizations.{list,create,get,update,suspend,resume}`, `projects.set_quota`, `pools.{list,get,create,update,drain,undrain,delete}`, `agents.list`, `trust_report`, `verify_audit`, `terminate_sandbox`, `reconcile` |
113
+ | client | `me`, `version` |
114
+
115
+ Keyword arguments are snake_case. Records come back as plain dicts keyed exactly
116
+ like the API's JSON (camelCase), typed with `TypedDict` in `boax.types`.
117
+ `ExecResult`, `ExecEvent` and `Page` are dataclasses.
118
+
119
+ `list` returns one `Page` (`items`, `next_cursor`); `iterate` walks every page:
120
+
121
+ ```python
122
+ for sb in client.sandboxes.iterate(status="running", labels={"team": "ml"}):
123
+ print(sb.id)
124
+ ```
125
+
126
+ ## Options
127
+
128
+ ```python
129
+ Boax(
130
+ api_key="dly_...", # default: BOAX_API_KEY
131
+ base_url="https://...", # default: BOAX_URL, then http://localhost:8080
132
+ timeout=120.0, # socket timeout in seconds; waits and streams set their own
133
+ max_retries=3,
134
+ headers={}, # extra headers on every request
135
+ )
136
+ ```
137
+
138
+ Plain `exec` uses the client timeout unless you pass `timeout_seconds`. For long
139
+ commands, pass `timeout_seconds` or use `exec_stream`, which has no socket
140
+ timeout.
141
+
142
+ ## Development
143
+
144
+ ```sh
145
+ python3 -m unittest discover -s tests # runs against an in-process http.server
146
+ ```
@@ -0,0 +1,22 @@
1
+ """Official Python SDK for Boax, the self-hosted sandbox and microVM platform."""
2
+
3
+ from ._client import DEFAULT_BASE_URL, Boax
4
+ from ._errors import BoaxError
5
+ from ._http import __version__
6
+ from ._sandbox import Sandbox, Sandboxes, SandboxFiles
7
+ from .openenv import BoaxEnvironment
8
+ from .types import ExecEvent, ExecResult, Page
9
+
10
+ __all__ = [
11
+ "DEFAULT_BASE_URL",
12
+ "Boax",
13
+ "BoaxError",
14
+ "BoaxEnvironment",
15
+ "ExecEvent",
16
+ "ExecResult",
17
+ "Page",
18
+ "Sandbox",
19
+ "SandboxFiles",
20
+ "Sandboxes",
21
+ "__version__",
22
+ ]
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Mapping, Optional
4
+
5
+ from ._http import HttpClient, path_segment
6
+ from ._sandbox import compact
7
+ from .types import (
8
+ AuditVerifyResult,
9
+ CostModel,
10
+ CreateOrganizationResult,
11
+ CredentialRef,
12
+ Organization,
13
+ Pool,
14
+ Project,
15
+ Quota,
16
+ Secret,
17
+ UsePoolResult,
18
+ SandboxInfo,
19
+ SuspendOrganizationResult,
20
+ TrustReport,
21
+ )
22
+
23
+
24
+ class AdminOrganizations:
25
+ def __init__(self, http: HttpClient) -> None:
26
+ self._http = http
27
+
28
+ def list(self) -> List[Organization]:
29
+ return self._http.json("GET", "/v1/admin/organizations").get("items") or []
30
+
31
+ def create(
32
+ self, name: str, *, quota: Optional[Quota] = None, default_project: Optional[str] = None
33
+ ) -> CreateOrganizationResult:
34
+ """Creates an organization, plus a project named ``default_project`` if given."""
35
+ body = compact(name=name, quota=quota, defaultProject=default_project)
36
+ return self._http.json("POST", "/v1/admin/organizations", json=body)
37
+
38
+ def get(self, org_id: str) -> Organization:
39
+ return self._http.json("GET", f"/v1/admin/organizations/{path_segment(org_id)}")
40
+
41
+ def update(self, org_id: str, *, name: Optional[str] = None, quota: Optional[Quota] = None) -> Organization:
42
+ body = compact(name=name, quota=quota)
43
+ return self._http.json("PATCH", f"/v1/admin/organizations/{path_segment(org_id)}", json=body)
44
+
45
+ def suspend(
46
+ self, org_id: str, *, terminate: bool = False, reason: Optional[str] = None
47
+ ) -> SuspendOrganizationResult:
48
+ """Blocks new work for the organization; ``terminate=True`` also ends its sandboxes."""
49
+ body = compact(terminate=terminate or None, reason=reason)
50
+ return self._http.json("POST", f"/v1/admin/organizations/{path_segment(org_id)}/suspend", json=body)
51
+
52
+ def resume(self, org_id: str, *, reason: Optional[str] = None) -> SuspendOrganizationResult:
53
+ body = compact(reason=reason)
54
+ return self._http.json("POST", f"/v1/admin/organizations/{path_segment(org_id)}/resume", json=body)
55
+
56
+
57
+ class AdminProjects:
58
+ def __init__(self, http: HttpClient) -> None:
59
+ self._http = http
60
+
61
+ def set_quota(self, project_id: str, quota: Quota) -> Project:
62
+ """Replaces a project's quota."""
63
+ return self._http.json("PATCH", f"/v1/admin/projects/{path_segment(project_id)}", json={"quota": quota})
64
+
65
+
66
+ class AdminPools:
67
+ def __init__(self, http: HttpClient) -> None:
68
+ self._http = http
69
+
70
+ def list(self) -> List[Pool]:
71
+ return self._http.json("GET", "/v1/admin/pools").get("items") or []
72
+
73
+ def get(self, name: str) -> Pool:
74
+ return self._http.json("GET", f"/v1/admin/pools/{path_segment(name)}")
75
+
76
+ def create(
77
+ self,
78
+ name: str,
79
+ adapter: str,
80
+ *,
81
+ trust: Optional[str] = None,
82
+ region: Optional[str] = None,
83
+ labels: Optional[Mapping[str, str]] = None,
84
+ enabled: Optional[bool] = None,
85
+ max_concurrency: Optional[int] = None,
86
+ cost_model: Optional[CostModel] = None,
87
+ config: Optional[Mapping[str, str]] = None,
88
+ credentials: Optional[CredentialRef] = None,
89
+ ) -> Pool:
90
+ """Registers a pool at runtime. ``credentials`` names a server env var, file or stored ``secret``, never a value."""
91
+ body = compact(
92
+ name=name,
93
+ adapter=adapter,
94
+ trust=trust,
95
+ region=region,
96
+ labels=dict(labels) if labels is not None else None,
97
+ enabled=enabled,
98
+ maxConcurrency=max_concurrency,
99
+ costModel=cost_model,
100
+ config=dict(config) if config is not None else None,
101
+ credentials=credentials,
102
+ )
103
+ return self._http.json("POST", "/v1/admin/pools", json=body)
104
+
105
+ def update(
106
+ self,
107
+ name: str,
108
+ *,
109
+ enabled: Optional[bool] = None,
110
+ max_concurrency: Optional[int] = None,
111
+ cost_model: Optional[CostModel] = None,
112
+ region: Optional[str] = None,
113
+ credential_ref: Optional[str] = None,
114
+ ) -> Pool:
115
+ """``credential_ref`` (API-registered pools only) is ``env:NAME``, ``file:/path`` or ``secret:name``."""
116
+ body = compact(enabled=enabled, maxConcurrency=max_concurrency, costModel=cost_model, region=region, credentialRef=credential_ref)
117
+ return self._http.json("PATCH", f"/v1/admin/pools/{path_segment(name)}", json=body)
118
+
119
+ def drain(self, name: str) -> Pool:
120
+ """Stops new placements on the pool; running sandboxes continue."""
121
+ return self._http.json("POST", f"/v1/admin/pools/{path_segment(name)}/drain")
122
+
123
+ def undrain(self, name: str) -> Pool:
124
+ return self._http.json("POST", f"/v1/admin/pools/{path_segment(name)}/undrain")
125
+
126
+ def use(self, name: str, *, approve_external: bool = False) -> UsePoolResult:
127
+ """Sends new sandboxes to this pool and drains every other one; running sandboxes stay.
128
+ An external pool must be approved by the global policy, or approved here."""
129
+ return self._http.json("POST", f"/v1/admin/pools/{path_segment(name)}/use", json=compact(approveExternal=approve_external or None))
130
+
131
+ def delete(self, name: str) -> None:
132
+ """Removes an API-registered pool. Pools from the config file cannot be deleted here."""
133
+ self._http.json("DELETE", f"/v1/admin/pools/{path_segment(name)}")
134
+
135
+
136
+ class AdminSecrets:
137
+ """Provider credentials stored in Boax, sealed with the server's secrets key."""
138
+
139
+ def __init__(self, http: HttpClient) -> None:
140
+ self._http = http
141
+
142
+ def list(self) -> List[Secret]:
143
+ return self._http.json("GET", "/v1/admin/secrets").get("items") or []
144
+
145
+ def put(self, name: str, value: str) -> Secret:
146
+ """Stores or replaces a value. Pools using it switch to the new value without a restart."""
147
+ return self._http.json("PUT", f"/v1/admin/secrets/{path_segment(name)}", json={"value": value})
148
+
149
+ def delete(self, name: str) -> None:
150
+ """Fails while a pool uses the secret."""
151
+ self._http.json("DELETE", f"/v1/admin/secrets/{path_segment(name)}")
152
+
153
+
154
+ class Admin:
155
+ """Operator-only endpoints. Every call needs a key with the ``admin`` scope."""
156
+
157
+ def __init__(self, http: HttpClient) -> None:
158
+ self._http = http
159
+ self.organizations = AdminOrganizations(http)
160
+ self.projects = AdminProjects(http)
161
+ self.pools = AdminPools(http)
162
+ self.secrets = AdminSecrets(http)
163
+
164
+ def trust_report(self) -> TrustReport:
165
+ """Where workloads can go, where they went, and what Boax cannot prove."""
166
+ return self._http.json("GET", "/v1/admin/trust-report")
167
+
168
+ def verify_audit(self) -> AuditVerifyResult:
169
+ """Verifies the hash chain of the whole audit log."""
170
+ return self._http.json("GET", "/v1/admin/audit/verify")
171
+
172
+ def terminate_sandbox(self, sandbox_id: str, *, reason: Optional[str] = None) -> SandboxInfo:
173
+ """Terminates any tenant's sandbox."""
174
+ body: Any = compact(reason=reason)
175
+ return self._http.json("POST", f"/v1/admin/sandboxes/{path_segment(sandbox_id)}/terminate", json=body)
176
+
177
+ def reconcile(self) -> None:
178
+ """Runs one scheduler reconcile pass now."""
179
+ self._http.json("POST", "/v1/admin/reconcile")
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Mapping, Optional
5
+
6
+ from ._admin import Admin
7
+ from ._errors import BoaxError
8
+ from ._http import HttpClient
9
+ from ._resources import ApiKeys, AuditEvents, Listing, Policies, Projects, Quotas, Snapshots, Usage
10
+ from ._sandbox import Sandboxes
11
+ from .types import Classification, PoolCapabilities, Principal, Profile, Template, VersionInfo
12
+
13
+ DEFAULT_BASE_URL = "http://localhost:8080"
14
+
15
+
16
+ class Boax:
17
+ """Client for the Boax /v1 API.
18
+
19
+ ``api_key`` defaults to ``BOAX_API_KEY`` and ``base_url`` to
20
+ ``BOAX_URL``, then http://localhost:8080. ``timeout`` is the socket
21
+ timeout in seconds; long polls and streams size their own. Idempotent
22
+ requests are retried up to ``max_retries`` times on 429 (rate limited),
23
+ 503 and connection errors.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ api_key: Optional[str] = None,
29
+ *,
30
+ base_url: Optional[str] = None,
31
+ timeout: float = 120.0,
32
+ max_retries: int = 3,
33
+ headers: Optional[Mapping[str, str]] = None,
34
+ ) -> None:
35
+ api_key = api_key or os.environ.get("BOAX_API_KEY")
36
+ if not api_key:
37
+ raise BoaxError("unauthenticated", "missing API key: pass api_key or set BOAX_API_KEY")
38
+ self._http = HttpClient(
39
+ api_key=api_key,
40
+ base_url=base_url or os.environ.get("BOAX_URL") or DEFAULT_BASE_URL,
41
+ timeout=timeout,
42
+ max_retries=max_retries,
43
+ headers=headers or {},
44
+ )
45
+ self.base_url = self._http.base_url
46
+ self.sandboxes = Sandboxes(self._http)
47
+ self.snapshots = Snapshots(self._http)
48
+ self.projects = Projects(self._http)
49
+ self.api_keys = ApiKeys(self._http)
50
+ self.quotas = Quotas(self._http)
51
+ self.usage = Usage(self._http)
52
+ self.audit_events = AuditEvents(self._http)
53
+ self.policies = Policies(self._http)
54
+ #: Pools as tenants see them: trust, region, health and supported features.
55
+ self.capabilities: Listing[PoolCapabilities] = Listing(self._http, "/v1/capabilities")
56
+ self.profiles: Listing[Profile] = Listing(self._http, "/v1/profiles")
57
+ self.templates: Listing[Template] = Listing(self._http, "/v1/templates")
58
+ self.classifications: Listing[Classification] = Listing(self._http, "/v1/classifications")
59
+ #: Operator-only endpoints.
60
+ self.admin = Admin(self._http)
61
+
62
+ def __repr__(self) -> str:
63
+ return f"Boax(base_url={self.base_url!r})"
64
+
65
+ def me(self) -> Principal:
66
+ """Describes the calling key: organization, project, scopes and operator status."""
67
+ return self._http.json("GET", "/v1/me")
68
+
69
+ def version(self) -> VersionInfo:
70
+ """Server version and installation ID."""
71
+ return self._http.json("GET", "/version")
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Dict, Mapping, Optional
5
+
6
+ _STATUS_CODES = {
7
+ 400: "invalid_request",
8
+ 401: "unauthenticated",
9
+ 403: "permission_denied",
10
+ 404: "not_found",
11
+ 409: "conflict",
12
+ 422: "no_eligible_pool",
13
+ 429: "rate_limited",
14
+ 501: "unsupported_operation",
15
+ 503: "provider_unavailable",
16
+ }
17
+
18
+
19
+ class BoaxError(Exception):
20
+ """Every failure the SDK reports.
21
+
22
+ Branch on ``code``, never on the message. Server codes include
23
+ ``invalid_request``, ``unauthenticated``, ``permission_denied``,
24
+ ``not_found``, ``conflict``, ``quota_exceeded``, ``no_eligible_pool``,
25
+ ``unsupported_capability``, ``unsupported_operation``,
26
+ ``provider_unavailable``, ``invalid_state``, ``rate_limited``,
27
+ ``suspended`` and ``internal``. The SDK itself raises ``timeout`` and
28
+ ``connection_error`` with ``status`` 0.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ code: str,
34
+ message: str,
35
+ *,
36
+ status: int = 0,
37
+ details: Optional[Mapping[str, str]] = None,
38
+ request_id: Optional[str] = None,
39
+ ) -> None:
40
+ super().__init__(message)
41
+ self.code = code
42
+ self.message = message
43
+ self.status = status
44
+ self.details: Dict[str, str] = dict(details or {})
45
+ self.request_id = request_id
46
+
47
+ def __str__(self) -> str:
48
+ return f"{self.code}: {self.message}"
49
+
50
+ def __repr__(self) -> str:
51
+ return f"BoaxError(code={self.code!r}, status={self.status}, message={self.message!r})"
52
+
53
+
54
+ def error_from_response(status: int, body: bytes, header_request_id: Optional[str]) -> BoaxError:
55
+ """Builds an error from a non-2xx response, tolerating non-JSON bodies from proxies."""
56
+ err: dict = {}
57
+ try:
58
+ envelope = json.loads(body.decode("utf-8"))
59
+ if isinstance(envelope, dict) and isinstance(envelope.get("error"), dict):
60
+ err = envelope["error"]
61
+ except (ValueError, UnicodeDecodeError):
62
+ pass
63
+ message = err.get("message")
64
+ if not isinstance(message, str):
65
+ text = body.decode("utf-8", "replace")[:200]
66
+ message = f"HTTP {status}" + (f": {text}" if text else "")
67
+ details = err.get("details")
68
+ return BoaxError(
69
+ err["code"] if isinstance(err.get("code"), str) else _STATUS_CODES.get(status, "internal"),
70
+ message,
71
+ status=status,
72
+ details=details if isinstance(details, dict) else None,
73
+ request_id=err.get("requestId") or header_request_id,
74
+ )