From f8a69b3c9f44181eb56d2e2d0f06f0e4e9c44ca9 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Thu, 29 Oct 2026 12:26:48 -0400 Subject: [PATCH] feat(python): ship typed sync and async gRPC stubs 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 --- docs/sdk/python.mdx | 33 ++++++++ e2e/mcp-conformance/host-bridge.py | 2 +- pyproject.toml | 5 +- python/generate_python_proto_test.py | 2 +- python/grpc_transport_test.py | 101 +++++++++++++++++++++++++ python/grpc_typing_test.py | 81 ++++++++++++++++++++ python/openshell/sandbox_test.py | 26 ++++--- python/wheel_verifier_test.py | 7 +- tasks/scripts/generate_python_proto.py | 11 ++- tasks/scripts/sync_docs_website.py | 2 +- tasks/scripts/verify-python-wheel.py | 1 + uv.lock | 80 +++++++++++++++----- 12 files changed, 310 insertions(+), 41 deletions(-) create mode 100644 python/grpc_transport_test.py create mode 100644 python/grpc_typing_test.py diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 035e5e73e2..165fd7ac8a 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -148,6 +148,39 @@ 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. +## 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: + +```shell +uv add --dev types-grpcio 'types-protobuf>=6.32,<7' +``` + +Construct `OpenShellStub` with your channel. The channel determines whether calls return responses directly or awaitable gRPC calls: + +```python +import grpc.aio + +from openshell._proto import datamodel_pb2 as dm +from openshell._proto import openshell_pb2 as pb +from openshell._proto.openshell_pb2_grpc import OpenShellStub + + +async def get_provider(channel: grpc.aio.Channel, name: str) -> pb.ProviderResponse: + stub = OpenShellStub(channel) + return await stub.GetProvider( + pb.GetProviderRequest( + workspace_scope=dm.WorkspaceSelector(workspace="default"), + name=name, + ), + timeout=30, + ) +``` + +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. + +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. + ## Implement a Credential Driver The SDK includes generated message classes, type stubs, and gRPC bindings for diff --git a/e2e/mcp-conformance/host-bridge.py b/e2e/mcp-conformance/host-bridge.py index e2b59a7d92..74fa6519c5 100644 --- a/e2e/mcp-conformance/host-bridge.py +++ b/e2e/mcp-conformance/host-bridge.py @@ -221,7 +221,7 @@ def do_POST(self) -> None: timeout=TIMEOUT, ) log(f"client exited {result.returncode} for {server_url}") - body = { + body: dict[str, object] = { "exit_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr, diff --git a/pyproject.toml b/pyproject.toml index 273ec4e3ef..857a69b450 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,11 +49,14 @@ dev = [ "pytest-cov>=4.0", "pytest-xdist>=3.0", "ruff>=0.4", - "ty>=0.0.1a6", + "ty>=0.0.94", "grpcio-tools>=1.60", "pyelftools>=0.30", "pyyaml==6.0.2", "types-pyyaml>=6.0.12.20260518", + "mypy-protobuf>=5.1", + "types-grpcio>=1.0.0.20251009", + "types-protobuf>=6.32,<7", ] [tool.setuptools] diff --git a/python/generate_python_proto_test.py b/python/generate_python_proto_test.py index ad57378ba7..b242c375e5 100644 --- a/python/generate_python_proto_test.py +++ b/python/generate_python_proto_test.py @@ -35,7 +35,7 @@ def test_generate_package_relative_bindings(tmp_path: Path, no_pyi: bool) -> Non } suffixes = ["_pb2.py", "_pb2_grpc.py"] if not no_pyi: - suffixes.append("_pb2.pyi") + suffixes.extend(["_pb2.pyi", "_pb2_grpc.pyi"]) assert {path.name for path in output.iterdir()} == { f"{stem}{suffix}" for stem in stems for suffix in suffixes } diff --git a/python/grpc_transport_test.py b/python/grpc_transport_test.py new file mode 100644 index 0000000000..52ce86a16e --- /dev/null +++ b/python/grpc_transport_test.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Generated async stubs retain native gRPC transport behavior.""" + +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +import grpc +import grpc.aio +import pytest + +from openshell._proto import datamodel_pb2 as dm +from openshell._proto import openshell_pb2 as pb +from openshell._proto import openshell_pb2_grpc as rpc + + +class Gateway(rpc.OpenShellServicer): + def __init__(self) -> None: + self.started = asyncio.Event() + self.cancelled = asyncio.Event() + + async def GetProvider( + self, request: pb.GetProviderRequest, context: grpc.aio.ServicerContext + ) -> pb.ProviderResponse: + assert ("authorization", "Bearer test-token") in ( + context.invocation_metadata() or () + ) + if request.name == "missing": + await context.abort(grpc.StatusCode.NOT_FOUND, "No provider") + if request.name == "blocked": + self.started.set() + try: + await asyncio.Future() + finally: + self.cancelled.set() + return pb.ProviderResponse( + provider=dm.Provider(metadata=dm.ObjectMeta(name=request.name)) + ) + + +@asynccontextmanager +async def gateway() -> AsyncGenerator[tuple[Gateway, grpc.aio.Channel]]: + service = Gateway() + server = grpc.aio.server() + rpc.add_OpenShellServicer_to_server(service, server) + port = server.add_insecure_port("127.0.0.1:0") + await server.start() + try: + async with grpc.aio.insecure_channel(f"127.0.0.1:{port}") as channel: + yield service, channel + finally: + await server.stop(0) + + +_METADATA = (("authorization", "Bearer test-token"),) + + +async def test_concurrent_calls_and_task_cancellation() -> None: + async with gateway() as (service, channel): + stub = rpc.OpenShellStub(channel) + + async def blocked_call() -> pb.ProviderResponse: + return await stub.GetProvider( + pb.GetProviderRequest(name="blocked"), timeout=5, metadata=_METADATA + ) + + pending = asyncio.create_task(blocked_call()) + try: + await asyncio.wait_for(service.started.wait(), timeout=5) + response = await stub.GetProvider( + pb.GetProviderRequest(name="ready"), timeout=5, metadata=_METADATA + ) + assert response.provider.metadata.name == "ready" + finally: + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + await asyncio.wait_for(service.cancelled.wait(), timeout=5) + response = await stub.GetProvider( + pb.GetProviderRequest(name="still-ready"), timeout=5, metadata=_METADATA + ) + assert response.provider.metadata.name == "still-ready" + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("missing", grpc.StatusCode.NOT_FOUND), + ("blocked", grpc.StatusCode.DEADLINE_EXCEEDED), + ], +) +async def test_status_and_deadline(name: str, expected: grpc.StatusCode) -> None: + async with gateway() as (_, channel): + stub = rpc.OpenShellStub(channel) + with pytest.raises(grpc.aio.AioRpcError) as error: + await stub.GetProvider( + pb.GetProviderRequest(name=name), timeout=0.2, metadata=_METADATA + ) + assert error.value.code() == expected diff --git a/python/grpc_typing_test.py b/python/grpc_typing_test.py new file mode 100644 index 0000000000..cfb4342dc7 --- /dev/null +++ b/python/grpc_typing_test.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Check the consumer-facing types of generated sync and async RPCs.""" + +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +def test_generated_rpc_types(tmp_path: Path, asynchronous: bool) -> None: + channel = "grpc.aio.Channel" if asynchronous else "grpc.Channel" + function = "async def" if asynchronous else "def" + await_call = "await " if asynchronous else "" + iterate = "async for" if asynchronous else "for" + source = f""" +from typing import assert_type +import grpc +import grpc.aio +from openshell._proto import credential_driver_pb2 as credentials +from openshell._proto import credential_driver_pb2_grpc as driver +from openshell._proto import openshell_pb2 as pb +from openshell._proto import openshell_pb2_grpc as rpc + +{function} check(channel: {channel}) -> None: + stub = rpc.OpenShellStub(channel) + assert_type({await_call}stub.GetProvider(pb.GetProviderRequest()), pb.ProviderResponse) + assert_type({await_call}stub.CreateProvider(pb.CreateProviderRequest()), pb.ProviderResponse) + assert_type({await_call}stub.UpdateProvider(pb.UpdateProviderRequest()), pb.ProviderResponse) + assert_type({await_call}stub.DeleteProvider(pb.DeleteProviderRequest()), pb.DeleteProviderResponse) + assert_type({await_call}stub.ListProviders(pb.ListProvidersRequest()), pb.ListProvidersResponse) + assert_type({await_call}stub.GetProviderProfile(pb.GetProviderProfileRequest()), pb.ProviderProfileResponse) + assert_type({await_call}stub.GetSandbox(pb.GetSandboxRequest()), pb.SandboxResponse) + assert_type({await_call}stub.DetachSandboxProvider(pb.DetachSandboxProviderRequest()), pb.DetachSandboxProviderResponse) + {iterate} event in stub.ExecSandbox(pb.ExecSandboxRequest()): + assert_type(event, pb.ExecSandboxEvent) + {iterate} frame in stub.ForwardTcp(iter([pb.TcpForwardFrame()])): + assert_type(frame, pb.TcpForwardFrame) + credential_stub = driver.CredentialDriverStub(channel) + assert_type({await_call}credential_stub.StoreCredential(credentials.StoreCredentialRequest()), credentials.StoreCredentialResponse) +""" + path = tmp_path / "consumer.py" + path.write_text(source) + result = _check_types(path) + assert result.returncode == 0, result.stdout + result.stderr + + # A missing import or Any-typed callable must not make this test pass. + path.write_text( + source.replace( + "stub.GetProvider(pb.GetProviderRequest())", + "stub.GetProvider(pb.DeleteProviderRequest())", + ) + ) + result = _check_types(path) + assert result.returncode != 0 + assert "invalid-argument-type" in result.stdout + assert "GetProviderRequest" in result.stdout + assert "DeleteProviderRequest" in result.stdout + + +def _check_types(path: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + "-m", + "ty", + "check", + "--python", + sys.executable, + "--output-format", + "concise", + str(path), + ], + cwd=Path(__file__).resolve().parents[1], + capture_output=True, + text=True, + check=False, + ) diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 82de216263..c1df0d46e0 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -15,6 +15,7 @@ from typing import Any, cast import pytest +from google.protobuf.struct_pb2 import Struct import openshell.sandbox as sandbox_module from openshell._proto import openshell_pb2 @@ -427,14 +428,14 @@ def ExecSandbox( def _client_with_fake_stub(stub: object) -> SandboxClient: - client = cast("SandboxClient", object.__new__(SandboxClient)) + client = object.__new__(SandboxClient) client._timeout = 30.0 client._stub = cast("Any", stub) return client def _template_client_with_fake_stub(stub: object) -> SandboxTemplateClient: - client = cast("SandboxTemplateClient", object.__new__(SandboxTemplateClient)) + client = object.__new__(SandboxTemplateClient) client._timeout = 30.0 client._stub = cast("Any", stub) return client @@ -2347,7 +2348,9 @@ def test_sandbox_template_create_builds_template_from_public_fields() -> None: assert template.spec.workload.resources.cpu == "1" assert template.spec.workload.resources.memory == "512Mi" assert template.spec.workload.resources.gpu.count == 2 - assert template.spec.driver_config["kubernetes"]["runtime_class_name"] == "kata" + config = template.spec.driver_config["kubernetes"] + assert isinstance(config, Struct) + assert config["runtime_class_name"] == "kata" def test_sandbox_template_create_materializes_default_workload() -> None: @@ -2380,7 +2383,9 @@ def test_sandbox_template_create_materializes_workload_with_driver_config_only() assert template.HasField("spec") assert template.spec.HasField("workload") assert template.spec.workload.image == "" - assert template.spec.driver_config["kubernetes"]["runtime_class_name"] == "kata" + config = template.spec.driver_config["kubernetes"] + assert isinstance(config, Struct) + assert config["runtime_class_name"] == "kata" def test_sandbox_template_create_rejects_missing_public_name() -> None: @@ -2486,12 +2491,9 @@ def test_sandbox_template_client_crud_forwards_requests() -> None: stub.create_template_request.template.spec.workload.image == "ghcr.io/test/gpu-kata:latest" ) - assert ( - stub.create_template_request.template.spec.driver_config["kubernetes"][ - "runtime_class_name" - ] - == "kata" - ) + config = stub.create_template_request.template.spec.driver_config["kubernetes"] + assert isinstance(config, Struct) + assert config["runtime_class_name"] == "kata" got = client.get("gpu-kata", workspace="default") assert got.metadata.name == "gpu-kata" @@ -2892,7 +2894,7 @@ def test_returned_labels_are_immutable() -> None: ref = _sandbox_ref(proto) with pytest.raises(TypeError): - ref.labels["mutated"] = "nope" # type: ignore[index] + ref.labels["mutated"] = "nope" # ty: ignore[invalid-assignment] def test_direct_sandbox_ref_construction_defaults_labels() -> None: @@ -2953,7 +2955,7 @@ def test_direct_sandbox_ref_copies_and_freezes_labels() -> None: assert dict(ref.labels) == {"aiq": "deep-research"} with pytest.raises(TypeError): - ref.labels["mutated"] = "nope" # type: ignore[index] + ref.labels["mutated"] = "nope" # ty: ignore[invalid-assignment] def test_high_level_creation_forwards_name_and_labels( diff --git a/python/wheel_verifier_test.py b/python/wheel_verifier_test.py index edbafb5568..0ade8cd918 100644 --- a/python/wheel_verifier_test.py +++ b/python/wheel_verifier_test.py @@ -43,6 +43,7 @@ def _wheel_files() -> set[str]: files.add(f"openshell/_proto/{stem}_pb2.py") files.add(f"openshell/_proto/{stem}_pb2.pyi") files.add(f"openshell/_proto/{stem}_pb2_grpc.py") + files.add(f"openshell/_proto/{stem}_pb2_grpc.pyi") return files @@ -86,8 +87,10 @@ def test_requires_exactly_one_wheel_in_directory(tmp_path: Path) -> None: verifier.verify_wheel(tmp_path) -@pytest.mark.parametrize("stem", ["sandbox", "credential_driver", "extension"]) -@pytest.mark.parametrize("suffix", ["_pb2.py", "_pb2.pyi", "_pb2_grpc.py"]) +@pytest.mark.parametrize("stem", verifier.PROTO_STEMS) +@pytest.mark.parametrize( + "suffix", ["_pb2.py", "_pb2.pyi", "_pb2_grpc.py", "_pb2_grpc.pyi"] +) def test_rejects_missing_generated_proto( tmp_path: Path, stem: str, suffix: str ) -> None: diff --git a/tasks/scripts/generate_python_proto.py b/tasks/scripts/generate_python_proto.py index b9c6cec01f..e0a1896d0d 100644 --- a/tasks/scripts/generate_python_proto.py +++ b/tasks/scripts/generate_python_proto.py @@ -39,7 +39,14 @@ def main() -> None: "grpc_tools.protoc", "-Iproto", "--python_out=python/openshell/_proto", - *([] if args.no_pyi else ["--pyi_out=python/openshell/_proto"]), + *( + [] + if args.no_pyi + else [ + "--pyi_out=python/openshell/_proto", + "--mypy_grpc_out=generate_concrete_servicer_stubs:python/openshell/_proto", + ] + ), "--grpc_python_out=python/openshell/_proto", *PROTO_FILES, ], @@ -52,7 +59,7 @@ def main() -> None: pattern = rf"^import ({modules})( as \w+)?$" suffixes = ["_pb2.py", "_pb2_grpc.py"] if not args.no_pyi: - suffixes.append("_pb2.pyi") + suffixes.extend(["_pb2.pyi", "_pb2_grpc.pyi"]) for proto in PROTO_FILES: for suffix in suffixes: file_path = output_dir / f"{Path(proto).stem}{suffix}" diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index 03abee3ce6..ea452cc1b6 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -354,7 +354,7 @@ def ordered_entries( def render_versions(entries: list[VersionEntry]) -> list[YamlMapping]: rendered: list[YamlMapping] = [] for entry in entries: - item = { + item: YamlMapping = { "display-name": entry.display_name, "path": entry.path, "slug": entry.slug, diff --git a/tasks/scripts/verify-python-wheel.py b/tasks/scripts/verify-python-wheel.py index d694d410fd..b145d1fd48 100644 --- a/tasks/scripts/verify-python-wheel.py +++ b/tasks/scripts/verify-python-wheel.py @@ -46,6 +46,7 @@ def _required_files() -> set[str]: files.add(f"openshell/_proto/{stem}_pb2.py") files.add(f"openshell/_proto/{stem}_pb2.pyi") files.add(f"openshell/_proto/{stem}_pb2_grpc.py") + files.add(f"openshell/_proto/{stem}_pb2_grpc.pyi") return files diff --git a/uv.lock b/uv.lock index 7aeebe2e40..4fd26f5171 100644 --- a/uv.lock +++ b/uv.lock @@ -314,6 +314,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "mypy-protobuf" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, + { name = "types-protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/07/7ffb897eee5c01e816e037f51a6720bf1a0a8c17cca1a2382ae554f43044/mypy_protobuf-5.1.0.tar.gz", hash = "sha256:8493758852a9cdc075a11dbe96c6e37ea2feba5fd5f33ef7442b9a275322a94c", size = 37529, upload-time = "2026-04-28T15:56:40.356Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/ec/5ffcf8f5f53757e01afdece10c664e694ac60d2f17c2036811b85b842a5b/mypy_protobuf-5.1.0-py3-none-any.whl", hash = "sha256:d7031f563f806b8bcd448a3e86768bf0994c0af5b0017b344a4fbbba4191f43c", size = 26313, upload-time = "2026-04-28T15:56:39.096Z" }, +] + [[package]] name = "openshell" source = { editable = "." } @@ -328,6 +341,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "grpcio-tools" }, + { name = "mypy-protobuf" }, { name = "pyelftools" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -336,6 +350,8 @@ dev = [ { name = "pyyaml" }, { name = "ruff" }, { name = "ty" }, + { name = "types-grpcio" }, + { name = "types-protobuf" }, { name = "types-pyyaml" }, ] @@ -351,6 +367,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "grpcio-tools", specifier = ">=1.60" }, + { name = "mypy-protobuf", specifier = ">=5.1" }, { name = "pyelftools", specifier = ">=0.30" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.23" }, @@ -358,7 +375,9 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.0" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "ruff", specifier = ">=0.4" }, - { name = "ty", specifier = ">=0.0.1a6" }, + { name = "ty", specifier = ">=0.0.94" }, + { name = "types-grpcio", specifier = ">=1.0.0.20251009" }, + { name = "types-protobuf", specifier = ">=6.32,<7" }, { name = "types-pyyaml", specifier = ">=6.0.12.20260518" }, ] @@ -595,26 +614,45 @@ wheels = [ [[package]] name = "ty" -version = "0.0.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/57/22c3d6bf95c2229120c49ffc2f0da8d9e8823755a1c3194da56e51f1cc31/ty-0.0.14.tar.gz", hash = "sha256:a691010565f59dd7f15cf324cdcd1d9065e010c77a04f887e1ea070ba34a7de2", size = 5036573, upload-time = "2026-01-27T00:57:31.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/cb/cc6d1d8de59beb17a41f9a614585f884ec2d95450306c173b3b7cc090d2e/ty-0.0.14-py3-none-linux_armv6l.whl", hash = "sha256:32cf2a7596e693094621d3ae568d7ee16707dce28c34d1762947874060fdddaa", size = 10034228, upload-time = "2026-01-27T00:57:53.133Z" }, - { url = "https://files.pythonhosted.org/packages/f3/96/dd42816a2075a8f31542296ae687483a8d047f86a6538dfba573223eaf9a/ty-0.0.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f971bf9805f49ce8c0968ad53e29624d80b970b9eb597b7cbaba25d8a18ce9a2", size = 9939162, upload-time = "2026-01-27T00:57:43.857Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b4/73c4859004e0f0a9eead9ecb67021438b2e8e5fdd8d03e7f5aca77623992/ty-0.0.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:45448b9e4806423523268bc15e9208c4f3f2ead7c344f615549d2e2354d6e924", size = 9418661, upload-time = "2026-01-27T00:58:03.411Z" }, - { url = "https://files.pythonhosted.org/packages/58/35/839c4551b94613db4afa20ee555dd4f33bfa7352d5da74c5fa416ffa0fd2/ty-0.0.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee94a9b747ff40114085206bdb3205a631ef19a4d3fb89e302a88754cbbae54c", size = 9837872, upload-time = "2026-01-27T00:57:23.718Z" }, - { url = "https://files.pythonhosted.org/packages/41/2b/bbecf7e2faa20c04bebd35fc478668953ca50ee5847ce23e08acf20ea119/ty-0.0.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6756715a3c33182e9ab8ffca2bb314d3c99b9c410b171736e145773ee0ae41c3", size = 9848819, upload-time = "2026-01-27T00:57:58.501Z" }, - { url = "https://files.pythonhosted.org/packages/be/60/3c0ba0f19c0f647ad9d2b5b5ac68c0f0b4dc899001bd53b3a7537fb247a2/ty-0.0.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89d0038a2f698ba8b6fec5cf216a4e44e2f95e4a5095a8c0f57fe549f87087c2", size = 10324371, upload-time = "2026-01-27T00:57:29.291Z" }, - { url = "https://files.pythonhosted.org/packages/24/32/99d0a0b37d0397b0a989ffc2682493286aa3bc252b24004a6714368c2c3d/ty-0.0.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c64a83a2d669b77f50a4957039ca1450626fb474619f18f6f8a3eb885bf7544", size = 10865898, upload-time = "2026-01-27T00:57:33.542Z" }, - { url = "https://files.pythonhosted.org/packages/1a/88/30b583a9e0311bb474269cfa91db53350557ebec09002bfc3fb3fc364e8c/ty-0.0.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242488bfb547ef080199f6fd81369ab9cb638a778bb161511d091ffd49c12129", size = 10555777, upload-time = "2026-01-27T00:58:05.853Z" }, - { url = "https://files.pythonhosted.org/packages/cd/a2/cb53fb6325dcf3d40f2b1d0457a25d55bfbae633c8e337bde8ec01a190eb/ty-0.0.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4790c3866f6c83a4f424fc7d09ebdb225c1f1131647ba8bdc6fcdc28f09ed0ff", size = 10412913, upload-time = "2026-01-27T00:57:38.834Z" }, - { url = "https://files.pythonhosted.org/packages/42/8f/f2f5202d725ed1e6a4e5ffaa32b190a1fe70c0b1a2503d38515da4130b4c/ty-0.0.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:950f320437f96d4ea9a2332bbfb5b68f1c1acd269ebfa4c09b6970cc1565bd9d", size = 9837608, upload-time = "2026-01-27T00:57:55.898Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/59a2a0521640c489dafa2c546ae1f8465f92956fede18660653cce73b4c5/ty-0.0.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a0ec3ee70d83887f86925bbc1c56f4628bd58a0f47f6f32ddfe04e1f05466df", size = 9884324, upload-time = "2026-01-27T00:57:46.786Z" }, - { url = "https://files.pythonhosted.org/packages/03/95/8d2a49880f47b638743212f011088552ecc454dd7a665ddcbdabea25772a/ty-0.0.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1a4e6b6da0c58b34415955279eff754d6206b35af56a18bb70eb519d8d139ef", size = 10033537, upload-time = "2026-01-27T00:58:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/e9/40/4523b36f2ce69f92ccf783855a9e0ebbbd0f0bb5cdce6211ee1737159ed3/ty-0.0.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dc04384e874c5de4c5d743369c277c8aa73d1edea3c7fc646b2064b637db4db3", size = 10495910, upload-time = "2026-01-27T00:57:26.691Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/655beb51224d1bfd4f9ddc0bb209659bfe71ff141bcf05c418ab670698f0/ty-0.0.14-py3-none-win32.whl", hash = "sha256:b20e22cf54c66b3e37e87377635da412d9a552c9bf4ad9fc449fed8b2e19dad2", size = 9507626, upload-time = "2026-01-27T00:57:41.43Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d9/c569c9961760e20e0a4bc008eeb1415754564304fd53997a371b7cf3f864/ty-0.0.14-py3-none-win_amd64.whl", hash = "sha256:e312ff9475522d1a33186657fe74d1ec98e4a13e016d66f5758a452c90ff6409", size = 10437980, upload-time = "2026-01-27T00:57:36.422Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" }, +version = "0.0.94" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/c7/1257c8c70e46274664db1eaf2b8aba99a1277470daaa39c65e7decd67c8f/ty-0.0.94.tar.gz", hash = "sha256:933d2feca6057742d04af00c090621c139e7be2c059aa38b040a70532fd5104b", size = 8106769, upload-time = "2026-10-28T18:06:09.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/bd/5145d91cf196cb20fc3b005515b21266a419ea7336d3d7891a1769439259/ty-0.0.94-py3-none-linux_armv6l.whl", hash = "sha256:c6463c045880ad17df911a53d367eb2554b17538ca54911bea6ccb20ec95ee11", size = 14528574, upload-time = "2026-10-28T18:05:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/2b563132429b5fc6044095061fde4584548cb55abf0d6d9f2829c41ab226/ty-0.0.94-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ec3a3892f4290306c674586149668097cf44f13e8b381ba388662f5a440b459b", size = 14067146, upload-time = "2026-10-28T18:05:23.014Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/3a458362ea33ca9756cd6b914f905b3dec007d329b21d83e859ecee65a63/ty-0.0.94-py3-none-macosx_11_0_arm64.whl", hash = "sha256:74ddc6eeecd3251cf8980c9abb960d7eeab649dac5cdb7504910d99c479f84ab", size = 13148266, upload-time = "2026-10-28T18:05:25.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/38/5380bdaad2745a4e191deacce96403af74d1992ed8782d5766c301c32b50/ty-0.0.94-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31bdc3aace142400d7e22b4d32676c063ec42f0437d77940012c3dc60587ecdd", size = 13973583, upload-time = "2026-10-28T18:05:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bc/7e1dc4f2594010aff6d1e2e34d0ff9003736210983be5720a04d69c27e03/ty-0.0.94-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fecacfe9c41ed1109aa503ed172d84b1b0fc283ee49aaf078d298dbe1c62e75f", size = 14269235, upload-time = "2026-10-28T18:05:31.697Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/43041f1c53164d6be21ace55047feffeaa8a9709782b01b06c951412b900/ty-0.0.94-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a49762bbc97a732847da8350ad0227c99ce2675fa32e3f58aa9f9ff4a81e4c14", size = 15191720, upload-time = "2026-10-28T18:05:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/f425d48b13cbf12b92e4f837a5866e9788e9ef4d5a071e2b12f54ee94da4/ty-0.0.94-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76a7b3aea839dfafb4bc068af918e0292b06ac26ea2a43f7ab3dd65548c0ed50", size = 15596604, upload-time = "2026-10-28T18:05:37.377Z" }, + { url = "https://files.pythonhosted.org/packages/13/ea/bd2da666ad4af596886833b095098fa9ff93685285f48bebc4d3865126e0/ty-0.0.94-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f032ce5a9c0c7033a5a8e748f952ece2e22afe2b148d211d05dca2cba8b28000", size = 15572711, upload-time = "2026-10-28T18:05:40.499Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/4f07eac57e9b60f49cc222d5048d9a61ac7f0902964ec8ac8d4ea8bc521d/ty-0.0.94-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc5e780c2107c1b83a21a394076da328e05a293ce06298df40ecab1ddc31d371", size = 16309102, upload-time = "2026-10-28T18:05:43.539Z" }, + { url = "https://files.pythonhosted.org/packages/22/39/c2ac7e14eb8ca59bd3c71b8c3055294524df84c476ba88545f59c11ceaf9/ty-0.0.94-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5bc969cdabf85aa66987c9b4818072e3af17ce5f2b7bc93550ab896c904c6c40", size = 15073645, upload-time = "2026-10-28T18:05:46.65Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7d/dd4935abf50a97ccc1f46cf6c921a7acdab30f102c3c56aa4e8d6cbc3fa9/ty-0.0.94-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:27bbaff0d7bbcb3e92ed7cab07d6b26dc7ed7fa28ff69910324e5cc347c7b256", size = 13936242, upload-time = "2026-10-28T18:05:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f0/b65fa6ccde2d629e25031bf2cc2d21ac7b50ee6e0e3d29a5f471c4686c54/ty-0.0.94-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d0960eb71220300655d67db91073fd180f385186b1d186e76faa2fd3ae5bd10f", size = 14280351, upload-time = "2026-10-28T18:05:52.521Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d2/5c901ad5e4f5444852b002b1f4dd3a0b470039305c688facc0483bfccd1a/ty-0.0.94-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ab04c14bca92ab92106ceaa19a962a80998389700cf7938576ba3936a304355d", size = 14611163, upload-time = "2026-10-28T18:05:55.208Z" }, + { url = "https://files.pythonhosted.org/packages/6d/99/37d8633e9b5d1dcf1b06f12466825ff0c4028664994e573c352546ee9cbd/ty-0.0.94-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a92ebefa092e6bd58cb5719626858df744575473e2de0cd1c44abd44b9b84b57", size = 14938978, upload-time = "2026-10-28T18:05:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/51/13/804b1f993bf84a819385f8b440fbc4b894499e2c20d865cd74d69327e7b8/ty-0.0.94-py3-none-win32.whl", hash = "sha256:3212dcf30b7567f2bb65a83cdfc4818a5385f82e7afddd83548b0d84e759057e", size = 13448009, upload-time = "2026-10-28T18:06:01.111Z" }, + { url = "https://files.pythonhosted.org/packages/a1/23/81850750cda0ea781ba9d6e08cea1a1039361cb3474c7dd03765bbc4b51f/ty-0.0.94-py3-none-win_amd64.whl", hash = "sha256:daf5bafe8e7d21b8a99a56fc8518f2342a2dbcaa370a5c25cfaedc684088d158", size = 14010576, upload-time = "2026-10-28T18:06:03.773Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/ac3a759556493230823abd31e149c95401f96b9cac36567bd403a8454ea3/ty-0.0.94-py3-none-win_arm64.whl", hash = "sha256:ce344353ccdc542cfcfd6abe1481cf23c005f483411ea79652b78ce0beae36f2", size = 13547398, upload-time = "2026-10-28T18:06:06.379Z" }, +] + +[[package]] +name = "types-grpcio" +version = "1.85.0.20261028" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/6b/5248f1b10df2c02ffbd99f3c913259bf73cabd2bee0ca1c28d6f3e66c3f4/types_grpcio-1.85.0.20261028.tar.gz", hash = "sha256:4ea4f36a54cf9a62fcb09515f7305e89c01f4edca54b32de24f6b1204b84522c", size = 16127, upload-time = "2026-10-28T07:38:58.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/ee/e2b2f2d677478456869ca922c1d5bb68f64052c9dfd9ad6c2a91a1122269/types_grpcio-1.85.0.20261028-py3-none-any.whl", hash = "sha256:2c2684330ae57ab64ffb43183ac652e73d2609024e6076e3e9d2b8ae06816207", size = 16076, upload-time = "2026-10-28T07:38:57.193Z" }, +] + +[[package]] +name = "types-protobuf" +version = "6.32.1.20260221" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" }, ] [[package]]