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.
Files changed (89) hide show
  1. tenki-0.5.1/.github/workflows/pypi-publish.yml +64 -0
  2. tenki-0.5.1/.github/workflows/test.yml +65 -0
  3. tenki-0.5.1/PKG-INFO +295 -0
  4. tenki-0.5.1/README.md +271 -0
  5. tenki-0.5.1/examples/template_builder.py +128 -0
  6. tenki-0.5.1/pyproject.toml +56 -0
  7. tenki-0.5.1/scripts/patch_generated_imports.py +27 -0
  8. tenki-0.5.1/src/tenki/__init__.py +10 -0
  9. tenki-0.5.1/src/tenki/py.typed +0 -0
  10. tenki-0.5.1/src/tenki_sandbox/__init__.py +164 -0
  11. tenki-0.5.1/src/tenki_sandbox/_convert.py +555 -0
  12. tenki-0.5.1/src/tenki_sandbox/_internal/proto/__init__.py +0 -0
  13. tenki-0.5.1/src/tenki_sandbox/_internal/proto/buf/__init__.py +0 -0
  14. tenki-0.5.1/src/tenki_sandbox/_internal/proto/buf/validate/__init__.py +0 -0
  15. tenki-0.5.1/src/tenki_sandbox/_internal/proto/buf/validate/validate_pb2.py +450 -0
  16. tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/__init__.py +0 -0
  17. tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/__init__.py +0 -0
  18. tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/__init__.py +0 -0
  19. tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/registry_pb2.py +225 -0
  20. tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/registry_pb2_grpc.py +3 -0
  21. tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/sandbox_pb2.py +936 -0
  22. tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/sandbox_pb2_grpc.py +3908 -0
  23. tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/ssh_gateway_client_pb2.py +58 -0
  24. tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/ssh_gateway_client_pb2_grpc.py +136 -0
  25. tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/template_pb2.py +402 -0
  26. tenki-0.5.1/src/tenki_sandbox/_internal/proto/tenki/sandbox/v1/template_pb2_grpc.py +3 -0
  27. tenki-0.5.1/src/tenki_sandbox/_pagination.py +8 -0
  28. tenki-0.5.1/src/tenki_sandbox/_pb.py +6 -0
  29. tenki-0.5.1/src/tenki_sandbox/_resource_validation.py +70 -0
  30. tenki-0.5.1/src/tenki_sandbox/_time.py +52 -0
  31. tenki-0.5.1/src/tenki_sandbox/_transport.py +367 -0
  32. tenki-0.5.1/src/tenki_sandbox/aio/__init__.py +35 -0
  33. tenki-0.5.1/src/tenki_sandbox/aio/_transport.py +125 -0
  34. tenki-0.5.1/src/tenki_sandbox/aio/client.py +241 -0
  35. tenki-0.5.1/src/tenki_sandbox/aio/data_plane.py +269 -0
  36. tenki-0.5.1/src/tenki_sandbox/aio/dial.py +150 -0
  37. tenki-0.5.1/src/tenki_sandbox/aio/fs.py +166 -0
  38. tenki-0.5.1/src/tenki_sandbox/aio/git.py +72 -0
  39. tenki-0.5.1/src/tenki_sandbox/aio/preview_url.py +85 -0
  40. tenki-0.5.1/src/tenki_sandbox/aio/process.py +233 -0
  41. tenki-0.5.1/src/tenki_sandbox/aio/registry.py +193 -0
  42. tenki-0.5.1/src/tenki_sandbox/aio/resources.py +556 -0
  43. tenki-0.5.1/src/tenki_sandbox/aio/sandbox.py +556 -0
  44. tenki-0.5.1/src/tenki_sandbox/aio/ssh.py +172 -0
  45. tenki-0.5.1/src/tenki_sandbox/aio/tunnel.py +444 -0
  46. tenki-0.5.1/src/tenki_sandbox/client.py +373 -0
  47. tenki-0.5.1/src/tenki_sandbox/constants.py +23 -0
  48. tenki-0.5.1/src/tenki_sandbox/data_plane.py +246 -0
  49. tenki-0.5.1/src/tenki_sandbox/dial.py +121 -0
  50. tenki-0.5.1/src/tenki_sandbox/errors.py +406 -0
  51. tenki-0.5.1/src/tenki_sandbox/fs.py +138 -0
  52. tenki-0.5.1/src/tenki_sandbox/git.py +69 -0
  53. tenki-0.5.1/src/tenki_sandbox/models.py +412 -0
  54. tenki-0.5.1/src/tenki_sandbox/preview_url.py +85 -0
  55. tenki-0.5.1/src/tenki_sandbox/process.py +266 -0
  56. tenki-0.5.1/src/tenki_sandbox/py.typed +1 -0
  57. tenki-0.5.1/src/tenki_sandbox/registry.py +212 -0
  58. tenki-0.5.1/src/tenki_sandbox/resources.py +685 -0
  59. tenki-0.5.1/src/tenki_sandbox/sandbox.py +567 -0
  60. tenki-0.5.1/src/tenki_sandbox/ssh.py +214 -0
  61. tenki-0.5.1/src/tenki_sandbox/template_spec.py +1005 -0
  62. tenki-0.5.1/src/tenki_sandbox/tunnel.py +508 -0
  63. tenki-0.5.1/tests/_async_fakes.py +175 -0
  64. tenki-0.5.1/tests/test_async_client.py +233 -0
  65. tenki-0.5.1/tests/test_async_conn.py +371 -0
  66. tenki-0.5.1/tests/test_async_parity.py +83 -0
  67. tenki-0.5.1/tests/test_async_process_fs.py +328 -0
  68. tenki-0.5.1/tests/test_async_resources.py +89 -0
  69. tenki-0.5.1/tests/test_async_transport.py +260 -0
  70. tenki-0.5.1/tests/test_client.py +512 -0
  71. tenki-0.5.1/tests/test_connect_transport.py +142 -0
  72. tenki-0.5.1/tests/test_data_plane.py +63 -0
  73. tenki-0.5.1/tests/test_dial.py +362 -0
  74. tenki-0.5.1/tests/test_pagination.py +10 -0
  75. tenki-0.5.1/tests/test_process_fs_preview.py +445 -0
  76. tenki-0.5.1/tests/test_protobuf_compatibility.py +75 -0
  77. tenki-0.5.1/tests/test_registry.py +512 -0
  78. tenki-0.5.1/tests/test_resources_errors.py +348 -0
  79. tenki-0.5.1/tests/test_sandbox_update.py +124 -0
  80. tenki-0.5.1/tests/test_snapshots_extras.py +295 -0
  81. tenki-0.5.1/tests/test_ssh.py +543 -0
  82. tenki-0.5.1/tests/test_template_builder_example.py +102 -0
  83. tenki-0.5.1/tests/test_template_spec.py +400 -0
  84. tenki-0.5.1/tests/test_template_workflow_e2e.py +217 -0
  85. tenki-0.5.1/tests/test_templates_typed.py +491 -0
  86. tenki-0.5.1/tests/test_tunnel.py +580 -0
  87. tenki-0.5.1/tests/test_volumes_wait.py +61 -0
  88. tenki-0.5.1/tests/test_who_am_i.py +105 -0
  89. 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.