Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a7de60f
Add flags to generate only sync or only async stubs
WouldYouKindly Nov 26, 2025
311a877
Specify type parameters for context in async grpc methods
WouldYouKindly Nov 26, 2025
5541400
Ignore pyright errors in sync/async only stubs
WouldYouKindly Nov 26, 2025
912d7db
Update readme with sync_only/async_only option
WouldYouKindly Nov 26, 2025
f7e76a1
Start adding tests
aidandj Nov 26, 2025
d6214ee
add constructor typing
aidandj Nov 27, 2025
c944a6b
run black
WouldYouKindly Nov 27, 2025
828c4d7
isort: skip generated grpc files
WouldYouKindly Nov 27, 2025
d0a2375
Add init files to generated sync/async
WouldYouKindly Nov 27, 2025
15762aa
Add init files to generated sync/async
WouldYouKindly Nov 27, 2025
6116175
Add missing enum import
WouldYouKindly Dec 14, 2025
372191b
Fix linting error and remove generated sync/async only files
WouldYouKindly Dec 14, 2025
9efdd41
Update Pyright config to exclude sync/async only test directories
WouldYouKindly Dec 14, 2025
c308446
Create sync/async only directories before generating files
WouldYouKindly Dec 14, 2025
3f57edc
Add --report-deprecated-as-note to sync/async only mypy checks
WouldYouKindly Dec 14, 2025
efc9980
Fix async_only test to use DummyServiceAsyncStub
WouldYouKindly Dec 14, 2025
9081493
Address review comments: error handling and simplify server type logic
WouldYouKindly Dec 14, 2025
ffbe6b3
Improve tests and add pyright support for sync/async only
WouldYouKindly Dec 14, 2025
b11e19a
Fix async_only test context types to match generated stubs
WouldYouKindly Dec 14, 2025
965e25b
Fix async_only test to use DummyServiceStub at runtime
WouldYouKindly Dec 14, 2025
c51be2f
Add type alias for Stub in async_only mode
WouldYouKindly Dec 14, 2025
344bfd1
Use Stub naming in async_only mode (not AsyncStub)
WouldYouKindly Dec 14, 2025
aa774b1
Update changelog
WouldYouKindly Dec 14, 2025
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ __pycache__/
!/test/generated/**/__init__.py
/test/generated_concrete/**/*.py
!/test/generated_concrete/**/__init__.py
/test/generated_async_only/**/*.py
!/test/generated_async_only/**/__init__.py
/test/generated_sync_only/**/*.py
!/test/generated_sync_only/**/__init__.py
.pytest_cache
/build/
/dist/
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Protobuf <6.32 still had the edition enums and field options, so it *should* still work. But is untested
- Add support for editions (up to 2024)
- Add `generate_concrete_servicer_stubs` option to generate concrete instead of abstract servicer stubs
- Add `sync_only`/`async_only` options to generate only sync or async version of GRPC stubs
- Switch to types-grpcio instead of no longer maintained grpc-stubs
- Add `_HasFieldArgType` and `_ClearFieldArgType` aliases to allow for typing field manipulation functions
- Add `_WhichOneofArgType_<oneof_name>` and `_WhichOneofReturnType_<oneof_name>` type aliases
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,21 @@ By default mypy-protobuf will output servicer stubs with abstract methods. To ou
protoc --python_out=output/location --mypy_grpc_out=generate_concrete_servicer_stubs:output/location
```

### `sync_only/async_only`

By default, generated GRPC stubs are compatible with both sync and async variants. If you only
want sync or async GRPC stubs, use this option:

```
protoc --python_out=output/location --mypy_grpc_out=sync_only:output/location
```

or

```
protoc --python_out=output/location --mypy_grpc_out=async_only:output/location
```

### Output suppression

To suppress output, you can run
Expand Down
255 changes: 179 additions & 76 deletions mypy_protobuf/main.py

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ line-length = 10000
[tool.isort]
profile = "black"
skip_gitignore = true
extend_skip_glob = ["*_pb2.py"]
extend_skip_glob = ["*_pb2.py", "*_pb2_grpc.py"]

[tool.mypy]
strict = true
Expand All @@ -32,12 +32,16 @@ include = [
exclude = [
"**/*_pb2.py",
"**/*_pb2_grpc.py",
"test/test_concrete.py"
"test/test_concrete.py",
]

executionEnvironments = [
# Due to how upb is typed, we need to disable incompatible variable override checks
{ root = "test/generated", extraPaths = ["./"], reportIncompatibleVariableOverride = "none" },
{ root = "test/generated_concrete", extraPaths = ["./"], reportIncompatibleVariableOverride = "none" },
{ root = "test/generated_sync_only", extraPaths = ["./"], reportIncompatibleVariableOverride = "none" },
{ root = "test/generated_async_only", extraPaths = ["./"], reportIncompatibleVariableOverride = "none" },
{ root = "mypy_protobuf/extensions_pb2.pyi", reportIncompatibleVariableOverride = "none" },
{ root = "test/async_only", extraPaths = ["test/generated_async_only"] },
{ root = "test/sync_only", extraPaths = ["test/generated_sync_only"] },
]
23 changes: 21 additions & 2 deletions run_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@ MYPY_PROTOBUF_VENV=venv_$PY_VER_MYPY_PROTOBUF

# CI Check to make sure generated files are committed
SHA_BEFORE=$(find test/generated -name "*.pyi" -print0 | xargs -0 sha1sum)
# Clean out generated/ directory - except for __init__.py
# Clean out generated/ directories - except for __init__.py
find test/generated -type f -not -name "__init__.py" -delete
find test/generated_sync_only -type f -not -name "__init__.py" -delete
find test/generated_async_only -type f -not -name "__init__.py" -delete

# Compile protoc -> python
find proto -name "*.proto" -print0 | xargs -0 "$PROTOC" "${PROTOC_ARGS[@]}" --python_out=test/generated
Expand All @@ -135,6 +137,13 @@ MYPY_PROTOBUF_VENV=venv_$PY_VER_MYPY_PROTOBUF
find proto -name "*.proto" -print0 | xargs -0 "$PROTOC" "${PROTOC_ARGS[@]}" --mypy_out=generate_concrete_servicer_stubs:test/generated_concrete
find proto/testproto/grpc -name "*.proto" -print0 | xargs -0 "$PROTOC" "${PROTOC_ARGS[@]}" --mypy_grpc_out=generate_concrete_servicer_stubs:test/generated_concrete

# Generate with sync_only stubs for testing
mkdir -p test/generated_sync_only
find proto/testproto -name "*.proto" -print0 | xargs -0 "$PROTOC" "${PROTOC_ARGS[@]}" --mypy_grpc_out=only_sync:test/generated_sync_only --mypy_out=test/generated_sync_only --python_out=test/generated_sync_only

# Generate with async_only stubs for testing
mkdir -p test/generated_async_only
find proto/testproto -name "*.proto" -print0 | xargs -0 "$PROTOC" "${PROTOC_ARGS[@]}" --mypy_grpc_out=only_async:test/generated_async_only --mypy_out=test/generated_async_only --python_out=test/generated_async_only

if [[ -n $VALIDATE ]] && ! diff <(echo "$SHA_BEFORE") <(find test/generated -name "*.pyi" -print0 | xargs -0 sha1sum); then
echo -e "${RED}Some .pyi files did not match. Please commit those files${NC}"
Expand All @@ -153,6 +162,8 @@ for PY_VER in $PY_VER_UNIT_TESTS; do
(
source "$UNIT_TESTS_VENV"/bin/activate
find proto/testproto/grpc -name "*.proto" -print0 | xargs -0 python -m grpc_tools.protoc "${PROTOC_ARGS[@]}" --grpc_python_out=test/generated
find proto/testproto/grpc -name "*.proto" -print0 | xargs -0 python -m grpc_tools.protoc "${PROTOC_ARGS[@]}" --grpc_python_out=test/generated_sync_only
find proto/testproto/grpc -name "*.proto" -print0 | xargs -0 python -m grpc_tools.protoc "${PROTOC_ARGS[@]}" --grpc_python_out=test/generated_async_only
)

# Run mypy on unit tests / generated output
Expand All @@ -162,6 +173,14 @@ for PY_VER in $PY_VER_UNIT_TESTS; do
CONCRETE_MODULES=( -m test.test_concrete )
MYPYPATH=$MYPYPATH:test/generated_concrete mypy ${CUSTOM_TYPESHED_DIR_ARG:+"$CUSTOM_TYPESHED_DIR_ARG"} --report-deprecated-as-note --no-incremental --python-executable="$UNIT_TESTS_VENV"/bin/python --python-version="$PY_VER_MYPY_TARGET" "${CONCRETE_MODULES[@]}"

# Run sync_only mypy
SYNC_ONLY_MODULES=( -m test.sync_only.test_sync_only )
MYPYPATH=$MYPYPATH:test/generated_sync_only mypy ${CUSTOM_TYPESHED_DIR_ARG:+"$CUSTOM_TYPESHED_DIR_ARG"} --report-deprecated-as-note --python-executable="$UNIT_TESTS_VENV"/bin/python --python-version="$PY_VER_MYPY_TARGET" "${SYNC_ONLY_MODULES[@]}"

# Run async_only mypy
ASYNC_ONLY_MODULES=( -m test.async_only.test_async_only )
MYPYPATH=$MYPYPATH:test/generated_async_only mypy ${CUSTOM_TYPESHED_DIR_ARG:+"$CUSTOM_TYPESHED_DIR_ARG"} --report-deprecated-as-note --python-executable="$UNIT_TESTS_VENV"/bin/python --python-version="$PY_VER_MYPY_TARGET" "${ASYNC_ONLY_MODULES[@]}"

export MYPYPATH=$MYPYPATH:test/generated

# Run mypy
Expand Down Expand Up @@ -210,7 +229,7 @@ for PY_VER in $PY_VER_UNIT_TESTS; do
(
# Run unit tests.
source "$UNIT_TESTS_VENV"/bin/activate
PYTHONPATH=test/generated py.test --ignore=test/generated -v
PYTHONPATH=test/generated py.test --ignore=test/generated --ignore=test/generated_sync_only --ignore=test/generated_async_only -v
)
done

Expand Down
86 changes: 86 additions & 0 deletions test/async_only/test_async_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""
Type-checking and runtime test for async_only GRPC stubs.

This module validates that stubs generated with the only_async flag have the correct types:
- Stub class (not AsyncStub) that only accepts grpc.aio.Channel
- Servicer methods use AsyncIterator for client streaming (not _MaybeAsyncIterator)
- add_XXXServicer_to_server accepts grpc.aio.Server
"""

import grpc.aio
import pytest
import typing_extensions as typing
from testproto.grpc import dummy_pb2, dummy_pb2_grpc

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.9.17

Import "testproto.grpc.dummy_pb2_grpc" could not be resolved from source (reportMissingModuleSource)

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.9.17

Import "testproto.grpc.dummy_pb2" could not be resolved from source (reportMissingModuleSource)

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.10.12

Import "testproto.grpc.dummy_pb2_grpc" could not be resolved from source (reportMissingModuleSource)

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.10.12

Import "testproto.grpc.dummy_pb2" could not be resolved from source (reportMissingModuleSource)

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.14.0

Import "testproto.grpc.dummy_pb2_grpc" could not be resolved from source (reportMissingModuleSource)

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.14.0

Import "testproto.grpc.dummy_pb2" could not be resolved from source (reportMissingModuleSource)

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.11.4

Import "testproto.grpc.dummy_pb2_grpc" could not be resolved from source (reportMissingModuleSource)

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.11.4

Import "testproto.grpc.dummy_pb2" could not be resolved from source (reportMissingModuleSource)

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.13.9

Import "testproto.grpc.dummy_pb2_grpc" could not be resolved from source (reportMissingModuleSource)

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.13.9

Import "testproto.grpc.dummy_pb2" could not be resolved from source (reportMissingModuleSource)

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.12.12

Import "testproto.grpc.dummy_pb2_grpc" could not be resolved from source (reportMissingModuleSource)

Check warning on line 13 in test/async_only/test_async_only.py

View workflow job for this annotation

GitHub Actions / Pyright - 3.12.12

Import "testproto.grpc.dummy_pb2" could not be resolved from source (reportMissingModuleSource)

ADDRESS = "localhost:22225"


class Servicer(dummy_pb2_grpc.DummyServiceServicer):
async def UnaryUnary(
self,
request: dummy_pb2.DummyRequest,
context: grpc.aio.ServicerContext[dummy_pb2.DummyRequest, typing.Awaitable[dummy_pb2.DummyReply]],
) -> dummy_pb2.DummyReply:
return dummy_pb2.DummyReply(value=request.value[::-1])

async def UnaryStream(
self,
request: dummy_pb2.DummyRequest,
context: grpc.aio.ServicerContext[dummy_pb2.DummyRequest, typing.AsyncIterator[dummy_pb2.DummyReply]],
) -> typing.AsyncIterator[dummy_pb2.DummyReply]:
for char in request.value:
yield dummy_pb2.DummyReply(value=char)

async def StreamUnary(
self,
request_iterator: typing.AsyncIterator[dummy_pb2.DummyRequest],
context: grpc.aio.ServicerContext[typing.AsyncIterator[dummy_pb2.DummyRequest], typing.Awaitable[dummy_pb2.DummyReply]],
) -> dummy_pb2.DummyReply:
values = [data.value async for data in request_iterator]
return dummy_pb2.DummyReply(value="".join(values))

async def StreamStream(
self,
request_iterator: typing.AsyncIterator[dummy_pb2.DummyRequest],
context: grpc.aio.ServicerContext[typing.AsyncIterator[dummy_pb2.DummyRequest], typing.AsyncIterator[dummy_pb2.DummyReply]],
) -> typing.AsyncIterator[dummy_pb2.DummyReply]:
async for data in request_iterator:
yield dummy_pb2.DummyReply(value=data.value.upper())


def make_server() -> grpc.aio.Server:
server = grpc.aio.server()
servicer = Servicer()
server.add_insecure_port(ADDRESS)
dummy_pb2_grpc.add_DummyServiceServicer_to_server(servicer, server)
return server


@pytest.mark.asyncio
async def test_async_only_grpc() -> None:
server = make_server()
await server.start()
async with grpc.aio.insecure_channel(ADDRESS) as channel:
client = dummy_pb2_grpc.DummyServiceStub(channel)
request = dummy_pb2.DummyRequest(value="cprg")
result1 = await client.UnaryUnary(request)
result2 = client.UnaryStream(dummy_pb2.DummyRequest(value=result1.value))
result2_list = [r async for r in result2]
assert len(result2_list) == 4
result3 = client.StreamStream(dummy_pb2.DummyRequest(value=part.value) for part in result2_list)
result3_list = [r async for r in result3]
assert len(result3_list) == 4
result4 = await client.StreamUnary(dummy_pb2.DummyRequest(value=part.value) for part in result3_list)
assert result4.value == "GRPC"

await server.stop(None)

class TestAttribute:
stub: "dummy_pb2_grpc.DummyServiceStub"

def __init__(self) -> None:
self.stub = dummy_pb2_grpc.DummyServiceStub(grpc.aio.insecure_channel(ADDRESS))

async def test(self) -> None:
val = await self.stub.UnaryUnary(dummy_pb2.DummyRequest(value="test"))
typing.assert_type(val, dummy_pb2.DummyReply)
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
@generated by mypy-protobuf. Do not edit manually!
isort:skip_file
"""

import builtins
import google.protobuf.descriptor
import google.protobuf.message
import sys
import typing

if sys.version_info >= (3, 10):
import typing as typing_extensions
else:
import typing_extensions

DESCRIPTOR: google.protobuf.descriptor.FileDescriptor

@typing.final
class lower(google.protobuf.message.Message):
DESCRIPTOR: google.protobuf.descriptor.Descriptor

A_FIELD_NUMBER: builtins.int
a: builtins.int
def __init__(
self,
*,
a: builtins.int = ...,
) -> None: ...
_ClearFieldArgType: typing_extensions.TypeAlias = typing.Literal["a", b"a"]
def ClearField(self, field_name: _ClearFieldArgType) -> None: ...

Global___lower: typing_extensions.TypeAlias = lower

@typing.final
class Upper(google.protobuf.message.Message):
DESCRIPTOR: google.protobuf.descriptor.Descriptor

LOWER_FIELD_NUMBER: builtins.int
@property
def Lower(self) -> Global___lower: ...
def __init__(
self,
*,
Lower: Global___lower | None = ...,
) -> None: ...
_HasFieldArgType: typing_extensions.TypeAlias = typing.Literal["Lower", b"Lower"]
def HasField(self, field_name: _HasFieldArgType) -> builtins.bool: ...
_ClearFieldArgType: typing_extensions.TypeAlias = typing.Literal["Lower", b"Lower"]
def ClearField(self, field_name: _ClearFieldArgType) -> None: ...

Global___Upper: typing_extensions.TypeAlias = Upper

@typing.final
class lower2(google.protobuf.message.Message):
DESCRIPTOR: google.protobuf.descriptor.Descriptor

UPPER_FIELD_NUMBER: builtins.int
@property
def upper(self) -> Global___Upper: ...
def __init__(
self,
*,
upper: Global___Upper | None = ...,
) -> None: ...
_HasFieldArgType: typing_extensions.TypeAlias = typing.Literal["upper", b"upper"]
def HasField(self, field_name: _HasFieldArgType) -> builtins.bool: ...
_ClearFieldArgType: typing_extensions.TypeAlias = typing.Literal["upper", b"upper"]
def ClearField(self, field_name: _ClearFieldArgType) -> None: ...

Global___lower2: typing_extensions.TypeAlias = lower2
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""
@generated by mypy-protobuf. Do not edit manually!
isort:skip_file
"""

import collections.abc


GRPC_GENERATED_VERSION: str
GRPC_VERSION: str
Empty file.
Empty file.
83 changes: 83 additions & 0 deletions test/generated_async_only/testproto/comment_special_chars_pb2.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""
@generated by mypy-protobuf. Do not edit manually!
isort:skip_file
"""

import builtins
import google.protobuf.descriptor
import google.protobuf.message
import sys
import typing

if sys.version_info >= (3, 10):
import typing as typing_extensions
else:
import typing_extensions

DESCRIPTOR: google.protobuf.descriptor.FileDescriptor

@typing.final
class Test(google.protobuf.message.Message):
DESCRIPTOR: google.protobuf.descriptor.Descriptor

A_FIELD_NUMBER: builtins.int
B_FIELD_NUMBER: builtins.int
C_FIELD_NUMBER: builtins.int
D_FIELD_NUMBER: builtins.int
E_FIELD_NUMBER: builtins.int
F_FIELD_NUMBER: builtins.int
G_FIELD_NUMBER: builtins.int
H_FIELD_NUMBER: builtins.int
I_FIELD_NUMBER: builtins.int
J_FIELD_NUMBER: builtins.int
K_FIELD_NUMBER: builtins.int
a: builtins.str
"""Ending with " """
b: builtins.str
"""Ending with "" """
c: builtins.str
"""Ending with \"\"\" """
d: builtins.str
"""Ending with \\ """
e: builtins.str
"""Containing bad escape: \\x"""
f: builtins.str
"""Containing \"\"\"" quadruple"""
g: builtins.str
"""Containing \"\"\""" quintuple"""
h: builtins.str
"""Containing \"\"\"\"\"\" sextuple"""
i: builtins.str
"""\"\"\" Multiple \"\"\" triples \"\"\" """
j: builtins.str
""""quotes" can be a problem in comments.
\"\"\"Triple quotes\"\"\" just as well
"""
k: builtins.str
"""\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"
" "
" Super Duper comments with surrounding edges! "
" "
" Pay attention to me!!!! "
" "
\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"
"""
def __init__(
self,
*,
a: builtins.str = ...,
b: builtins.str = ...,
c: builtins.str = ...,
d: builtins.str = ...,
e: builtins.str = ...,
f: builtins.str = ...,
g: builtins.str = ...,
h: builtins.str = ...,
i: builtins.str = ...,
j: builtins.str = ...,
k: builtins.str = ...,
) -> None: ...
_ClearFieldArgType: typing_extensions.TypeAlias = typing.Literal["a", b"a", "b", b"b", "c", b"c", "d", b"d", "e", b"e", "f", b"f", "g", b"g", "h", b"h", "i", b"i", "j", b"j", "k", b"k"]
def ClearField(self, field_name: _ClearFieldArgType) -> None: ...

Global___Test: typing_extensions.TypeAlias = Test
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""
@generated by mypy-protobuf. Do not edit manually!
isort:skip_file
"""

import collections.abc


GRPC_GENERATED_VERSION: str
GRPC_VERSION: str
Empty file.
Empty file.
Loading
Loading