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.
- sail_sdk-0.1.0/.gitignore +18 -0
- sail_sdk-0.1.0/PKG-INFO +134 -0
- sail_sdk-0.1.0/README.md +108 -0
- sail_sdk-0.1.0/examples/sailbox_custom_image.py +97 -0
- sail_sdk-0.1.0/examples/sailbox_smoke.py +80 -0
- sail_sdk-0.1.0/pyproject.toml +46 -0
- sail_sdk-0.1.0/src/sail/__about__.py +3 -0
- sail_sdk-0.1.0/src/sail/__init__.py +43 -0
- sail_sdk-0.1.0/src/sail/_client.py +70 -0
- sail_sdk-0.1.0/src/sail/_config.py +36 -0
- sail_sdk-0.1.0/src/sail/_http_client.py +57 -0
- sail_sdk-0.1.0/src/sail/app.py +31 -0
- sail_sdk-0.1.0/src/sail/errors.py +37 -0
- sail_sdk-0.1.0/src/sail/image.py +152 -0
- sail_sdk-0.1.0/src/sail/pb/saild/v1/control_pb2.py +70 -0
- sail_sdk-0.1.0/src/sail/pb/saild/v1/control_pb2_grpc.py +436 -0
- sail_sdk-0.1.0/src/sail/pb/scheduler/__init__.py +0 -0
- sail_sdk-0.1.0/src/sail/pb/scheduler/v1/__init__.py +0 -0
- sail_sdk-0.1.0/src/sail/pb/scheduler/v1/scheduler_pb2.py +110 -0
- sail_sdk-0.1.0/src/sail/pb/scheduler/v1/scheduler_pb2_grpc.py +635 -0
- sail_sdk-0.1.0/src/sail/pb/workerproxy/v1/workerproxy_pb2.py +104 -0
- sail_sdk-0.1.0/src/sail/pb/workerproxy/v1/workerproxy_pb2_grpc.py +672 -0
- sail_sdk-0.1.0/src/sail/sailbox.py +525 -0
- sail_sdk-0.1.0/tests/test_sdk.py +1065 -0
- sail_sdk-0.1.0/uv.lock +225 -0
|
@@ -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
|
sail_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -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`
|
sail_sdk-0.1.0/README.md
ADDED
|
@@ -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,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
|
+
}
|