Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ cython_debug/
# Generated Python protobuf stubs (keep package marker)
python/openshell/_proto/*
!python/openshell/_proto/__init__.py
python/openshell/aio.py

# =============================================================================
# IDE / Editor
Expand Down
71 changes: 70 additions & 1 deletion docs/sdk/python.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,75 @@ workspace for repeated exec, stop, start, and delete operations. List methods
return lazy `Pager` instances; use `list_all()` only when you want to fetch the
complete collection.

## Use the Async Client

`AsyncSandboxClient` provides the same sandbox operations and arguments as
`SandboxClient` over `grpc.aio`. Construct and use it on one event loop:

```python
from openshell import AsyncSandboxClient

async def run():
async with AsyncSandboxClient.from_active_cluster() as client:
sandbox = await client.get("worker", workspace="default")
result = await client.exec(sandbox.name, ["echo", "hello"], workspace="default")
print(result.stdout)

async for page in client.list(workspace="default"):
for item in page.items:
print(item.name)
```

List methods return lazy `AsyncPager` objects without an `await`. Use
`await client.list_all(...)` to collect every page. The provider, sandbox-template,
and workspace clients also have `Async` counterparts. Authentication settings
match the synchronous client; credential refresh runs off the event loop.

Both sandbox clients accept a caller-configured channel through `from_channel()`.
That factory borrows the channel: closing the client leaves it open. Clients
constructed from an endpoint or registered gateway own and close their channels.

RPC methods accept `timeout` in seconds to override the client default. This is
separate from an exec command's `timeout_seconds`. Mutations that support gateway
deduplication accept `request_id`; reuse the same ID and payload when explicitly
retrying an uncertain result. Request IDs do not enable general automatic retries. Exec retains the gateway
owner-route fallback when the route fails before the first response; it never
replays a command after receiving output.

Async task cancellation propagates to the active RPC. For early exit from
`exec_stream()`, use `contextlib.aclosing()` so the stream is cancelled promptly.
Gateway RPC failures raise `GatewayError`, preserving the gRPC status and details.

## Manage Services and Read Logs

Both sandbox clients expose service creation, lookup, pagination, and deletion.
Use `get_logs()` for structured gateway and supervisor logs:

```python
from openshell import SandboxClient

with SandboxClient.from_active_cluster() as client:
session = client.get_session("worker", workspace="default")
endpoint = session.expose_service(name="api", target_port=8000)
print(endpoint.url)
for page in session.list_services():
for service in page.items:
print(service.endpoint.name, service.url)
logs = session.get_logs(lines=100, sources=["sandbox"])
for entry in logs.logs:
print(entry.message, dict(entry.fields))
session.delete_service(name="api", allow_missing=True)
```

`get_logs()` preserves `buffer_total`, the unfiltered count of buffered entries.
Its optional `since` argument takes a timezone-aware `datetime`. These logs do
not include arbitrary workload output written to files; use exec to read those
files. Service and log results retain their complete generated protobuf fields.

Import supported protobuf-backed inputs and results from `openshell.types`,
including `SandboxSpec`, `SandboxTemplate`, `SandboxPolicy`, `SandboxPhase`,
`ServiceEndpointResponse`, and `GetSandboxLogsResponse`.

## Call Generated RPCs

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:
Expand Down Expand Up @@ -177,7 +246,7 @@ async def get_provider(channel: grpc.aio.Channel, name: str) -> pb.ProviderRespo
)
```

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.
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.

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.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--mat
[tool.ruff]
target-version = "py311"
line-length = 88
exclude = ["python/openshell/_proto"]
exclude = ["python/openshell/_proto", "python/openshell/aio.py"]

[tool.ruff.lint]
select = [
Expand Down
18 changes: 18 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Python SDK implementation

The synchronous clients in `openshell/sandbox.py` and `openshell/providers.py`
own request construction and response conversion. `tasks/scripts/generate_python_async.py`
emits concrete async counterparts into `openshell/aio.py`; the handwritten
`openshell/_async_runtime.py` owns asyncio transport, authentication, pagination,
and resource cleanup. Generated files are build outputs and are not committed.

Run `mise run python:proto` after changing client methods or protobufs. The same
generation runs when building release wheels. The async transform recognizes
stub calls, lazy pager fetches, stream iteration, and polling sleeps. Add new I/O
shapes explicitly to the generator and test their runtime and consumer typing
contracts. Keep blocking operations out of generated async method bodies.

Validate changes with Python formatting, lint, and type checks plus the affected
tests. `python/openshell/async_client_test.py` exercises real loopback gRPC calls;
`python/grpc_typing_test.py` checks consumer signatures. The wheel verifier requires
both generated bindings and the async client module.
23 changes: 23 additions & 0 deletions python/grpc_typing_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,26 @@ def _check_types(path: Path) -> subprocess.CompletedProcess[str]:
text=True,
check=False,
)


def test_async_sdk_consumer_types(tmp_path: Path) -> None:
source = """
from typing import assert_type
from openshell import AsyncSandboxClient, AsyncPager, SandboxRef, ExecResult, DeletionResult

async def check(client: AsyncSandboxClient) -> None:
assert_type(await client.get("worker", workspace="team"), SandboxRef)
assert_type(await client.create(workspace="team"), SandboxRef)
assert_type(client.list(workspace="team"), AsyncPager[SandboxRef])
assert_type(await client.list_all(workspace="team"), list[SandboxRef])
assert_type(await client.exec("worker", ["echo"], workspace="team"), ExecResult)
assert_type(await client.delete("worker", workspace="team"), DeletionResult)
"""
path = tmp_path / "sdk_consumer.py"
path.write_text(source)
result = _check_types(path)
assert result.returncode == 0, result.stdout + result.stderr
path.write_text(source.replace('workspace="team"', "workspace=123"))
result = _check_types(path)
assert result.returncode != 0
assert "invalid-argument-type" in result.stdout
14 changes: 14 additions & 0 deletions python/openshell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@

from __future__ import annotations

from ._async_runtime import AsyncPager
from .aio import (
AsyncProviderClient,
AsyncSandboxClient,
AsyncSandboxSession,
AsyncSandboxTemplateClient,
AsyncWorkspaceClient,
)
from .errors import ErrorInfo, FieldViolation, GatewayError, from_grpc_error
from .mutations import DeletionOutcome, DeletionResult
from .providers import ProviderClient, ProviderCredentialValue
Expand Down Expand Up @@ -38,6 +46,12 @@
__version__ = "0.0.0"

__all__ = [
"AsyncPager",
"AsyncProviderClient",
"AsyncSandboxClient",
"AsyncSandboxSession",
"AsyncSandboxTemplateClient",
"AsyncWorkspaceClient",
"ClientCredentialsAuth",
"DeletionOutcome",
"DeletionResult",
Expand Down
Loading
Loading