Skip to content

Commit a2b8022

Browse files
authored
fix(python): ship credential driver protocol bindings (#4760)
Closes #4759 Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
1 parent 465324a commit a2b8022

6 files changed

Lines changed: 173 additions & 67 deletions

File tree

‎docs/sdk/python.mdx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,23 @@ 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+
## Implement a Credential Driver
152+
153+
The SDK includes generated message classes, type stubs, and gRPC bindings for
154+
the credential-driver and extension protocols:
155+
156+
```python
157+
from openshell._proto import credential_driver_pb2, credential_driver_pb2_grpc
158+
from openshell._proto import datamodel_pb2, extension_pb2
159+
```
160+
161+
Subclass `credential_driver_pb2_grpc.CredentialDriverServicer` and register your
162+
implementation with `add_CredentialDriverServicer_to_server()` from the same
163+
module. Clients use `CredentialDriverStub`. The generated messages share
164+
`datamodel_pb2.CredentialHandle` and `extension_pb2.PeerMetadata` with the SDK.
165+
Use bindings from the same OpenShell release as your gateway; these modules
166+
expose the internal credential-driver contract.
167+
151168
## Next Steps
152169

153170
- Review [Manage Sandboxes](/how-it-works/sandboxes/overview) for labels, templates, services, and Python SDK examples.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Credential-driver bindings must share SDK datamodel types and dispatch RPCs."""
5+
6+
import grpc.aio
7+
import pytest
8+
9+
from openshell._proto import credential_driver_pb2 as credentials
10+
from openshell._proto import credential_driver_pb2_grpc as driver_grpc
11+
from openshell._proto import datamodel_pb2 as datamodel
12+
from openshell._proto import extension_pb2 as extension
13+
from openshell._proto import openshell_pb2 as gateway
14+
15+
16+
def test_driver_messages_share_sdk_descriptors() -> None:
17+
request = credentials.ResolveCredentialRequest(
18+
handle=datamodel.CredentialHandle(driver="test", handle="opaque"),
19+
)
20+
assert isinstance(request.handle, datamodel.CredentialHandle)
21+
assert request.handle.DESCRIPTOR is datamodel.CredentialHandle.DESCRIPTOR
22+
assert (
23+
credentials.GetCredentialDriverCapabilitiesRequest(
24+
gateway=extension.PeerMetadata()
25+
).gateway.DESCRIPTOR
26+
is extension.PeerMetadata.DESCRIPTOR
27+
)
28+
assert (
29+
gateway.CreateProviderRequest().provider.DESCRIPTOR
30+
is datamodel.Provider.DESCRIPTOR
31+
)
32+
33+
34+
@pytest.mark.asyncio
35+
async def test_generated_servicer_and_stub_exchange() -> None:
36+
class Driver(driver_grpc.CredentialDriverServicer):
37+
async def StoreCredential(self, request, context): # noqa: ARG002
38+
assert request.value == "registration-ticket"
39+
return credentials.StoreCredentialResponse(
40+
handle=datamodel.CredentialHandle(driver="test", handle="opaque")
41+
)
42+
43+
server = grpc.aio.server()
44+
driver_grpc.add_CredentialDriverServicer_to_server(Driver(), server)
45+
address = "127.0.0.1"
46+
port = server.add_insecure_port(f"{address}:0")
47+
await server.start()
48+
try:
49+
async with grpc.aio.insecure_channel(f"{address}:{port}") as channel:
50+
stub = driver_grpc.CredentialDriverStub(channel)
51+
result = await stub.StoreCredential(
52+
credentials.StoreCredentialRequest(value="registration-ticket"),
53+
timeout=5,
54+
)
55+
assert isinstance(result.handle, datamodel.CredentialHandle)
56+
assert result.handle.handle == "opaque"
57+
finally:
58+
await server.stop(0)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Exercise generation in a clean tree so stale bindings cannot hide failures."""
5+
6+
import ast
7+
import shutil
8+
import subprocess
9+
import sys
10+
from pathlib import Path
11+
12+
import pytest
13+
14+
15+
@pytest.mark.parametrize("no_pyi", [False, True])
16+
def test_generate_package_relative_bindings(tmp_path: Path, no_pyi: bool) -> None:
17+
root = Path(__file__).resolve().parents[1]
18+
shutil.copytree(root / "proto", tmp_path / "proto")
19+
output = tmp_path / "python/openshell/_proto"
20+
output.mkdir(parents=True)
21+
command = [
22+
sys.executable,
23+
str(root / "tasks/scripts/generate_python_proto.py"),
24+
*(["--no-pyi"] if no_pyi else []),
25+
]
26+
subprocess.run(command, cwd=tmp_path, check=True, capture_output=True, text=True)
27+
28+
stems = {
29+
"credential_driver",
30+
"datamodel",
31+
"extension",
32+
"openshell",
33+
"options",
34+
"sandbox",
35+
}
36+
suffixes = ["_pb2.py", "_pb2_grpc.py"]
37+
if not no_pyi:
38+
suffixes.append("_pb2.pyi")
39+
assert {path.name for path in output.iterdir()} == {
40+
f"{stem}{suffix}" for stem in stems for suffix in suffixes
41+
}
42+
for path in output.iterdir():
43+
for node in ast.walk(ast.parse(path.read_text())):
44+
if isinstance(node, ast.Import):
45+
assert not any(alias.name.endswith("_pb2") for alias in node.names), (
46+
f"Absolute generated import in {path.name}"
47+
)
48+
49+
# Skipping type generation must also leave existing stubs untouched.
50+
sentinel = output / "credential_driver_pb2.pyi"
51+
sentinel.write_text("# existing type stub\n")
52+
subprocess.run(
53+
command if no_pyi else [*command, "--no-pyi"],
54+
cwd=tmp_path,
55+
check=True,
56+
capture_output=True,
57+
text=True,
58+
)
59+
assert sentinel.read_text() == "# existing type stub\n"

‎python/wheel_verifier_test.py‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@ def _wheel_files() -> set[str]:
3232
"openshell/py.typed",
3333
"openshell/_proto/__init__.py",
3434
}
35-
for stem in ("datamodel", "openshell", "options", "sandbox"):
35+
for stem in (
36+
"credential_driver",
37+
"datamodel",
38+
"extension",
39+
"openshell",
40+
"options",
41+
"sandbox",
42+
):
3643
files.add(f"openshell/_proto/{stem}_pb2.py")
3744
files.add(f"openshell/_proto/{stem}_pb2.pyi")
3845
files.add(f"openshell/_proto/{stem}_pb2_grpc.py")
@@ -79,11 +86,15 @@ def test_requires_exactly_one_wheel_in_directory(tmp_path: Path) -> None:
7986
verifier.verify_wheel(tmp_path)
8087

8188

82-
def test_rejects_missing_generated_proto(tmp_path: Path) -> None:
83-
files = _wheel_files() - {"openshell/_proto/sandbox_pb2.py"}
89+
@pytest.mark.parametrize("stem", ["sandbox", "credential_driver", "extension"])
90+
@pytest.mark.parametrize("suffix", ["_pb2.py", "_pb2.pyi", "_pb2_grpc.py"])
91+
def test_rejects_missing_generated_proto(
92+
tmp_path: Path, stem: str, suffix: str
93+
) -> None:
94+
files = _wheel_files() - {f"openshell/_proto/{stem}{suffix}"}
8495
wheel = _write_wheel(tmp_path, files=files)
8596

86-
with pytest.raises(ValueError, match=r"sandbox_pb2\.py"):
97+
with pytest.raises(ValueError, match="wheel is missing required files"):
8798
verifier.verify_wheel(wheel)
8899

89100

‎tasks/scripts/generate_python_proto.py‎

Lines changed: 16 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -14,63 +14,10 @@
1414
"proto/datamodel.proto",
1515
"proto/options.proto",
1616
"proto/sandbox.proto",
17+
"proto/extension.proto",
18+
"proto/credential_driver.proto",
1719
]
1820

19-
LINE_REWRITES = {
20-
"python/openshell/_proto/openshell_pb2_grpc.py": [
21-
(
22-
r"^import openshell_pb2 as openshell__pb2$",
23-
"from . import openshell_pb2 as openshell__pb2",
24-
),
25-
(
26-
r"^import sandbox_pb2 as sandbox__pb2$",
27-
"from . import sandbox_pb2 as sandbox__pb2",
28-
),
29-
],
30-
"python/openshell/_proto/openshell_pb2.py": [
31-
(
32-
r"^import datamodel_pb2 as datamodel__pb2$",
33-
"from . import datamodel_pb2 as datamodel__pb2",
34-
),
35-
(
36-
r"^import options_pb2 as options__pb2$",
37-
"from . import options_pb2 as options__pb2",
38-
),
39-
(
40-
r"^import sandbox_pb2 as sandbox__pb2$",
41-
"from . import sandbox_pb2 as sandbox__pb2",
42-
),
43-
],
44-
"python/openshell/_proto/datamodel_pb2.py": [
45-
(
46-
r"^import options_pb2 as options__pb2$",
47-
"from . import options_pb2 as options__pb2",
48-
),
49-
(
50-
r"^import sandbox_pb2 as sandbox__pb2$",
51-
"from . import sandbox_pb2 as sandbox__pb2",
52-
),
53-
],
54-
"python/openshell/_proto/datamodel_pb2_grpc.py": [
55-
(
56-
r"^import datamodel_pb2 as datamodel__pb2$",
57-
"from . import datamodel_pb2 as datamodel__pb2",
58-
),
59-
],
60-
"python/openshell/_proto/sandbox_pb2.py": [
61-
(
62-
r"^import datamodel_pb2 as datamodel__pb2$",
63-
"from . import datamodel_pb2 as datamodel__pb2",
64-
),
65-
],
66-
"python/openshell/_proto/sandbox_pb2_grpc.py": [
67-
(
68-
r"^import sandbox_pb2 as sandbox__pb2$",
69-
"from . import sandbox_pb2 as sandbox__pb2",
70-
),
71-
],
72-
}
73-
7421

7522
def main() -> None:
7623
parser = argparse.ArgumentParser(description=__doc__)
@@ -83,6 +30,7 @@ def main() -> None:
8330
),
8431
)
8532
args = parser.parse_args()
33+
output_dir = Path("python/openshell/_proto")
8634

8735
subprocess.run(
8836
[
@@ -98,13 +46,19 @@ def main() -> None:
9846
check=True,
9947
)
10048

101-
for path, rules in LINE_REWRITES.items():
102-
file_path = Path(path)
103-
text = file_path.read_text()
104-
text = text.replace("from . from . import", "from . import")
105-
for pattern, replacement in rules:
106-
text = re.sub(pattern, replacement, text, flags=re.MULTILINE)
107-
file_path.write_text(text)
49+
# Rewrite only imports of our generated modules, leaving google.protobuf
50+
# imports intact. The same rule covers runtime modules and type stubs.
51+
modules = "|".join(re.escape(f"{Path(path).stem}_pb2") for path in PROTO_FILES)
52+
pattern = rf"^import ({modules})( as \w+)?$"
53+
suffixes = ["_pb2.py", "_pb2_grpc.py"]
54+
if not args.no_pyi:
55+
suffixes.append("_pb2.pyi")
56+
for proto in PROTO_FILES:
57+
for suffix in suffixes:
58+
file_path = output_dir / f"{Path(proto).stem}{suffix}"
59+
text = file_path.read_text()
60+
text = re.sub(pattern, r"from . import \1\2", text, flags=re.MULTILINE)
61+
file_path.write_text(text)
10862

10963

11064
if __name__ == "__main__":

‎tasks/scripts/verify-python-wheel.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@
1313
from pathlib import Path
1414
from zipfile import BadZipFile, ZipFile
1515

16-
PROTO_STEMS = ("datamodel", "openshell", "options", "sandbox")
16+
PROTO_STEMS = (
17+
"credential_driver",
18+
"datamodel",
19+
"extension",
20+
"openshell",
21+
"options",
22+
"sandbox",
23+
)
1724
BYTECODE_SUFFIXES = (".pyc", ".pyo")
1825
NATIVE_SUFFIXES = (".dll", ".dylib", ".exe", ".pyd", ".so")
1926

0 commit comments

Comments
 (0)