sail-sdk 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,18 @@
1
+ .env
2
+ .env.*
3
+ *.env*.local
4
+ *.pyc
5
+ .venv
6
+ openapi.documented.yml
7
+ node_modules
8
+ .DS_Store
9
+ go.work
10
+ go.work.sum
11
+ .vercel
12
+ .env*.local
13
+ .cursor/skills/
14
+ .claude/worktrees/
15
+ .claude/plans/
16
+ .idea/
17
+ services/tokenizer/uv.lock
18
+ *.test
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: sail-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Sail sandbox platform
5
+ Project-URL: Homepage, https://app.sailresearch.com
6
+ Project-URL: Repository, https://github.com/sailresearch/sail
7
+ Project-URL: Issues, https://github.com/sailresearch/sail/issues
8
+ Author: Sail
9
+ License-Expression: Apache-2.0
10
+ Keywords: grpc,sail,sailbox,sandbox,sdk
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: grpcio>=1.80.0
21
+ Requires-Dist: protobuf>=6.31.1
22
+ Provides-Extra: dev
23
+ Requires-Dist: grpcio-tools>=1.80.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # sail-sdk
28
+
29
+ Python SDK for the Sail sandbox platform.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install sail-sdk
35
+ ```
36
+
37
+ ```bash
38
+ uv add sail-sdk
39
+ ```
40
+
41
+ ## Sailbox exec
42
+
43
+ ```python
44
+ import sail
45
+
46
+ app = sail.App.find(name="example-app", mint_if_missing=True)
47
+ sb = sail.Sailbox.create(
48
+ image=sail.Image.debian_amd64,
49
+ app=app,
50
+ name="sandbox-1",
51
+ )
52
+ result = sb.exec("echo hi", timeout=5).wait()
53
+
54
+ print(result.stdout)
55
+ print(result.stderr)
56
+ print(result.returncode)
57
+
58
+ # Detached background launch. wait() waits for the launcher shell, not for the
59
+ # long-lived background process to exit.
60
+ sb.exec("python3 -m http.server 3000", background=True).wait()
61
+
62
+ # Omitting timeout means Sail will not terminate the exec automatically.
63
+ sb.exec("sleep 600").wait()
64
+ ```
65
+
66
+ ## Sailbox networking
67
+
68
+ ```python
69
+ import sail
70
+
71
+ app = sail.App.find(name="example-app", mint_if_missing=True)
72
+ sb = sail.Sailbox.create(
73
+ image=sail.Image.debian_amd64,
74
+ app=app,
75
+ name="sandbox-net",
76
+ timeout=300,
77
+ ingress_ports=[3000],
78
+ )
79
+
80
+ listener = sb.listener(3000)
81
+ print(listener.url)
82
+
83
+ req = sb.request(
84
+ "POST",
85
+ "https://example.com/api",
86
+ json={"hello": "world"},
87
+ idempotency_key="example-1",
88
+ )
89
+ print(req.id, req.status)
90
+
91
+ completed = req.wait()
92
+ print(completed.status)
93
+ print(completed.response.status_code)
94
+ print(completed.response.text)
95
+ ```
96
+
97
+ Explicit base images:
98
+
99
+ ```python
100
+ amd64_image = sail.Image.debian_amd64
101
+ amd_image = sail.Image.debian_amd
102
+ arm64_image = sail.Image.debian_arm64
103
+ arm_image = sail.Image.debian_arm
104
+ ```
105
+
106
+ ## Examples
107
+
108
+ - `examples/sailbox_smoke.py`: start a sailbox from the amd64 Debian image.
109
+ - `examples/sailbox_custom_image.py`: build a custom image with `apt_install`, `pip_install`, `run_commands`, and `env`, then launch a sailbox from it.
110
+
111
+ ## Publishing
112
+
113
+ Build a distributable package locally from the repo root:
114
+
115
+ ```bash
116
+ just python-sdk-build
117
+ ```
118
+
119
+ Publish from a developer machine with a PyPI token:
120
+
121
+ ```bash
122
+ export UV_PUBLISH_TOKEN=pypi-...
123
+ just python-sdk-publish
124
+ ```
125
+
126
+ The repository also includes a GitHub Actions release workflow at `.github/workflows/python-sdk-publish.yml`.
127
+ It publishes when you push a tag like `python-sdk-v0.1.0`, after verifying that the tag version matches `sail.__version__`.
128
+
129
+ Recommended setup:
130
+
131
+ 1. Create the `sail-sdk` project on PyPI.
132
+ 2. Configure PyPI Trusted Publishing for this GitHub repository and the `python-sdk-publish.yml` workflow.
133
+ 3. Bump `sdk/python/src/sail/__about__.py`.
134
+ 4. Push a matching tag: `git tag python-sdk-v0.1.0 && git push origin python-sdk-v0.1.0`
@@ -0,0 +1,108 @@
1
+ # sail-sdk
2
+
3
+ Python SDK for the Sail sandbox platform.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install sail-sdk
9
+ ```
10
+
11
+ ```bash
12
+ uv add sail-sdk
13
+ ```
14
+
15
+ ## Sailbox exec
16
+
17
+ ```python
18
+ import sail
19
+
20
+ app = sail.App.find(name="example-app", mint_if_missing=True)
21
+ sb = sail.Sailbox.create(
22
+ image=sail.Image.debian_amd64,
23
+ app=app,
24
+ name="sandbox-1",
25
+ )
26
+ result = sb.exec("echo hi", timeout=5).wait()
27
+
28
+ print(result.stdout)
29
+ print(result.stderr)
30
+ print(result.returncode)
31
+
32
+ # Detached background launch. wait() waits for the launcher shell, not for the
33
+ # long-lived background process to exit.
34
+ sb.exec("python3 -m http.server 3000", background=True).wait()
35
+
36
+ # Omitting timeout means Sail will not terminate the exec automatically.
37
+ sb.exec("sleep 600").wait()
38
+ ```
39
+
40
+ ## Sailbox networking
41
+
42
+ ```python
43
+ import sail
44
+
45
+ app = sail.App.find(name="example-app", mint_if_missing=True)
46
+ sb = sail.Sailbox.create(
47
+ image=sail.Image.debian_amd64,
48
+ app=app,
49
+ name="sandbox-net",
50
+ timeout=300,
51
+ ingress_ports=[3000],
52
+ )
53
+
54
+ listener = sb.listener(3000)
55
+ print(listener.url)
56
+
57
+ req = sb.request(
58
+ "POST",
59
+ "https://example.com/api",
60
+ json={"hello": "world"},
61
+ idempotency_key="example-1",
62
+ )
63
+ print(req.id, req.status)
64
+
65
+ completed = req.wait()
66
+ print(completed.status)
67
+ print(completed.response.status_code)
68
+ print(completed.response.text)
69
+ ```
70
+
71
+ Explicit base images:
72
+
73
+ ```python
74
+ amd64_image = sail.Image.debian_amd64
75
+ amd_image = sail.Image.debian_amd
76
+ arm64_image = sail.Image.debian_arm64
77
+ arm_image = sail.Image.debian_arm
78
+ ```
79
+
80
+ ## Examples
81
+
82
+ - `examples/sailbox_smoke.py`: start a sailbox from the amd64 Debian image.
83
+ - `examples/sailbox_custom_image.py`: build a custom image with `apt_install`, `pip_install`, `run_commands`, and `env`, then launch a sailbox from it.
84
+
85
+ ## Publishing
86
+
87
+ Build a distributable package locally from the repo root:
88
+
89
+ ```bash
90
+ just python-sdk-build
91
+ ```
92
+
93
+ Publish from a developer machine with a PyPI token:
94
+
95
+ ```bash
96
+ export UV_PUBLISH_TOKEN=pypi-...
97
+ just python-sdk-publish
98
+ ```
99
+
100
+ The repository also includes a GitHub Actions release workflow at `.github/workflows/python-sdk-publish.yml`.
101
+ It publishes when you push a tag like `python-sdk-v0.1.0`, after verifying that the tag version matches `sail.__version__`.
102
+
103
+ Recommended setup:
104
+
105
+ 1. Create the `sail-sdk` project on PyPI.
106
+ 2. Configure PyPI Trusted Publishing for this GitHub repository and the `python-sdk-publish.yml` workflow.
107
+ 3. Bump `sdk/python/src/sail/__about__.py`.
108
+ 4. Push a matching tag: `git tag python-sdk-v0.1.0 && git push origin python-sdk-v0.1.0`
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ """Build and run a sailbox with a custom Debian image.
4
+
5
+ Usage:
6
+ cd sdk/python
7
+ export SAIL_MODE=dev
8
+ export SAIL_API_KEY=your_api_key_here
9
+ uv run python examples/sailbox_custom_image.py
10
+ """
11
+
12
+ import os
13
+ import sys
14
+
15
+ import sail
16
+
17
+
18
+ def main() -> int:
19
+ scheduler_url = os.environ.get("SAIL_SCHEDULER_URL", "")
20
+ sail_mode = os.environ.get("SAIL_MODE", "")
21
+ api_key = os.environ.get("SAIL_API_KEY", "")
22
+ if not api_key:
23
+ print(
24
+ "Set SAIL_API_KEY before running this script. Optionally set SAIL_MODE=dev or override SAIL_SCHEDULER_URL.",
25
+ file=sys.stderr,
26
+ )
27
+ return 2
28
+
29
+ if not scheduler_url:
30
+ scheduler_url = (
31
+ "sailbox-scheduler.dev.sailresearch.com:443"
32
+ if sail_mode.strip().lower() == "dev"
33
+ else "sailbox-scheduler.sailresearch.com:443"
34
+ )
35
+
36
+ print("Finding or minting app...")
37
+ app = sail.App.find(name="sailbox-custom-image", mint_if_missing=True)
38
+
39
+ print("Defining custom image...")
40
+ image = (
41
+ sail.Image.debian_amd64.apt_install("git", "curl")
42
+ .pip_install("requests")
43
+ .run_commands(
44
+ "git clone https://github.com/pallets/flask.git /opt/flask-src",
45
+ "python3 -m pip show requests >/tmp/requests.txt",
46
+ )
47
+ .env(
48
+ {
49
+ "APP_ENV": "custom-image-demo",
50
+ "DEMO_MESSAGE": "hello-from-custom-image",
51
+ }
52
+ )
53
+ )
54
+
55
+ print(f"Building image via scheduler {scheduler_url}...")
56
+ built_image = image.build(timeout=1800)
57
+
58
+ print("Creating sailbox from custom image...")
59
+ sb = sail.Sailbox.create(
60
+ app=app,
61
+ image=built_image,
62
+ name="custom-image-demo",
63
+ cpu=1,
64
+ memory=512,
65
+ )
66
+
67
+ print("Sailbox ready:")
68
+ print(f" sailbox_id: {sb.sailbox_id}")
69
+ print(f" worker: {sb.worker_address}")
70
+ print(f" vm_id: {sb.vm_id}")
71
+
72
+ print("Verifying installed packages, build output, and runtime env...")
73
+ result = sb.exec(
74
+ "python3 - <<'PY'\n"
75
+ "import os\n"
76
+ "import requests\n"
77
+ "print('requests=' + requests.__version__)\n"
78
+ "print('app_env=' + os.environ['APP_ENV'])\n"
79
+ "print('demo_message=' + os.environ['DEMO_MESSAGE'])\n"
80
+ "PY\n"
81
+ "test -d /opt/flask-src/.git\n"
82
+ "cat /tmp/requests.txt | head -n 5\n",
83
+ timeout=30,
84
+ ).wait()
85
+ print(result.stdout)
86
+ if result.stderr:
87
+ print(result.stderr, file=sys.stderr)
88
+ print(f"return code: {result.returncode}")
89
+
90
+ print("Terminating sailbox...")
91
+ sb.terminate()
92
+ print("Terminated.")
93
+ return 0
94
+
95
+
96
+ if __name__ == "__main__":
97
+ raise SystemExit(main())
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ """Simple end-to-end sailbox smoke test.
4
+
5
+ Usage:
6
+ cd sdk/python
7
+ export SAIL_MODE=dev
8
+ export SAIL_API_KEY=your_api_key_here
9
+ uv run python examples/sailbox_smoke.py
10
+
11
+ You can generate a SAIL_API_KEY here: https://app.unkey.com/sail-research/apis/api_5XuHxk82XDqMSPAa/keys/ks_5XuHxpQQYsLp3omz
12
+ """
13
+
14
+ import os
15
+ import sys
16
+
17
+ import sail
18
+
19
+
20
+ def main() -> int:
21
+ scheduler_url = os.environ.get("SAIL_SCHEDULER_URL", "")
22
+ sail_mode = os.environ.get("SAIL_MODE", "")
23
+ api_key = os.environ.get("SAIL_API_KEY", "")
24
+ if not api_key:
25
+ print(
26
+ "Set SAIL_API_KEY before running this script. Optionally set SAIL_MODE=dev or override SAIL_SCHEDULER_URL.",
27
+ file=sys.stderr,
28
+ )
29
+ return 2
30
+
31
+ if not scheduler_url:
32
+ scheduler_url = (
33
+ "sailbox-scheduler.dev.sailresearch.com:443"
34
+ if sail_mode.strip().lower() == "dev"
35
+ else "sailbox-scheduler.sailresearch.com:443"
36
+ )
37
+
38
+ print("Finding or minting app...")
39
+ app = sail.App.find(name="sailbox-smoke", mint_if_missing=True)
40
+
41
+ print(f"Creating sailbox via scheduler {scheduler_url}...")
42
+ sb = sail.Sailbox.create(
43
+ app=app,
44
+ image=sail.Image.debian_amd64,
45
+ name="smoke-test",
46
+ cpu=1,
47
+ memory=512,
48
+ )
49
+
50
+ print("Sailbox ready:")
51
+ print(f" sailbox_id: {sb.sailbox_id}")
52
+ print(f" worker: {sb.worker_address}")
53
+ print(f" vm_id: {sb.vm_id}")
54
+
55
+ print("Running `echo hi`...")
56
+ result = sb.exec("echo hi", timeout=5).wait()
57
+ print(f" stdout: {result.stdout!r}")
58
+ print(f" stderr: {result.stderr!r}")
59
+ print(f" code: {result.returncode}")
60
+
61
+ print("Running a failing command...")
62
+ failed = sb.exec("echo boom >&2; exit 7", timeout=5).wait()
63
+ print(f" stdout: {failed.stdout!r}")
64
+ print(f" stderr: {failed.stderr!r}")
65
+ print(f" code: {failed.returncode}")
66
+
67
+ print("Running another successful command after failure...")
68
+ recovered = sb.exec("echo still-ok", timeout=5).wait()
69
+ print(f" stdout: {recovered.stdout!r}")
70
+ print(f" stderr: {recovered.stderr!r}")
71
+ print(f" code: {recovered.returncode}")
72
+
73
+ print("Terminating sailbox...")
74
+ sb.terminate()
75
+ print("Terminated.")
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ raise SystemExit(main())
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sail-sdk"
7
+ dynamic = ["version"]
8
+ description = "Python SDK for the Sail sandbox platform"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "Sail" },
14
+ ]
15
+ keywords = ["sail", "sandbox", "sailbox", "grpc", "sdk"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: Apache Software License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+ dependencies = [
27
+ "grpcio>=1.80.0",
28
+ "protobuf>=6.31.1",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://app.sailresearch.com"
33
+ Repository = "https://github.com/sailresearch/sail"
34
+ Issues = "https://github.com/sailresearch/sail/issues"
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "grpcio-tools>=1.80.0",
39
+ "pytest>=8.0",
40
+ ]
41
+
42
+ [tool.hatch.version]
43
+ path = "src/sail/__about__.py"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/sail"]
@@ -0,0 +1,3 @@
1
+ """Package metadata for the Sail Python SDK."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,43 @@
1
+ """Sail SDK — Python client for the Sail sandbox platform."""
2
+
3
+ from sail.__about__ import __version__
4
+ from sail.app import App
5
+ from sail.errors import (
6
+ ImageBuildError,
7
+ SailError,
8
+ SailboxCreationError,
9
+ SailboxError,
10
+ SailboxExecAlreadyRunningError,
11
+ SailboxExecutionError,
12
+ SailboxExecRequestNotFoundError,
13
+ SailboxTerminatedError,
14
+ )
15
+ from sail.image import Image
16
+ from sail.sailbox import (
17
+ Sailbox,
18
+ SailboxExecRequest,
19
+ SailboxExecResult,
20
+ SailboxListener,
21
+ SailboxRequest,
22
+ SailboxResponse,
23
+ )
24
+
25
+ __all__ = [
26
+ "App",
27
+ "Image",
28
+ "ImageBuildError",
29
+ "SailError",
30
+ "Sailbox",
31
+ "SailboxCreationError",
32
+ "SailboxError",
33
+ "SailboxExecAlreadyRunningError",
34
+ "SailboxExecutionError",
35
+ "SailboxExecRequestNotFoundError",
36
+ "SailboxExecRequest",
37
+ "SailboxExecResult",
38
+ "SailboxListener",
39
+ "SailboxRequest",
40
+ "SailboxResponse",
41
+ "SailboxTerminatedError",
42
+ "__version__",
43
+ ]
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ # The generated proto stubs use absolute imports (e.g. ``from scheduler.v1 import
7
+ # scheduler_pb2``). Adding the ``pb/`` directory to sys.path makes those imports
8
+ # resolve correctly.
9
+ _PB_DIR = str(Path(__file__).resolve().parent / "pb")
10
+ if _PB_DIR not in sys.path:
11
+ sys.path.insert(0, _PB_DIR)
12
+
13
+ import grpc # noqa: E402
14
+ from scheduler.v1 import scheduler_pb2_grpc # noqa: E402
15
+
16
+ from sail._config import Config # noqa: E402
17
+
18
+
19
+ def open_channel(target: str) -> grpc.Channel:
20
+ if target.startswith(("localhost:", "127.0.0.1:", "[::1]:")):
21
+ return grpc.insecure_channel(target)
22
+ return grpc.secure_channel(target, grpc.ssl_channel_credentials())
23
+
24
+
25
+ class Client:
26
+ """Manages the gRPC channel to the sailbox scheduler."""
27
+
28
+ _instance: Client | None = None
29
+
30
+ def __init__(self, config: Config | None = None) -> None:
31
+ self._config = config or Config.from_env()
32
+ self._channel = open_channel(self._config.scheduler_url)
33
+ self._stub = scheduler_pb2_grpc.SchedulerServiceStub(self._channel)
34
+
35
+ @classmethod
36
+ def get(cls) -> Client:
37
+ """Return the singleton client, creating it on first call."""
38
+ if cls._instance is None:
39
+ cls._instance = cls()
40
+ return cls._instance
41
+
42
+ @classmethod
43
+ def reset(cls) -> None:
44
+ """Close and discard the singleton (useful for tests)."""
45
+ if cls._instance is not None:
46
+ cls._instance.close()
47
+ cls._instance = None
48
+
49
+ @property
50
+ def stub(self) -> scheduler_pb2_grpc.SchedulerServiceStub:
51
+ return self._stub
52
+
53
+ def metadata(self) -> list[tuple[str, str]]:
54
+ """Return gRPC call metadata with the authorization header."""
55
+ return [("authorization", f"Bearer {self._config.api_key}")]
56
+
57
+ def close(self) -> None:
58
+ if self._channel is not None:
59
+ self._channel.close()
60
+
61
+
62
+ def open_channel(target: str) -> grpc.Channel:
63
+ if _is_local_target(target):
64
+ return grpc.secure_channel(target, grpc.local_channel_credentials())
65
+ return grpc.secure_channel(target, grpc.ssl_channel_credentials())
66
+
67
+
68
+ def _is_local_target(target: str) -> bool:
69
+ host = target.split(":", 1)[0].strip().lower()
70
+ return host in {"localhost", "127.0.0.1", "[::1]"}
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ _PROD_SCHEDULER_URL = "sailbox-scheduler.sailresearch.com:443"
6
+ _DEV_SCHEDULER_URL = "sailbox-scheduler.dev.sailresearch.com:443"
7
+ _PROD_API_URL = "https://api.sailresearch.com"
8
+ _DEV_API_URL = "https://dev.sailresearch.com"
9
+
10
+
11
+ class Config:
12
+ """SDK configuration loaded from environment variables."""
13
+
14
+ def __init__(self, *, scheduler_url: str, api_key: str, api_url: str = "") -> None:
15
+ self.scheduler_url = scheduler_url
16
+ self.api_key = api_key
17
+ self.api_url = api_url
18
+
19
+ @classmethod
20
+ def from_env(cls) -> Config:
21
+ mode = os.environ.get("SAIL_MODE", "").strip().lower()
22
+ if mode == "dev":
23
+ default_scheduler_url = _DEV_SCHEDULER_URL
24
+ default_api_url = _DEV_API_URL
25
+ else:
26
+ default_scheduler_url = _PROD_SCHEDULER_URL
27
+ default_api_url = _PROD_API_URL
28
+
29
+ scheduler_url = (
30
+ os.environ.get("SAIL_SCHEDULER_URL", "").strip() or default_scheduler_url
31
+ )
32
+ api_key = os.environ.get("SAIL_API_KEY", "")
33
+ if not api_key:
34
+ raise ValueError("SAIL_API_KEY must be set")
35
+ api_url = os.environ.get("SAIL_API_URL", "").strip() or default_api_url
36
+ return cls(scheduler_url=scheduler_url, api_key=api_key, api_url=api_url)
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+
7
+ from sail._config import Config
8
+
9
+
10
+ class HTTPClient:
11
+ """Thin HTTP client for the Sail REST API."""
12
+
13
+ _instance: HTTPClient | None = None
14
+
15
+ def __init__(self, config: Config | None = None) -> None:
16
+ self._config = config or Config.from_env()
17
+ if not self._config.api_url:
18
+ raise ValueError(
19
+ "SAIL_API_URL must be set (e.g. https://api.sailresearch.com)"
20
+ )
21
+
22
+ @classmethod
23
+ def get(cls) -> HTTPClient:
24
+ """Return the singleton client, creating it on first call."""
25
+ if cls._instance is None:
26
+ cls._instance = cls()
27
+ return cls._instance
28
+
29
+ @classmethod
30
+ def reset(cls) -> None:
31
+ """Close and discard the singleton (useful for tests)."""
32
+ cls._instance = None
33
+
34
+ def post(self, path: str, body: dict) -> tuple[int, dict]:
35
+ """POST JSON to {api_url}{path}, return (status_code, parsed_json)."""
36
+ url = self._config.api_url.rstrip("/") + path
37
+ data = json.dumps(body).encode()
38
+ req = urllib.request.Request(
39
+ url,
40
+ data=data,
41
+ headers={
42
+ "Authorization": f"Bearer {self._config.api_key}",
43
+ "Content-Type": "application/json",
44
+ },
45
+ method="POST",
46
+ )
47
+ try:
48
+ with urllib.request.urlopen(req) as resp:
49
+ return resp.status, json.loads(resp.read())
50
+ except urllib.error.HTTPError as e:
51
+ body_bytes = e.read()
52
+ try:
53
+ return e.code, json.loads(body_bytes)
54
+ except (json.JSONDecodeError, ValueError):
55
+ return e.code, {
56
+ "error": {"message": body_bytes.decode(errors="replace")}
57
+ }