Skip to content

Commit d7b6168

Browse files
authored
feat(python): ship typed sync and async gRPC stubs (#5040)
Generate and package gRPC type stubs with concrete request and response types. Verify consumer typing, native async transport behavior, and wheel contents while preserving the existing synchronous APIs. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
1 parent 3771275 commit d7b6168

12 files changed

Lines changed: 310 additions & 41 deletions

‎docs/sdk/python.mdx‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,39 @@ 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+
## Call Generated RPCs
152+
153+
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:
154+
155+
```shell
156+
uv add --dev types-grpcio 'types-protobuf>=6.32,<7'
157+
```
158+
159+
Construct `OpenShellStub` with your channel. The channel determines whether calls return responses directly or awaitable gRPC calls:
160+
161+
```python
162+
import grpc.aio
163+
164+
from openshell._proto import datamodel_pb2 as dm
165+
from openshell._proto import openshell_pb2 as pb
166+
from openshell._proto.openshell_pb2_grpc import OpenShellStub
167+
168+
169+
async def get_provider(channel: grpc.aio.Channel, name: str) -> pb.ProviderResponse:
170+
stub = OpenShellStub(channel)
171+
return await stub.GetProvider(
172+
pb.GetProviderRequest(
173+
workspace_scope=dm.WorkspaceSelector(workspace="default"),
174+
name=name,
175+
),
176+
timeout=30,
177+
)
178+
```
179+
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.
181+
182+
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.
183+
151184
## Implement a Credential Driver
152185

153186
The SDK includes generated message classes, type stubs, and gRPC bindings for

‎e2e/mcp-conformance/host-bridge.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ def do_POST(self) -> None:
221221
timeout=TIMEOUT,
222222
)
223223
log(f"client exited {result.returncode} for {server_url}")
224-
body = {
224+
body: dict[str, object] = {
225225
"exit_code": result.returncode,
226226
"stdout": result.stdout,
227227
"stderr": result.stderr,

‎pyproject.toml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,14 @@ dev = [
4949
"pytest-cov>=4.0",
5050
"pytest-xdist>=3.0",
5151
"ruff>=0.4",
52-
"ty>=0.0.1a6",
52+
"ty>=0.0.94",
5353
"grpcio-tools>=1.60",
5454
"pyelftools>=0.30",
5555
"pyyaml==6.0.2",
5656
"types-pyyaml>=6.0.12.20260518",
57+
"mypy-protobuf>=5.1",
58+
"types-grpcio>=1.0.0.20251009",
59+
"types-protobuf>=6.32,<7",
5760
]
5861

5962
[tool.setuptools]

‎python/generate_python_proto_test.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def test_generate_package_relative_bindings(tmp_path: Path, no_pyi: bool) -> Non
3535
}
3636
suffixes = ["_pb2.py", "_pb2_grpc.py"]
3737
if not no_pyi:
38-
suffixes.append("_pb2.pyi")
38+
suffixes.extend(["_pb2.pyi", "_pb2_grpc.pyi"])
3939
assert {path.name for path in output.iterdir()} == {
4040
f"{stem}{suffix}" for stem in stems for suffix in suffixes
4141
}

‎python/grpc_transport_test.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Generated async stubs retain native gRPC transport behavior."""
5+
6+
import asyncio
7+
from collections.abc import AsyncGenerator
8+
from contextlib import asynccontextmanager
9+
10+
import grpc
11+
import grpc.aio
12+
import pytest
13+
14+
from openshell._proto import datamodel_pb2 as dm
15+
from openshell._proto import openshell_pb2 as pb
16+
from openshell._proto import openshell_pb2_grpc as rpc
17+
18+
19+
class Gateway(rpc.OpenShellServicer):
20+
def __init__(self) -> None:
21+
self.started = asyncio.Event()
22+
self.cancelled = asyncio.Event()
23+
24+
async def GetProvider(
25+
self, request: pb.GetProviderRequest, context: grpc.aio.ServicerContext
26+
) -> pb.ProviderResponse:
27+
assert ("authorization", "Bearer test-token") in (
28+
context.invocation_metadata() or ()
29+
)
30+
if request.name == "missing":
31+
await context.abort(grpc.StatusCode.NOT_FOUND, "No provider")
32+
if request.name == "blocked":
33+
self.started.set()
34+
try:
35+
await asyncio.Future()
36+
finally:
37+
self.cancelled.set()
38+
return pb.ProviderResponse(
39+
provider=dm.Provider(metadata=dm.ObjectMeta(name=request.name))
40+
)
41+
42+
43+
@asynccontextmanager
44+
async def gateway() -> AsyncGenerator[tuple[Gateway, grpc.aio.Channel]]:
45+
service = Gateway()
46+
server = grpc.aio.server()
47+
rpc.add_OpenShellServicer_to_server(service, server)
48+
port = server.add_insecure_port("127.0.0.1:0")
49+
await server.start()
50+
try:
51+
async with grpc.aio.insecure_channel(f"127.0.0.1:{port}") as channel:
52+
yield service, channel
53+
finally:
54+
await server.stop(0)
55+
56+
57+
_METADATA = (("authorization", "Bearer test-token"),)
58+
59+
60+
async def test_concurrent_calls_and_task_cancellation() -> None:
61+
async with gateway() as (service, channel):
62+
stub = rpc.OpenShellStub(channel)
63+
64+
async def blocked_call() -> pb.ProviderResponse:
65+
return await stub.GetProvider(
66+
pb.GetProviderRequest(name="blocked"), timeout=5, metadata=_METADATA
67+
)
68+
69+
pending = asyncio.create_task(blocked_call())
70+
try:
71+
await asyncio.wait_for(service.started.wait(), timeout=5)
72+
response = await stub.GetProvider(
73+
pb.GetProviderRequest(name="ready"), timeout=5, metadata=_METADATA
74+
)
75+
assert response.provider.metadata.name == "ready"
76+
finally:
77+
pending.cancel()
78+
with pytest.raises(asyncio.CancelledError):
79+
await pending
80+
await asyncio.wait_for(service.cancelled.wait(), timeout=5)
81+
response = await stub.GetProvider(
82+
pb.GetProviderRequest(name="still-ready"), timeout=5, metadata=_METADATA
83+
)
84+
assert response.provider.metadata.name == "still-ready"
85+
86+
87+
@pytest.mark.parametrize(
88+
("name", "expected"),
89+
[
90+
("missing", grpc.StatusCode.NOT_FOUND),
91+
("blocked", grpc.StatusCode.DEADLINE_EXCEEDED),
92+
],
93+
)
94+
async def test_status_and_deadline(name: str, expected: grpc.StatusCode) -> None:
95+
async with gateway() as (_, channel):
96+
stub = rpc.OpenShellStub(channel)
97+
with pytest.raises(grpc.aio.AioRpcError) as error:
98+
await stub.GetProvider(
99+
pb.GetProviderRequest(name=name), timeout=0.2, metadata=_METADATA
100+
)
101+
assert error.value.code() == expected

‎python/grpc_typing_test.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Check the consumer-facing types of generated sync and async RPCs."""
5+
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
13+
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
14+
def test_generated_rpc_types(tmp_path: Path, asynchronous: bool) -> None:
15+
channel = "grpc.aio.Channel" if asynchronous else "grpc.Channel"
16+
function = "async def" if asynchronous else "def"
17+
await_call = "await " if asynchronous else ""
18+
iterate = "async for" if asynchronous else "for"
19+
source = f"""
20+
from typing import assert_type
21+
import grpc
22+
import grpc.aio
23+
from openshell._proto import credential_driver_pb2 as credentials
24+
from openshell._proto import credential_driver_pb2_grpc as driver
25+
from openshell._proto import openshell_pb2 as pb
26+
from openshell._proto import openshell_pb2_grpc as rpc
27+
28+
{function} check(channel: {channel}) -> None:
29+
stub = rpc.OpenShellStub(channel)
30+
assert_type({await_call}stub.GetProvider(pb.GetProviderRequest()), pb.ProviderResponse)
31+
assert_type({await_call}stub.CreateProvider(pb.CreateProviderRequest()), pb.ProviderResponse)
32+
assert_type({await_call}stub.UpdateProvider(pb.UpdateProviderRequest()), pb.ProviderResponse)
33+
assert_type({await_call}stub.DeleteProvider(pb.DeleteProviderRequest()), pb.DeleteProviderResponse)
34+
assert_type({await_call}stub.ListProviders(pb.ListProvidersRequest()), pb.ListProvidersResponse)
35+
assert_type({await_call}stub.GetProviderProfile(pb.GetProviderProfileRequest()), pb.ProviderProfileResponse)
36+
assert_type({await_call}stub.GetSandbox(pb.GetSandboxRequest()), pb.SandboxResponse)
37+
assert_type({await_call}stub.DetachSandboxProvider(pb.DetachSandboxProviderRequest()), pb.DetachSandboxProviderResponse)
38+
{iterate} event in stub.ExecSandbox(pb.ExecSandboxRequest()):
39+
assert_type(event, pb.ExecSandboxEvent)
40+
{iterate} frame in stub.ForwardTcp(iter([pb.TcpForwardFrame()])):
41+
assert_type(frame, pb.TcpForwardFrame)
42+
credential_stub = driver.CredentialDriverStub(channel)
43+
assert_type({await_call}credential_stub.StoreCredential(credentials.StoreCredentialRequest()), credentials.StoreCredentialResponse)
44+
"""
45+
path = tmp_path / "consumer.py"
46+
path.write_text(source)
47+
result = _check_types(path)
48+
assert result.returncode == 0, result.stdout + result.stderr
49+
50+
# A missing import or Any-typed callable must not make this test pass.
51+
path.write_text(
52+
source.replace(
53+
"stub.GetProvider(pb.GetProviderRequest())",
54+
"stub.GetProvider(pb.DeleteProviderRequest())",
55+
)
56+
)
57+
result = _check_types(path)
58+
assert result.returncode != 0
59+
assert "invalid-argument-type" in result.stdout
60+
assert "GetProviderRequest" in result.stdout
61+
assert "DeleteProviderRequest" in result.stdout
62+
63+
64+
def _check_types(path: Path) -> subprocess.CompletedProcess[str]:
65+
return subprocess.run(
66+
[
67+
sys.executable,
68+
"-m",
69+
"ty",
70+
"check",
71+
"--python",
72+
sys.executable,
73+
"--output-format",
74+
"concise",
75+
str(path),
76+
],
77+
cwd=Path(__file__).resolve().parents[1],
78+
capture_output=True,
79+
text=True,
80+
check=False,
81+
)

‎python/openshell/sandbox_test.py‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing import Any, cast
1616

1717
import pytest
18+
from google.protobuf.struct_pb2 import Struct
1819

1920
import openshell.sandbox as sandbox_module
2021
from openshell._proto import openshell_pb2
@@ -427,14 +428,14 @@ def ExecSandbox(
427428

428429

429430
def _client_with_fake_stub(stub: object) -> SandboxClient:
430-
client = cast("SandboxClient", object.__new__(SandboxClient))
431+
client = object.__new__(SandboxClient)
431432
client._timeout = 30.0
432433
client._stub = cast("Any", stub)
433434
return client
434435

435436

436437
def _template_client_with_fake_stub(stub: object) -> SandboxTemplateClient:
437-
client = cast("SandboxTemplateClient", object.__new__(SandboxTemplateClient))
438+
client = object.__new__(SandboxTemplateClient)
438439
client._timeout = 30.0
439440
client._stub = cast("Any", stub)
440441
return client
@@ -2347,7 +2348,9 @@ def test_sandbox_template_create_builds_template_from_public_fields() -> None:
23472348
assert template.spec.workload.resources.cpu == "1"
23482349
assert template.spec.workload.resources.memory == "512Mi"
23492350
assert template.spec.workload.resources.gpu.count == 2
2350-
assert template.spec.driver_config["kubernetes"]["runtime_class_name"] == "kata"
2351+
config = template.spec.driver_config["kubernetes"]
2352+
assert isinstance(config, Struct)
2353+
assert config["runtime_class_name"] == "kata"
23512354

23522355

23532356
def test_sandbox_template_create_materializes_default_workload() -> None:
@@ -2380,7 +2383,9 @@ def test_sandbox_template_create_materializes_workload_with_driver_config_only()
23802383
assert template.HasField("spec")
23812384
assert template.spec.HasField("workload")
23822385
assert template.spec.workload.image == ""
2383-
assert template.spec.driver_config["kubernetes"]["runtime_class_name"] == "kata"
2386+
config = template.spec.driver_config["kubernetes"]
2387+
assert isinstance(config, Struct)
2388+
assert config["runtime_class_name"] == "kata"
23842389

23852390

23862391
def test_sandbox_template_create_rejects_missing_public_name() -> None:
@@ -2486,12 +2491,9 @@ def test_sandbox_template_client_crud_forwards_requests() -> None:
24862491
stub.create_template_request.template.spec.workload.image
24872492
== "ghcr.io/test/gpu-kata:latest"
24882493
)
2489-
assert (
2490-
stub.create_template_request.template.spec.driver_config["kubernetes"][
2491-
"runtime_class_name"
2492-
]
2493-
== "kata"
2494-
)
2494+
config = stub.create_template_request.template.spec.driver_config["kubernetes"]
2495+
assert isinstance(config, Struct)
2496+
assert config["runtime_class_name"] == "kata"
24952497

24962498
got = client.get("gpu-kata", workspace="default")
24972499
assert got.metadata.name == "gpu-kata"
@@ -2892,7 +2894,7 @@ def test_returned_labels_are_immutable() -> None:
28922894
ref = _sandbox_ref(proto)
28932895

28942896
with pytest.raises(TypeError):
2895-
ref.labels["mutated"] = "nope" # type: ignore[index]
2897+
ref.labels["mutated"] = "nope" # ty: ignore[invalid-assignment]
28962898

28972899

28982900
def test_direct_sandbox_ref_construction_defaults_labels() -> None:
@@ -2953,7 +2955,7 @@ def test_direct_sandbox_ref_copies_and_freezes_labels() -> None:
29532955

29542956
assert dict(ref.labels) == {"aiq": "deep-research"}
29552957
with pytest.raises(TypeError):
2956-
ref.labels["mutated"] = "nope" # type: ignore[index]
2958+
ref.labels["mutated"] = "nope" # ty: ignore[invalid-assignment]
29572959

29582960

29592961
def test_high_level_creation_forwards_name_and_labels(

‎python/wheel_verifier_test.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def _wheel_files() -> set[str]:
4343
files.add(f"openshell/_proto/{stem}_pb2.py")
4444
files.add(f"openshell/_proto/{stem}_pb2.pyi")
4545
files.add(f"openshell/_proto/{stem}_pb2_grpc.py")
46+
files.add(f"openshell/_proto/{stem}_pb2_grpc.pyi")
4647
return files
4748

4849

@@ -86,8 +87,10 @@ def test_requires_exactly_one_wheel_in_directory(tmp_path: Path) -> None:
8687
verifier.verify_wheel(tmp_path)
8788

8889

89-
@pytest.mark.parametrize("stem", ["sandbox", "credential_driver", "extension"])
90-
@pytest.mark.parametrize("suffix", ["_pb2.py", "_pb2.pyi", "_pb2_grpc.py"])
90+
@pytest.mark.parametrize("stem", verifier.PROTO_STEMS)
91+
@pytest.mark.parametrize(
92+
"suffix", ["_pb2.py", "_pb2.pyi", "_pb2_grpc.py", "_pb2_grpc.pyi"]
93+
)
9194
def test_rejects_missing_generated_proto(
9295
tmp_path: Path, stem: str, suffix: str
9396
) -> None:

‎tasks/scripts/generate_python_proto.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ def main() -> None:
3939
"grpc_tools.protoc",
4040
"-Iproto",
4141
"--python_out=python/openshell/_proto",
42-
*([] if args.no_pyi else ["--pyi_out=python/openshell/_proto"]),
42+
*(
43+
[]
44+
if args.no_pyi
45+
else [
46+
"--pyi_out=python/openshell/_proto",
47+
"--mypy_grpc_out=generate_concrete_servicer_stubs:python/openshell/_proto",
48+
]
49+
),
4350
"--grpc_python_out=python/openshell/_proto",
4451
*PROTO_FILES,
4552
],
@@ -52,7 +59,7 @@ def main() -> None:
5259
pattern = rf"^import ({modules})( as \w+)?$"
5360
suffixes = ["_pb2.py", "_pb2_grpc.py"]
5461
if not args.no_pyi:
55-
suffixes.append("_pb2.pyi")
62+
suffixes.extend(["_pb2.pyi", "_pb2_grpc.pyi"])
5663
for proto in PROTO_FILES:
5764
for suffix in suffixes:
5865
file_path = output_dir / f"{Path(proto).stem}{suffix}"

‎tasks/scripts/sync_docs_website.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ def ordered_entries(
354354
def render_versions(entries: list[VersionEntry]) -> list[YamlMapping]:
355355
rendered: list[YamlMapping] = []
356356
for entry in entries:
357-
item = {
357+
item: YamlMapping = {
358358
"display-name": entry.display_name,
359359
"path": entry.path,
360360
"slug": entry.slug,

0 commit comments

Comments
 (0)