Skip to content

Commit 6aae4ae

Browse files
committed
feat(python): add native async sandbox clients
Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
1 parent 8d0797a commit 6aae4ae

15 files changed

Lines changed: 1160 additions & 75 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ cython_debug/
107107
# Generated Python protobuf stubs (keep package marker)
108108
python/openshell/_proto/*
109109
!python/openshell/_proto/__init__.py
110+
python/openshell/aio.py
110111

111112
# =============================================================================
112113
# IDE / Editor

‎docs/sdk/python.mdx‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,45 @@ workspace for repeated exec, stop, start, and delete operations. List methods
148148
return lazy `Pager` instances; use `list_all()` only when you want to fetch the
149149
complete collection.
150150

151+
## Use the Async Client
152+
153+
`AsyncSandboxClient` provides the same sandbox operations and arguments as
154+
`SandboxClient` over `grpc.aio`. Construct and use it on one event loop:
155+
156+
```python
157+
from openshell import AsyncSandboxClient
158+
159+
async def run():
160+
async with AsyncSandboxClient.from_active_cluster() as client:
161+
sandbox = await client.get("worker", workspace="default")
162+
result = await client.exec(sandbox.name, ["echo", "hello"], workspace="default")
163+
print(result.stdout)
164+
165+
async for page in client.list(workspace="default"):
166+
for item in page.items:
167+
print(item.name)
168+
```
169+
170+
List methods return lazy `AsyncPager` objects without an `await`. Use
171+
`await client.list_all(...)` to collect every page. The provider, sandbox-template,
172+
and workspace clients also have `Async` counterparts. Authentication settings
173+
match the synchronous client; credential refresh runs off the event loop.
174+
175+
Both sandbox clients accept a caller-configured channel through `from_channel()`.
176+
That factory borrows the channel: closing the client leaves it open. Clients
177+
constructed from an endpoint or registered gateway own and close their channels.
178+
179+
RPC methods accept `timeout` in seconds to override the client default. This is
180+
separate from an exec command's `timeout_seconds`. Mutations that support gateway
181+
deduplication accept `request_id`; reuse the same ID and payload when explicitly
182+
retrying an uncertain result. Request IDs do not enable general automatic retries. Exec retains the gateway
183+
owner-route fallback when the route fails before the first response; it never
184+
replays a command after receiving output.
185+
186+
Async task cancellation propagates to the active RPC. For early exit from
187+
`exec_stream()`, use `contextlib.aclosing()` so the stream is cancelled promptly.
188+
Gateway RPC failures raise `GatewayError`, preserving the gRPC status and details.
189+
151190
## Call Generated RPCs
152191

153192
The generated gRPC stubs include concrete request and response types for synchronous `grpc.Channel` and asynchronous `grpc.aio.Channel` connections. Install the dependency type stubs in your development environment for editor signatures and type checking:
@@ -177,7 +216,7 @@ async def get_provider(channel: grpc.aio.Channel, name: str) -> pb.ProviderRespo
177216
)
178217
```
179218

180-
Your application configures the channel's TLS and authentication, supplies RPC deadlines, and closes the channel. Create and use async channels on the same event loop. Cancellation and gRPC errors propagate directly. Generated stubs do not reuse `SandboxClient` authentication automatically; `SandboxClient` and `ProviderClient` remain synchronous.
219+
Your application configures the channel's TLS and authentication, supplies RPC deadlines, and closes the channel. Create and use async channels on the same event loop. Cancellation and gRPC errors propagate directly. Generated stubs do not reuse SDK client authentication automatically. Use `AsyncSandboxClient` for the higher-level async methods and managed authentication.
181220

182221
If you annotate a stored async stub explicitly, use a deferred annotation such as `"openshell_pb2_grpc.OpenShellAsyncStub"`. That type exists only in the `.pyi` file; construct `OpenShellStub` at runtime. The same convention applies to credential-driver stubs.
183222

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--mat
7878
[tool.ruff]
7979
target-version = "py311"
8080
line-length = 88
81-
exclude = ["python/openshell/_proto"]
81+
exclude = ["python/openshell/_proto", "python/openshell/aio.py"]
8282

8383
[tool.ruff.lint]
8484
select = [

‎python/README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Python SDK implementation
2+
3+
The synchronous clients in `openshell/sandbox.py` and `openshell/providers.py`
4+
own request construction and response conversion. `tasks/scripts/generate_python_async.py`
5+
emits concrete async counterparts into `openshell/aio.py`; the handwritten
6+
`openshell/_async_runtime.py` owns asyncio transport, authentication, pagination,
7+
and resource cleanup. Generated files are build outputs and are not committed.
8+
9+
Run `mise run python:proto` after changing client methods or protobufs. The same
10+
generation runs when building release wheels. The async transform recognizes
11+
stub calls, lazy pager fetches, stream iteration, and polling sleeps. Add new I/O
12+
shapes explicitly to the generator and test their runtime and consumer typing
13+
contracts. Keep blocking operations out of generated async method bodies.
14+
15+
Validate changes with Python formatting, lint, and type checks plus the affected
16+
tests. `python/openshell/async_client_test.py` exercises real loopback gRPC calls;
17+
`python/grpc_typing_test.py` checks consumer signatures. The wheel verifier requires
18+
both generated bindings and the async client module.

‎python/grpc_typing_test.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,26 @@ def _check_types(path: Path) -> subprocess.CompletedProcess[str]:
7979
text=True,
8080
check=False,
8181
)
82+
83+
84+
def test_async_sdk_consumer_types(tmp_path: Path) -> None:
85+
source = """
86+
from typing import assert_type
87+
from openshell import AsyncSandboxClient, AsyncPager, SandboxRef, ExecResult, DeletionResult
88+
89+
async def check(client: AsyncSandboxClient) -> None:
90+
assert_type(await client.get("worker", workspace="team"), SandboxRef)
91+
assert_type(await client.create(workspace="team"), SandboxRef)
92+
assert_type(client.list(workspace="team"), AsyncPager[SandboxRef])
93+
assert_type(await client.list_all(workspace="team"), list[SandboxRef])
94+
assert_type(await client.exec("worker", ["echo"], workspace="team"), ExecResult)
95+
assert_type(await client.delete("worker", workspace="team"), DeletionResult)
96+
"""
97+
path = tmp_path / "sdk_consumer.py"
98+
path.write_text(source)
99+
result = _check_types(path)
100+
assert result.returncode == 0, result.stdout + result.stderr
101+
path.write_text(source.replace('workspace="team"', "workspace=123"))
102+
result = _check_types(path)
103+
assert result.returncode != 0
104+
assert "invalid-argument-type" in result.stdout

‎python/openshell/__init__.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55

66
from __future__ import annotations
77

8+
from ._async_runtime import AsyncPager
9+
from .aio import (
10+
AsyncProviderClient,
11+
AsyncSandboxClient,
12+
AsyncSandboxSession,
13+
AsyncSandboxTemplateClient,
14+
AsyncWorkspaceClient,
15+
)
816
from .errors import ErrorInfo, FieldViolation, GatewayError, from_grpc_error
917
from .mutations import DeletionOutcome, DeletionResult
1018
from .providers import ProviderClient, ProviderCredentialValue
@@ -38,6 +46,12 @@
3846
__version__ = "0.0.0"
3947

4048
__all__ = [
49+
"AsyncPager",
50+
"AsyncProviderClient",
51+
"AsyncSandboxClient",
52+
"AsyncSandboxSession",
53+
"AsyncSandboxTemplateClient",
54+
"AsyncWorkspaceClient",
4155
"ClientCredentialsAuth",
4256
"DeletionOutcome",
4357
"DeletionResult",

0 commit comments

Comments
 (0)