tenki 0.5.1__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.
- tenki-0.5.1/.github/workflows/pypi-publish.yml +64 -0
- tenki-0.5.1/.github/workflows/test.yml +65 -0
- tenki-0.5.1/PKG-INFO +295 -0
- tenki-0.5.1/README.md +271 -0
- tenki-0.5.1/examples/template_builder.py +128 -0
- tenki-0.5.1/pyproject.toml +56 -0
- tenki-0.5.1/scripts/patch_generated_imports.py +27 -0
- tenki-0.5.1/src/tenki/__init__.py +10 -0
- tenki-0.5.1/src/tenki/py.typed +0 -0
- tenki-0.5.1/src/tenki_sandbox/__init__.py +164 -0
- tenki-0.5.1/src/tenki_sandbox/_convert.py +555 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/__init__.py +0 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/buf/__init__.py +0 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/buf/validate/__init__.py +0 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/buf/validate/validate_pb2.py +450 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/__init__.py +0 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/__init__.py +0 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/__init__.py +0 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/registry_pb2.py +225 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/registry_pb2_grpc.py +3 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/sandbox_pb2.py +936 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/sandbox_pb2_grpc.py +3908 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/ssh_gateway_client_pb2.py +58 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/ssh_gateway_client_pb2_grpc.py +136 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/template_pb2.py +402 -0
- tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/template_pb2_grpc.py +3 -0
- tenki-0.5.1/src/tenki_sandbox/_pagination.py +8 -0
- tenki-0.5.1/src/tenki_sandbox/_pb.py +6 -0
- tenki-0.5.1/src/tenki_sandbox/_resource_validation.py +70 -0
- tenki-0.5.1/src/tenki_sandbox/_time.py +52 -0
- tenki-0.5.1/src/tenki_sandbox/_transport.py +367 -0
- tenki-0.5.1/src/tenki_sandbox/aio/__init__.py +35 -0
- tenki-0.5.1/src/tenki_sandbox/aio/_transport.py +125 -0
- tenki-0.5.1/src/tenki_sandbox/aio/client.py +241 -0
- tenki-0.5.1/src/tenki_sandbox/aio/data_plane.py +269 -0
- tenki-0.5.1/src/tenki_sandbox/aio/dial.py +150 -0
- tenki-0.5.1/src/tenki_sandbox/aio/fs.py +166 -0
- tenki-0.5.1/src/tenki_sandbox/aio/git.py +72 -0
- tenki-0.5.1/src/tenki_sandbox/aio/preview_url.py +85 -0
- tenki-0.5.1/src/tenki_sandbox/aio/process.py +233 -0
- tenki-0.5.1/src/tenki_sandbox/aio/registry.py +193 -0
- tenki-0.5.1/src/tenki_sandbox/aio/resources.py +556 -0
- tenki-0.5.1/src/tenki_sandbox/aio/sandbox.py +556 -0
- tenki-0.5.1/src/tenki_sandbox/aio/ssh.py +172 -0
- tenki-0.5.1/src/tenki_sandbox/aio/tunnel.py +444 -0
- tenki-0.5.1/src/tenki_sandbox/client.py +373 -0
- tenki-0.5.1/src/tenki_sandbox/constants.py +23 -0
- tenki-0.5.1/src/tenki_sandbox/data_plane.py +246 -0
- tenki-0.5.1/src/tenki_sandbox/dial.py +121 -0
- tenki-0.5.1/src/tenki_sandbox/errors.py +406 -0
- tenki-0.5.1/src/tenki_sandbox/fs.py +138 -0
- tenki-0.5.1/src/tenki_sandbox/git.py +69 -0
- tenki-0.5.1/src/tenki_sandbox/models.py +412 -0
- tenki-0.5.1/src/tenki_sandbox/preview_url.py +85 -0
- tenki-0.5.1/src/tenki_sandbox/process.py +266 -0
- tenki-0.5.1/src/tenki_sandbox/py.typed +1 -0
- tenki-0.5.1/src/tenki_sandbox/registry.py +212 -0
- tenki-0.5.1/src/tenki_sandbox/resources.py +685 -0
- tenki-0.5.1/src/tenki_sandbox/sandbox.py +567 -0
- tenki-0.5.1/src/tenki_sandbox/ssh.py +214 -0
- tenki-0.5.1/src/tenki_sandbox/template_spec.py +1005 -0
- tenki-0.5.1/src/tenki_sandbox/tunnel.py +508 -0
- tenki-0.5.1/tests/_async_fakes.py +175 -0
- tenki-0.5.1/tests/test_async_client.py +233 -0
- tenki-0.5.1/tests/test_async_conn.py +371 -0
- tenki-0.5.1/tests/test_async_parity.py +83 -0
- tenki-0.5.1/tests/test_async_process_fs.py +328 -0
- tenki-0.5.1/tests/test_async_resources.py +89 -0
- tenki-0.5.1/tests/test_async_transport.py +260 -0
- tenki-0.5.1/tests/test_client.py +512 -0
- tenki-0.5.1/tests/test_connect_transport.py +142 -0
- tenki-0.5.1/tests/test_data_plane.py +63 -0
- tenki-0.5.1/tests/test_dial.py +362 -0
- tenki-0.5.1/tests/test_pagination.py +10 -0
- tenki-0.5.1/tests/test_process_fs_preview.py +445 -0
- tenki-0.5.1/tests/test_protobuf_compatibility.py +75 -0
- tenki-0.5.1/tests/test_registry.py +512 -0
- tenki-0.5.1/tests/test_resources_errors.py +348 -0
- tenki-0.5.1/tests/test_sandbox_update.py +124 -0
- tenki-0.5.1/tests/test_snapshots_extras.py +295 -0
- tenki-0.5.1/tests/test_ssh.py +543 -0
- tenki-0.5.1/tests/test_template_builder_example.py +102 -0
- tenki-0.5.1/tests/test_template_spec.py +400 -0
- tenki-0.5.1/tests/test_template_workflow_e2e.py +217 -0
- tenki-0.5.1/tests/test_templates_typed.py +491 -0
- tenki-0.5.1/tests/test_tunnel.py +580 -0
- tenki-0.5.1/tests/test_volumes_wait.py +61 -0
- tenki-0.5.1/tests/test_who_am_i.py +105 -0
- tenki-0.5.1/tests/test_workspace_compatibility.py +12 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
dry_run:
|
|
7
|
+
description: Run checks and package build without publishing
|
|
8
|
+
type: boolean
|
|
9
|
+
default: true
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
id-token: write
|
|
14
|
+
|
|
15
|
+
concurrency:
|
|
16
|
+
group: pypi-publish-${{ github.ref }}
|
|
17
|
+
cancel-in-progress: false
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
publish:
|
|
21
|
+
runs-on: tenki-standard-small-2c-4g
|
|
22
|
+
environment: pypi-publish
|
|
23
|
+
steps:
|
|
24
|
+
- name: Checkout
|
|
25
|
+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
26
|
+
|
|
27
|
+
- name: Setup Python
|
|
28
|
+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
29
|
+
with:
|
|
30
|
+
python-version: "3.12"
|
|
31
|
+
|
|
32
|
+
- name: Install dependencies
|
|
33
|
+
run: |
|
|
34
|
+
python -m pip install --upgrade pip
|
|
35
|
+
pip install build twine
|
|
36
|
+
pip install -e ".[dev]"
|
|
37
|
+
|
|
38
|
+
- name: Test
|
|
39
|
+
run: pytest
|
|
40
|
+
|
|
41
|
+
- name: Build
|
|
42
|
+
run: python -m build
|
|
43
|
+
|
|
44
|
+
- name: Check package
|
|
45
|
+
run: twine check dist/*
|
|
46
|
+
|
|
47
|
+
- name: Check if version is already published
|
|
48
|
+
id: version
|
|
49
|
+
run: |
|
|
50
|
+
set -euo pipefail
|
|
51
|
+
|
|
52
|
+
NAME=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["name"])')
|
|
53
|
+
VERSION=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
|
|
54
|
+
|
|
55
|
+
if curl -fsS "https://pypi.org/pypi/${NAME}/${VERSION}/json" >/dev/null 2>&1; then
|
|
56
|
+
echo "${NAME}@${VERSION} already published, skipping"
|
|
57
|
+
echo "published=true" >> "$GITHUB_OUTPUT"
|
|
58
|
+
else
|
|
59
|
+
echo "published=false" >> "$GITHUB_OUTPUT"
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
- name: Publish to PyPI
|
|
63
|
+
if: ${{ steps.version.outputs.published == 'false' && (github.event_name == 'push' || inputs.dry_run == false) }}
|
|
64
|
+
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
paths:
|
|
6
|
+
- ".github/workflows/test.yml"
|
|
7
|
+
- "examples/**"
|
|
8
|
+
- "pyproject.toml"
|
|
9
|
+
- "scripts/**"
|
|
10
|
+
- "src/**"
|
|
11
|
+
- "tests/**"
|
|
12
|
+
pull_request:
|
|
13
|
+
paths:
|
|
14
|
+
- ".github/workflows/test.yml"
|
|
15
|
+
- "examples/**"
|
|
16
|
+
- "pyproject.toml"
|
|
17
|
+
- "scripts/**"
|
|
18
|
+
- "src/**"
|
|
19
|
+
- "tests/**"
|
|
20
|
+
workflow_dispatch:
|
|
21
|
+
|
|
22
|
+
concurrency:
|
|
23
|
+
group: test-${{ github.event.pull_request.number || github.ref }}
|
|
24
|
+
cancel-in-progress: true
|
|
25
|
+
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
protobuf-compatibility:
|
|
31
|
+
name: protobuf ${{ matrix.protobuf }}
|
|
32
|
+
runs-on: tenki-standard-small-2c-4g
|
|
33
|
+
strategy:
|
|
34
|
+
fail-fast: false
|
|
35
|
+
matrix:
|
|
36
|
+
include:
|
|
37
|
+
- protobuf: "5.29.5"
|
|
38
|
+
warning_args: "-W error"
|
|
39
|
+
- protobuf: "6.31.1"
|
|
40
|
+
warning_args: ""
|
|
41
|
+
- protobuf: "6.33.6"
|
|
42
|
+
warning_args: "-W error"
|
|
43
|
+
- protobuf: "7.35.1"
|
|
44
|
+
warning_args: "-W error"
|
|
45
|
+
|
|
46
|
+
steps:
|
|
47
|
+
- name: Checkout
|
|
48
|
+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
49
|
+
|
|
50
|
+
- name: Setup Python
|
|
51
|
+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
52
|
+
with:
|
|
53
|
+
python-version: "3.12"
|
|
54
|
+
|
|
55
|
+
- name: Install test dependencies
|
|
56
|
+
run: |
|
|
57
|
+
python -m pip install --upgrade pip
|
|
58
|
+
pip install -e . \
|
|
59
|
+
"protobuf==${{ matrix.protobuf }}" \
|
|
60
|
+
"pytest>=8.0" \
|
|
61
|
+
"pytest-asyncio>=0.23" \
|
|
62
|
+
"websockets>=12"
|
|
63
|
+
|
|
64
|
+
- name: Test
|
|
65
|
+
run: python ${{ matrix.warning_args }} -m pytest
|
tenki-0.5.1/PKG-INFO
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tenki
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: Tenki Cloud Python SDK — cloud sandboxes (microVMs) for AI agents and code execution
|
|
5
|
+
Project-URL: Homepage, https://tenki.cloud
|
|
6
|
+
Project-URL: Documentation, https://tenki.cloud/docs/sandbox/sdk
|
|
7
|
+
Project-URL: Repository, https://github.com/LuxorLabs/tenki-sdk-python
|
|
8
|
+
Project-URL: Issues, https://github.com/LuxorLabs/tenki-sdk-python/issues
|
|
9
|
+
Author: Tenki Cloud
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: agents,ai-agents,cloud,code-execution,code-interpreter,microvm,sandbox,tenki
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: grpcio>=1.73
|
|
14
|
+
Requires-Dist: protobuf>=5.29.5
|
|
15
|
+
Requires-Dist: websocket-client>=1.6
|
|
16
|
+
Provides-Extra: async
|
|
17
|
+
Requires-Dist: websockets>=12; extra == 'async'
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: grpcio-tools>=1.73; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: websockets>=12; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Tenki Python SDK
|
|
26
|
+
|
|
27
|
+
Python SDK for Tenki: cloud sandboxes (microVMs) for AI agents and code execution.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install tenki
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> The SDK is published as `tenki`. Existing code that does
|
|
34
|
+
> `import tenki_sandbox` keeps working (that module ships inside `tenki`), but
|
|
35
|
+
> new code should use `import tenki`.
|
|
36
|
+
|
|
37
|
+
## Protobuf compatibility
|
|
38
|
+
|
|
39
|
+
The SDK supports `protobuf>=5.29.5` with no upper bound. Its Python bindings are
|
|
40
|
+
temporarily generated with protobuf 5.29.5 so applications that require
|
|
41
|
+
`protobuf<6` can install the SDK alongside dependencies such as Memori.
|
|
42
|
+
|
|
43
|
+
Protobuf 6.31.x emits a benign warning when it loads 5.29.5-generated bindings.
|
|
44
|
+
If a test suite pins that runtime and promotes warnings to errors, scope the
|
|
45
|
+
exception narrowly instead of disabling protobuf's runtime checks:
|
|
46
|
+
|
|
47
|
+
```toml
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
filterwarnings = [
|
|
50
|
+
"ignore:Protobuf gencode version 5\\.29\\.5 is exactly one major version older than the runtime version 6\\.31\\..*:UserWarning:google\\.protobuf\\.runtime_version",
|
|
51
|
+
]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from tenki import Sandbox
|
|
56
|
+
|
|
57
|
+
# create() waits by default via a single server-held request and returns an
|
|
58
|
+
# exec-ready sandbox with data-plane access primed (wait=False to skip).
|
|
59
|
+
with Sandbox.create(cpu_cores=2, memory_mb=4096) as sb:
|
|
60
|
+
result = sb.exec("python3", "-c", "print('hello')")
|
|
61
|
+
result.check()
|
|
62
|
+
print(result.stdout_text)
|
|
63
|
+
|
|
64
|
+
# fs paths are relative to the sandbox workdir (absolute paths must stay inside it)
|
|
65
|
+
sb.fs.write_text("input.txt", "data")
|
|
66
|
+
print(sb.fs.read_text("input.txt"))
|
|
67
|
+
|
|
68
|
+
preview = sb.expose_port(3000, ttl=3600)
|
|
69
|
+
print(preview.url)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The API key determines the Workspace automatically; ordinary Sandbox calls do not require a Workspace ID.
|
|
73
|
+
|
|
74
|
+
## Async (asyncio)
|
|
75
|
+
|
|
76
|
+
`AsyncClient` / `AsyncSandbox` expose the same surface with native `async`/`await`,
|
|
77
|
+
built on `grpc.aio` — no `asyncio.to_thread` wrapping required. Use it inside any
|
|
78
|
+
asyncio server (FastAPI, aiohttp, etc.).
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import asyncio
|
|
82
|
+
from tenki import AsyncSandbox
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def main():
|
|
86
|
+
async with await AsyncSandbox.create(cpu_cores=2) as sb:
|
|
87
|
+
result = await sb.exec("python3", "-c", "print('hello')")
|
|
88
|
+
result.check()
|
|
89
|
+
print(result.stdout_text)
|
|
90
|
+
|
|
91
|
+
await sb.fs.write_text("input.txt", "data")
|
|
92
|
+
print(await sb.fs.read_text("input.txt"))
|
|
93
|
+
|
|
94
|
+
proc = await sb.start("bash", "-lc", "read name; echo hi $name")
|
|
95
|
+
await proc.write_stdin("tenki\n")
|
|
96
|
+
await proc.close_stdin()
|
|
97
|
+
async for chunk in proc.stdout:
|
|
98
|
+
print(chunk.decode(), end="")
|
|
99
|
+
(await proc.wait()).check()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
asyncio.run(main())
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The sync `Client` / `Sandbox` remain available for non-async consumers. SSH,
|
|
106
|
+
`dial`, and host-port tunnels are also async: `AsyncSandbox.ssh()` returns an
|
|
107
|
+
`AsyncSSHConn` (raw async `read`/`write`/`close`, matching the TS/Go SDKs; needs
|
|
108
|
+
the `tenki[async]` extra for `websockets`), `dial()` returns an
|
|
109
|
+
`AsyncDialConn`, and `expose_host_port()` / `expose_host_port_resilient()` return
|
|
110
|
+
async tunnels with `await tunnel.terminated.wait()` and `on_terminated` callbacks.
|
|
111
|
+
|
|
112
|
+
## Auth
|
|
113
|
+
|
|
114
|
+
Auth resolution:
|
|
115
|
+
|
|
116
|
+
1. `auth_token=` passed to `Client` or `Sandbox.create`
|
|
117
|
+
2. `TENKI_AUTH_TOKEN`
|
|
118
|
+
3. `TENKI_API_KEY`
|
|
119
|
+
|
|
120
|
+
`TENKI_API_ENDPOINT` overrides the API URL; legacy `TENKI_API_URL` is also accepted.
|
|
121
|
+
|
|
122
|
+
## Process API
|
|
123
|
+
|
|
124
|
+
`exec` collects stdout/stderr and returns a result:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
result = sb.exec("npm", "test", cwd="project", timeout=60, env={"CI": "1"})
|
|
128
|
+
print(result.stdout_text)
|
|
129
|
+
result.check()
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`start` returns a live process:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
proc = sb.start("bash", "-lc", "read name; echo hello $name")
|
|
136
|
+
proc.write_stdin("tenki\n")
|
|
137
|
+
proc.close_stdin()
|
|
138
|
+
for chunk in proc.stdout:
|
|
139
|
+
print(chunk.decode(), end="")
|
|
140
|
+
proc.wait().check()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Use `shell()` when you want shell parsing:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
sb.shell("python3 -m http.server 3000 >/tmp/server.log 2>&1 &")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Process `cwd` values follow the guest contract: relative paths are normalized
|
|
150
|
+
under the sandbox guest workdir (`/home/tenki` by default), absolute paths are
|
|
151
|
+
used unchanged, and missing or non-directory targets fail before the process
|
|
152
|
+
starts.
|
|
153
|
+
|
|
154
|
+
## Sandbox lifetime
|
|
155
|
+
|
|
156
|
+
Long-lived sandboxes are a parameter choice at `create()`, not a separate API:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
sb = client.create(
|
|
160
|
+
sticky=True, # long-lived session: not reaped on idle
|
|
161
|
+
idle_timeout_minutes=120, # or: generous idle window before auto-pause
|
|
162
|
+
max_duration=8 * 3600, # total lifetime cap (seconds)
|
|
163
|
+
pause_retention=24 * 3600,# how long a paused session is kept resumable
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `max_duration` caps total lifetime; `sb.extend(seconds)` pushes the deadline
|
|
168
|
+
(`sb.info.timeout_at`) while running.
|
|
169
|
+
- `idle_timeout_minutes` auto-pauses an idle sandbox; `sb.resume()` brings it
|
|
170
|
+
back with the filesystem intact.
|
|
171
|
+
- `sticky=True` opts the session out of idle reaping for keep-warm use cases
|
|
172
|
+
(workspaces cap concurrent sticky sessions).
|
|
173
|
+
- `client.list(sticky=True)` filters for long-lived sessions in the API key's Workspace.
|
|
174
|
+
|
|
175
|
+
## SSH
|
|
176
|
+
|
|
177
|
+
For tools that speak SSH (paramiko, scp, IDE remote dev), the SDK can mint a
|
|
178
|
+
short-lived OpenSSH user certificate for your session and open a transport to
|
|
179
|
+
the sandbox SSH gateway. No keys are provisioned into the guest; the engine
|
|
180
|
+
signs your local public key and the gateway verifies the certificate.
|
|
181
|
+
|
|
182
|
+
Requires `pip install websocket-client paramiko` (websocket-client for the
|
|
183
|
+
transport, paramiko if you want a client in-process).
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
import subprocess
|
|
187
|
+
import paramiko
|
|
188
|
+
|
|
189
|
+
# 1. local keypair (any OpenSSH key works; ed25519 shown)
|
|
190
|
+
subprocess.run(["ssh-keygen", "-t", "ed25519", "-N", "", "-q", "-f", "id_tenki"], check=True)
|
|
191
|
+
|
|
192
|
+
# 2. engine signs a short-lived user cert for this sandbox
|
|
193
|
+
cert = sb.issue_ssh_cert(open("id_tenki.pub").read(), ttl=600)
|
|
194
|
+
open("id_tenki-cert.pub", "w").write(cert.ssh_cert)
|
|
195
|
+
|
|
196
|
+
# 3. open the gateway transport and run a paramiko session over it
|
|
197
|
+
pkey = paramiko.Ed25519Key.from_private_key_file("id_tenki")
|
|
198
|
+
pkey.load_certificate("id_tenki-cert.pub")
|
|
199
|
+
|
|
200
|
+
transport = paramiko.Transport(sb.ssh()) # WebSocket-backed socket
|
|
201
|
+
transport.connect(username="tenki", pkey=pkey)
|
|
202
|
+
session = transport.open_session()
|
|
203
|
+
session.exec_command("echo hello-over-ssh")
|
|
204
|
+
print(session.makefile().read().decode())
|
|
205
|
+
transport.close()
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Notes:
|
|
209
|
+
|
|
210
|
+
- `sb.ssh()` / `client.ssh(session_id)` discover an active gateway and return
|
|
211
|
+
`SSHConn`, an `io.RawIOBase` socket usable anywhere paramiko accepts one.
|
|
212
|
+
- The SSH username is `tenki`.
|
|
213
|
+
- `TENKI_SANDBOX_GATEWAY_URL` overrides the gateway WebSocket URL (it is
|
|
214
|
+
otherwise derived from the API endpoint).
|
|
215
|
+
- Certificate RPCs use the Connect protocol over HTTPS (same as the Go SDK and
|
|
216
|
+
the `tenki` CLI), so they work through standard HTTP load balancers.
|
|
217
|
+
- `sb.update_ssh_authorized_keys([...])` additionally plants long-lived public
|
|
218
|
+
keys in the guest's `authorized_keys` if you prefer key-based auth for the
|
|
219
|
+
in-guest sshd.
|
|
220
|
+
|
|
221
|
+
## Resource APIs
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from tenki import Client, GiB
|
|
225
|
+
|
|
226
|
+
client = Client()
|
|
227
|
+
|
|
228
|
+
volume = client.volumes.create(
|
|
229
|
+
name="cache",
|
|
230
|
+
size_bytes=10 * GiB,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
preview = client.preview_urls.create(
|
|
234
|
+
slug="demo",
|
|
235
|
+
session_id=sb.id,
|
|
236
|
+
port=3000,
|
|
237
|
+
)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Registry
|
|
241
|
+
|
|
242
|
+
Delete an untagged, non-latest, unshared registry version by image and snapshot
|
|
243
|
+
ID:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
result = client.registry.delete_version(
|
|
247
|
+
"11111111-1111-1111-1111-111111111111",
|
|
248
|
+
"55555555-5555-5555-5555-555555555555",
|
|
249
|
+
)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Templates (typed builder)
|
|
253
|
+
|
|
254
|
+
`TemplateSpec` is an immutable typed recipe (every builder call returns a new
|
|
255
|
+
value). Builds freeze the normalized spec + `spec_hash` server-side and return
|
|
256
|
+
a private digest-addressed `Image`, the normal sandbox launch source.
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
from tenki import Client, TemplateSpec
|
|
260
|
+
|
|
261
|
+
client = Client()
|
|
262
|
+
|
|
263
|
+
spec = (
|
|
264
|
+
TemplateSpec() # defaults to base image "sandbox"
|
|
265
|
+
.from_image("sandbox-v2") # or .from_template(...) / .from_snapshot(...)
|
|
266
|
+
.with_git_context("https://github.com/acme/node-api", "main")
|
|
267
|
+
.run("npm ci", name="Install dependencies")
|
|
268
|
+
.runtime_env({"NODE_ENV": "production"})
|
|
269
|
+
.start(["npm", "start"], ready_when=[{"http": "http://localhost:3000/health"}])
|
|
270
|
+
)
|
|
271
|
+
spec.validate() # aggregate local validation; server stays authoritative
|
|
272
|
+
|
|
273
|
+
template = client.templates.create(name="node-api", spec=spec)
|
|
274
|
+
|
|
275
|
+
build = client.templates.build(
|
|
276
|
+
template, # resource objects in, IDs as fallback
|
|
277
|
+
build_secrets={"GITHUB_TOKEN": token}, # explicit request-time values
|
|
278
|
+
wait_for_completion=True,
|
|
279
|
+
on_event=lambda e: print(e), # one ordered log/progress handler
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
sb = client.create(image=build.image, wait_for_runtime=True)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`wait_build(build_id)` reconnects to an existing build (ordered, deduplicated
|
|
286
|
+
events). Local interruption (Ctrl-C or `cancel_event`) stops observation only;
|
|
287
|
+
`client.templates.cancel_build(build)` cancels remotely. Waited failures raise
|
|
288
|
+
`TemplateBuildFailedError` carrying the final redacted build; runtime waits
|
|
289
|
+
raise `TemplateRuntimeFailedError` without terminating the running sandbox.
|
|
290
|
+
Strict authored JSON import/export: `spec.to_json()` / `TemplateSpec.from_json(text)`.
|
|
291
|
+
Checkout `mode` uses `"contents"` or `"directory"`; runtime `runAt`,
|
|
292
|
+
`restartPolicy`, and `snapshotMode` use short values such as `"build"`,
|
|
293
|
+
`"on-failure"`, and `"memory"`. Full protobuf enum names remain accepted;
|
|
294
|
+
unknown fields and enum values are rejected.
|
|
295
|
+
See `examples/template_builder.py` for the full filesystem/memory workflow.
|
tenki-0.5.1/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# Tenki Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for Tenki: cloud sandboxes (microVMs) for AI agents and code execution.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install tenki
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
> The SDK is published as `tenki`. Existing code that does
|
|
10
|
+
> `import tenki_sandbox` keeps working (that module ships inside `tenki`), but
|
|
11
|
+
> new code should use `import tenki`.
|
|
12
|
+
|
|
13
|
+
## Protobuf compatibility
|
|
14
|
+
|
|
15
|
+
The SDK supports `protobuf>=5.29.5` with no upper bound. Its Python bindings are
|
|
16
|
+
temporarily generated with protobuf 5.29.5 so applications that require
|
|
17
|
+
`protobuf<6` can install the SDK alongside dependencies such as Memori.
|
|
18
|
+
|
|
19
|
+
Protobuf 6.31.x emits a benign warning when it loads 5.29.5-generated bindings.
|
|
20
|
+
If a test suite pins that runtime and promotes warnings to errors, scope the
|
|
21
|
+
exception narrowly instead of disabling protobuf's runtime checks:
|
|
22
|
+
|
|
23
|
+
```toml
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
filterwarnings = [
|
|
26
|
+
"ignore:Protobuf gencode version 5\\.29\\.5 is exactly one major version older than the runtime version 6\\.31\\..*:UserWarning:google\\.protobuf\\.runtime_version",
|
|
27
|
+
]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from tenki import Sandbox
|
|
32
|
+
|
|
33
|
+
# create() waits by default via a single server-held request and returns an
|
|
34
|
+
# exec-ready sandbox with data-plane access primed (wait=False to skip).
|
|
35
|
+
with Sandbox.create(cpu_cores=2, memory_mb=4096) as sb:
|
|
36
|
+
result = sb.exec("python3", "-c", "print('hello')")
|
|
37
|
+
result.check()
|
|
38
|
+
print(result.stdout_text)
|
|
39
|
+
|
|
40
|
+
# fs paths are relative to the sandbox workdir (absolute paths must stay inside it)
|
|
41
|
+
sb.fs.write_text("input.txt", "data")
|
|
42
|
+
print(sb.fs.read_text("input.txt"))
|
|
43
|
+
|
|
44
|
+
preview = sb.expose_port(3000, ttl=3600)
|
|
45
|
+
print(preview.url)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The API key determines the Workspace automatically; ordinary Sandbox calls do not require a Workspace ID.
|
|
49
|
+
|
|
50
|
+
## Async (asyncio)
|
|
51
|
+
|
|
52
|
+
`AsyncClient` / `AsyncSandbox` expose the same surface with native `async`/`await`,
|
|
53
|
+
built on `grpc.aio` — no `asyncio.to_thread` wrapping required. Use it inside any
|
|
54
|
+
asyncio server (FastAPI, aiohttp, etc.).
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import asyncio
|
|
58
|
+
from tenki import AsyncSandbox
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def main():
|
|
62
|
+
async with await AsyncSandbox.create(cpu_cores=2) as sb:
|
|
63
|
+
result = await sb.exec("python3", "-c", "print('hello')")
|
|
64
|
+
result.check()
|
|
65
|
+
print(result.stdout_text)
|
|
66
|
+
|
|
67
|
+
await sb.fs.write_text("input.txt", "data")
|
|
68
|
+
print(await sb.fs.read_text("input.txt"))
|
|
69
|
+
|
|
70
|
+
proc = await sb.start("bash", "-lc", "read name; echo hi $name")
|
|
71
|
+
await proc.write_stdin("tenki\n")
|
|
72
|
+
await proc.close_stdin()
|
|
73
|
+
async for chunk in proc.stdout:
|
|
74
|
+
print(chunk.decode(), end="")
|
|
75
|
+
(await proc.wait()).check()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
asyncio.run(main())
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The sync `Client` / `Sandbox` remain available for non-async consumers. SSH,
|
|
82
|
+
`dial`, and host-port tunnels are also async: `AsyncSandbox.ssh()` returns an
|
|
83
|
+
`AsyncSSHConn` (raw async `read`/`write`/`close`, matching the TS/Go SDKs; needs
|
|
84
|
+
the `tenki[async]` extra for `websockets`), `dial()` returns an
|
|
85
|
+
`AsyncDialConn`, and `expose_host_port()` / `expose_host_port_resilient()` return
|
|
86
|
+
async tunnels with `await tunnel.terminated.wait()` and `on_terminated` callbacks.
|
|
87
|
+
|
|
88
|
+
## Auth
|
|
89
|
+
|
|
90
|
+
Auth resolution:
|
|
91
|
+
|
|
92
|
+
1. `auth_token=` passed to `Client` or `Sandbox.create`
|
|
93
|
+
2. `TENKI_AUTH_TOKEN`
|
|
94
|
+
3. `TENKI_API_KEY`
|
|
95
|
+
|
|
96
|
+
`TENKI_API_ENDPOINT` overrides the API URL; legacy `TENKI_API_URL` is also accepted.
|
|
97
|
+
|
|
98
|
+
## Process API
|
|
99
|
+
|
|
100
|
+
`exec` collects stdout/stderr and returns a result:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
result = sb.exec("npm", "test", cwd="project", timeout=60, env={"CI": "1"})
|
|
104
|
+
print(result.stdout_text)
|
|
105
|
+
result.check()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`start` returns a live process:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
proc = sb.start("bash", "-lc", "read name; echo hello $name")
|
|
112
|
+
proc.write_stdin("tenki\n")
|
|
113
|
+
proc.close_stdin()
|
|
114
|
+
for chunk in proc.stdout:
|
|
115
|
+
print(chunk.decode(), end="")
|
|
116
|
+
proc.wait().check()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Use `shell()` when you want shell parsing:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
sb.shell("python3 -m http.server 3000 >/tmp/server.log 2>&1 &")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Process `cwd` values follow the guest contract: relative paths are normalized
|
|
126
|
+
under the sandbox guest workdir (`/home/tenki` by default), absolute paths are
|
|
127
|
+
used unchanged, and missing or non-directory targets fail before the process
|
|
128
|
+
starts.
|
|
129
|
+
|
|
130
|
+
## Sandbox lifetime
|
|
131
|
+
|
|
132
|
+
Long-lived sandboxes are a parameter choice at `create()`, not a separate API:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
sb = client.create(
|
|
136
|
+
sticky=True, # long-lived session: not reaped on idle
|
|
137
|
+
idle_timeout_minutes=120, # or: generous idle window before auto-pause
|
|
138
|
+
max_duration=8 * 3600, # total lifetime cap (seconds)
|
|
139
|
+
pause_retention=24 * 3600,# how long a paused session is kept resumable
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- `max_duration` caps total lifetime; `sb.extend(seconds)` pushes the deadline
|
|
144
|
+
(`sb.info.timeout_at`) while running.
|
|
145
|
+
- `idle_timeout_minutes` auto-pauses an idle sandbox; `sb.resume()` brings it
|
|
146
|
+
back with the filesystem intact.
|
|
147
|
+
- `sticky=True` opts the session out of idle reaping for keep-warm use cases
|
|
148
|
+
(workspaces cap concurrent sticky sessions).
|
|
149
|
+
- `client.list(sticky=True)` filters for long-lived sessions in the API key's Workspace.
|
|
150
|
+
|
|
151
|
+
## SSH
|
|
152
|
+
|
|
153
|
+
For tools that speak SSH (paramiko, scp, IDE remote dev), the SDK can mint a
|
|
154
|
+
short-lived OpenSSH user certificate for your session and open a transport to
|
|
155
|
+
the sandbox SSH gateway. No keys are provisioned into the guest; the engine
|
|
156
|
+
signs your local public key and the gateway verifies the certificate.
|
|
157
|
+
|
|
158
|
+
Requires `pip install websocket-client paramiko` (websocket-client for the
|
|
159
|
+
transport, paramiko if you want a client in-process).
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
import subprocess
|
|
163
|
+
import paramiko
|
|
164
|
+
|
|
165
|
+
# 1. local keypair (any OpenSSH key works; ed25519 shown)
|
|
166
|
+
subprocess.run(["ssh-keygen", "-t", "ed25519", "-N", "", "-q", "-f", "id_tenki"], check=True)
|
|
167
|
+
|
|
168
|
+
# 2. engine signs a short-lived user cert for this sandbox
|
|
169
|
+
cert = sb.issue_ssh_cert(open("id_tenki.pub").read(), ttl=600)
|
|
170
|
+
open("id_tenki-cert.pub", "w").write(cert.ssh_cert)
|
|
171
|
+
|
|
172
|
+
# 3. open the gateway transport and run a paramiko session over it
|
|
173
|
+
pkey = paramiko.Ed25519Key.from_private_key_file("id_tenki")
|
|
174
|
+
pkey.load_certificate("id_tenki-cert.pub")
|
|
175
|
+
|
|
176
|
+
transport = paramiko.Transport(sb.ssh()) # WebSocket-backed socket
|
|
177
|
+
transport.connect(username="tenki", pkey=pkey)
|
|
178
|
+
session = transport.open_session()
|
|
179
|
+
session.exec_command("echo hello-over-ssh")
|
|
180
|
+
print(session.makefile().read().decode())
|
|
181
|
+
transport.close()
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Notes:
|
|
185
|
+
|
|
186
|
+
- `sb.ssh()` / `client.ssh(session_id)` discover an active gateway and return
|
|
187
|
+
`SSHConn`, an `io.RawIOBase` socket usable anywhere paramiko accepts one.
|
|
188
|
+
- The SSH username is `tenki`.
|
|
189
|
+
- `TENKI_SANDBOX_GATEWAY_URL` overrides the gateway WebSocket URL (it is
|
|
190
|
+
otherwise derived from the API endpoint).
|
|
191
|
+
- Certificate RPCs use the Connect protocol over HTTPS (same as the Go SDK and
|
|
192
|
+
the `tenki` CLI), so they work through standard HTTP load balancers.
|
|
193
|
+
- `sb.update_ssh_authorized_keys([...])` additionally plants long-lived public
|
|
194
|
+
keys in the guest's `authorized_keys` if you prefer key-based auth for the
|
|
195
|
+
in-guest sshd.
|
|
196
|
+
|
|
197
|
+
## Resource APIs
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from tenki import Client, GiB
|
|
201
|
+
|
|
202
|
+
client = Client()
|
|
203
|
+
|
|
204
|
+
volume = client.volumes.create(
|
|
205
|
+
name="cache",
|
|
206
|
+
size_bytes=10 * GiB,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
preview = client.preview_urls.create(
|
|
210
|
+
slug="demo",
|
|
211
|
+
session_id=sb.id,
|
|
212
|
+
port=3000,
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Registry
|
|
217
|
+
|
|
218
|
+
Delete an untagged, non-latest, unshared registry version by image and snapshot
|
|
219
|
+
ID:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
result = client.registry.delete_version(
|
|
223
|
+
"11111111-1111-1111-1111-111111111111",
|
|
224
|
+
"55555555-5555-5555-5555-555555555555",
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Templates (typed builder)
|
|
229
|
+
|
|
230
|
+
`TemplateSpec` is an immutable typed recipe (every builder call returns a new
|
|
231
|
+
value). Builds freeze the normalized spec + `spec_hash` server-side and return
|
|
232
|
+
a private digest-addressed `Image`, the normal sandbox launch source.
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
from tenki import Client, TemplateSpec
|
|
236
|
+
|
|
237
|
+
client = Client()
|
|
238
|
+
|
|
239
|
+
spec = (
|
|
240
|
+
TemplateSpec() # defaults to base image "sandbox"
|
|
241
|
+
.from_image("sandbox-v2") # or .from_template(...) / .from_snapshot(...)
|
|
242
|
+
.with_git_context("https://github.com/acme/node-api", "main")
|
|
243
|
+
.run("npm ci", name="Install dependencies")
|
|
244
|
+
.runtime_env({"NODE_ENV": "production"})
|
|
245
|
+
.start(["npm", "start"], ready_when=[{"http": "http://localhost:3000/health"}])
|
|
246
|
+
)
|
|
247
|
+
spec.validate() # aggregate local validation; server stays authoritative
|
|
248
|
+
|
|
249
|
+
template = client.templates.create(name="node-api", spec=spec)
|
|
250
|
+
|
|
251
|
+
build = client.templates.build(
|
|
252
|
+
template, # resource objects in, IDs as fallback
|
|
253
|
+
build_secrets={"GITHUB_TOKEN": token}, # explicit request-time values
|
|
254
|
+
wait_for_completion=True,
|
|
255
|
+
on_event=lambda e: print(e), # one ordered log/progress handler
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
sb = client.create(image=build.image, wait_for_runtime=True)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`wait_build(build_id)` reconnects to an existing build (ordered, deduplicated
|
|
262
|
+
events). Local interruption (Ctrl-C or `cancel_event`) stops observation only;
|
|
263
|
+
`client.templates.cancel_build(build)` cancels remotely. Waited failures raise
|
|
264
|
+
`TemplateBuildFailedError` carrying the final redacted build; runtime waits
|
|
265
|
+
raise `TemplateRuntimeFailedError` without terminating the running sandbox.
|
|
266
|
+
Strict authored JSON import/export: `spec.to_json()` / `TemplateSpec.from_json(text)`.
|
|
267
|
+
Checkout `mode` uses `"contents"` or `"directory"`; runtime `runAt`,
|
|
268
|
+
`restartPolicy`, and `snapshotMode` use short values such as `"build"`,
|
|
269
|
+
`"on-failure"`, and `"memory"`. Full protobuf enum names remain accepted;
|
|
270
|
+
unknown fields and enum values are rejected.
|
|
271
|
+
See `examples/template_builder.py` for the full filesystem/memory workflow.
|