Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
- Add `_WhichOneofArgType_<oneof_name>` and `_WhichOneofReturnType_<oneof_name>` type aliases
- Use `__new__` overloads for async stubs instead of `TypeVar` based `__init__` overloads.
- https://github.com/nipunn1313/mypy-protobuf/issues/707
- Export stub methods as properties instead of attributes if deprecated and mark as such
- Export enum fields as properties on class level (not module level) enums if deprecated and mark as such
- Export fields as properties with getters/setters if deprecated and mark as such

## 3.7.0

Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,19 @@ See [Changelog](CHANGELOG.md) for full listing
* `mypy-protobuf` generates correctly typed `HasField`, `WhichOneof`, and `ClearField` methods.
* There are differences in how `mypy-protobuf` and `pyi_out` generate enums. See [this issue](https://github.com/protocolbuffers/protobuf/issues/8175) for details
* Type aliases exported for `HasField`, `WhichOneof` and `ClearField` arguments
* Parses comments as docstrings
* `mypy-protobuf` marks enums, enum values, messages, message fields, services, and methods with `@warnings.deprecated` if the deprecation option is set to true.

#### Examples

`mypy-protobuf`:

```python
"""
@generated by mypy-protobuf. Do not edit manually!
isort:skip_file
Edition version of proto2 file"""

import builtins
import google.protobuf.descriptor
import google.protobuf.message
Expand All @@ -132,8 +139,10 @@ class Editions2024SubMessage(google.protobuf.message.Message):
*,
thing: builtins.str | None = ...,
) -> None: ...
def HasField(self, field_name: typing.Literal["thing", b"thing"]) -> builtins.bool: ...
def ClearField(self, field_name: typing.Literal["thing", b"thing"]) -> None: ...
_HasFieldArgType: typing_extensions.TypeAlias = typing.Literal["thing", b"thing"]
def HasField(self, field_name: _HasFieldArgType) -> builtins.bool: ...
_ClearFieldArgType: typing_extensions.TypeAlias = typing.Literal["thing", b"thing"]
def ClearField(self, field_name: _ClearFieldArgType) -> None: ...

Global___Editions2024SubMessage: typing_extensions.TypeAlias = Editions2024SubMessage

Expand Down Expand Up @@ -167,10 +176,13 @@ class Editions2024Test(google.protobuf.message.Message):
implicit_singular: builtins.str = ...,
default_singular: builtins.str | None = ...,
) -> None: ...
def HasField(self, field_name: typing.Literal["default_singular", b"default_singular", "explicit_singular", b"explicit_singular", "legacy", b"legacy", "message_field", b"message_field"]) -> builtins.bool: ...
def ClearField(self, field_name: typing.Literal["default_singular", b"default_singular", "explicit_singular", b"explicit_singular", "implicit_singular", b"implicit_singular", "legacy", b"legacy", "message_field", b"message_field"]) -> None: ...
_HasFieldArgType: typing_extensions.TypeAlias = typing.Literal["default_singular", b"default_singular", "explicit_singular", b"explicit_singular", "legacy", b"legacy", "message_field", b"message_field"]
def HasField(self, field_name: _HasFieldArgType) -> builtins.bool: ...
_ClearFieldArgType: typing_extensions.TypeAlias = typing.Literal["default_singular", b"default_singular", "explicit_singular", b"explicit_singular", "implicit_singular", b"implicit_singular", "legacy", b"legacy", "message_field", b"message_field"]
def ClearField(self, field_name: _ClearFieldArgType) -> None: ...

Global___Editions2024Test: typing_extensions.TypeAlias = Editions2024Test

```

Builtin pyi generator:
Expand Down
103 changes: 83 additions & 20 deletions mypy_protobuf/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ def _get_comments(self, scl: SourceCodeLocation) -> List[str]:

return lines

def _write_deprecation_warning(self, scl: SourceCodeLocation, default_message: str) -> None:
def _get_deprecation_message(self, scl: SourceCodeLocation, default_message: str) -> str:
msg = default_message
if not self.use_default_depreaction_warnings and (comments := self._get_comments(scl)):
# Make sure the comment string is a valid python string literal
Expand All @@ -327,6 +327,10 @@ def _write_deprecation_warning(self, scl: SourceCodeLocation, default_message: s
except SyntaxError as e:
print(f"Warning: Deprecation comment {joined} could not be parsed as a python string literal. Using default deprecation message. {e}", file=sys.stderr)
pass
return msg

def _write_deprecation_warning(self, scl: SourceCodeLocation, default_message: str) -> None:
msg = self._get_deprecation_message(scl, default_message)
self._write_line(
'@{}("""{}""")',
self._import("warnings", "deprecated"),
Expand Down Expand Up @@ -387,16 +391,32 @@ def write_enum_values(
values: Iterable[Tuple[int, d.EnumValueDescriptorProto]],
value_type: str,
scl_prefix: SourceCodeLocation,
*,
class_attributes: bool = False,
) -> None:
for i, val in values:
if val.name in PYTHON_RESERVED:
continue

scl = scl_prefix + [i]
self._write_line(
f"{val.name}: {value_type} # {val.number}",
)
self._write_comments(scl)
# Class level
if class_attributes and val.options.deprecated:
self._write_line("@property")
self._write_deprecation_warning(
scl + [d.EnumValueDescriptorProto.OPTIONS_FIELD_NUMBER] + [d.EnumOptions.DEPRECATED_FIELD_NUMBER],
"This enum value has been marked as deprecated using proto enum value options.",
)
self._write_line(
f"def {val.name}(self) -> {value_type}: {'' if self._has_comments(scl) else '...'} # {val.number}",
)
with self._indent():
self._write_comments(scl)
# Module level or non-deprecated class level
else:
self._write_line(
f"{val.name}: {value_type} # {val.number}",
)
self._write_comments(scl)

def write_module_attributes(self) -> None:
wl = self._write_line
Expand Down Expand Up @@ -443,6 +463,7 @@ def write_enums(
[(i, v) for i, v in enumerate(enum_proto.value) if v.name not in PROTO_ENUM_RESERVED],
value_type_helper_fq,
scl + [d.EnumDescriptorProto.VALUE_FIELD_NUMBER],
class_attributes=True,
)
wl("")

Expand All @@ -461,6 +482,7 @@ def write_enums(
if prefix == "":
wl("")

# Write the module level constants for enum values
self.write_enum_values(
enumerate(enum_proto.value),
value_type_fq,
Expand Down Expand Up @@ -533,9 +555,33 @@ def write_messages(
continue
field_type = self.python_type(field)
if is_scalar(field) and field.label != d.FieldDescriptorProto.LABEL_REPEATED:
# Scalar non repeated fields are r/w
wl(f"{field.name}: {field_type}")
self._write_comments(scl + [d.DescriptorProto.FIELD_FIELD_NUMBER, idx])
# Scalar non repeated fields are r/w, generate getter and setter if deprecated
scl_field = scl + [d.DescriptorProto.FIELD_FIELD_NUMBER, idx]
deprecation_scl_field = scl_field + [d.FieldDescriptorProto.OPTIONS_FIELD_NUMBER] + [d.FieldOptions.DEPRECATED_FIELD_NUMBER]
if field.options.deprecated:
wl("@property")
self._write_deprecation_warning(
deprecation_scl_field,
"This field has been marked as deprecated using proto field options.",
)
wl(f"def {field.name}(self) -> {field_type}:{' ...' if not self._has_comments(scl_field) else ''}")
if self._has_comments(scl_field):
with self._indent():
self._write_comments(scl_field)
wl("")
wl(f"@{field.name}.setter")
self._write_deprecation_warning(
deprecation_scl_field,
"This field has been marked as deprecated using proto field options.",
)
wl(f"def {field.name}(self, value: {field_type}) -> None:{' ...' if not self._has_comments(scl_field) else ''}")
if self._has_comments(scl_field):
with self._indent():
self._write_comments(scl_field)
wl("")
else:
wl(f"{field.name}: {field_type}")
self._write_comments(scl_field)

for idx, field in enumerate(desc.field):
if field.name in PYTHON_RESERVED:
Expand All @@ -545,8 +591,12 @@ def write_messages(
# r/o Getters for non-scalar fields and scalar-repeated fields
scl_field = scl + [d.DescriptorProto.FIELD_FIELD_NUMBER, idx]
wl("@property")
body = " ..." if not self._has_comments(scl_field) else ""
wl(f"def {field.name}(self) -> {field_type}:{body}")
if field.options.deprecated:
self._write_deprecation_warning(
scl_field + [d.FieldDescriptorProto.OPTIONS_FIELD_NUMBER] + [d.FieldOptions.DEPRECATED_FIELD_NUMBER],
"This field has been marked as deprecated using proto field options.",
)
wl(f"def {field.name}(self) -> {field_type}:{' ...' if not self._has_comments(scl_field) else ''}")
if self._has_comments(scl_field):
with self._indent():
self._write_comments(scl_field)
Expand Down Expand Up @@ -878,7 +928,7 @@ def write_grpc_servicer_context(self) -> None:
wl("...")
wl("")

def write_grpc_stub_methods(self, service: d.ServiceDescriptorProto, scl_prefix: SourceCodeLocation, *, is_async: bool, both: bool = False, ignore_assignment_errors: bool = False) -> None:
def write_grpc_stub_methods(self, service: d.ServiceDescriptorProto, scl_prefix: SourceCodeLocation, *, is_async: bool, both: bool = False, ignore_type_error: bool = False) -> None:
wl = self._write_line
methods = [(i, m) for i, m in enumerate(service.method) if m.name not in PYTHON_RESERVED]
if not methods:
Expand All @@ -889,17 +939,30 @@ def type_str(method: d.MethodDescriptorProto, is_async: bool) -> str:

for i, method in methods:
scl = scl_prefix + [d.ServiceDescriptorProto.METHOD_FIELD_NUMBER, i]
is_deprecated = method.options.deprecated
has_comments = self._has_comments(scl)

# Generate type annotation once
if both:
wl(
"{}: {}[{}, {}]",
method.name,
self._import("typing", "Union"),
type_str(method, is_async=False),
type_str(method, is_async=True),
type_annotation = f"{self._import('typing', 'Union')}[{type_str(method, is_async=False)}, {type_str(method, is_async=True)}]"
else:
type_annotation = type_str(method, is_async=is_async)

if is_deprecated:
wl("@property")
self._write_deprecation_warning(
scl + [d.MethodDescriptorProto.OPTIONS_FIELD_NUMBER, d.MethodOptions.DEPRECATED_FIELD_NUMBER],
"This method has been marked as deprecated using proto method options.",
)
wl(f"def {method.name}(self) -> {type_annotation}:{' ...' if not has_comments else ''}{' # type: ignore[override]' if ignore_type_error else ''}")

if has_comments:
with self._indent():
if not self._write_comments(scl):
wl("...")
else:
wl("{}: {}{}", method.name, type_str(method, is_async=is_async), "" if not ignore_assignment_errors else " # type: ignore[assignment]")
self._write_comments(scl)
wl(f"{method.name}: {type_annotation}{' # type: ignore[assignment]' if ignore_type_error else ''}")
self._write_comments(scl)

def write_grpc_methods(self, service: d.ServiceDescriptorProto, scl_prefix: SourceCodeLocation) -> None:
wl = self._write_line
Expand Down Expand Up @@ -1016,7 +1079,7 @@ def write_grpc_services(
if self._write_comments(scl):
wl("")
wl("def __init__(self, channel: {}) -> None: ...", self._import("grpc.aio", "Channel"))
self.write_grpc_stub_methods(service, scl, is_async=True, ignore_assignment_errors=True)
self.write_grpc_stub_methods(service, scl, is_async=True, ignore_type_error=True)
else:
# ASYNC only - use Stub name (not AsyncStub) since there's only one type
wl("class {}:", class_name)
Expand Down
4 changes: 4 additions & 0 deletions proto/testproto/grpc/dummy.proto
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ service DeprecatedService {
}
// DeprecatedMethodNotDeprecatedRequest
rpc DeprecatedMethodNotDeprecatedRequest(DummyRequest) returns (DummyReply) {
// Method is deprecated, but request message is not
option deprecated = true;
}
rpc DeprecatedMethodNoComments(DeprecatedRequest) returns (DummyReply) {
option deprecated = true;
}
}
Expand Down
11 changes: 9 additions & 2 deletions proto/testproto/test.proto
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ message DeprecatedMessage {
option deprecated = true;

optional string a_string = 1;
optional string deprecated_field = 2 [deprecated = true];
// This field is deprecated with comment
optional string deprecated_field_with_comment = 3 [deprecated = true];
}

message DeprecatedMessageBadComment {
Expand All @@ -193,6 +196,10 @@ enum DeprecatedEnum {
option deprecated = true;
// Trailing comment

DEPRECATED_ONE = 1;
DEPRECATED_TWO = 2;
DEPRECATED_ONE = 1 [
// This enum value is deprecated but this message doesn't show
deprecated = true
]; // Trailing comment for enum value
DEPRECATED_TWO = 2 [deprecated = true];
NOT_DEPRECATED = 3;
}
2 changes: 1 addition & 1 deletion 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", "*_pb2_grpc.py"]
extend_skip_glob = ["*_pb2.py"]

[tool.mypy]
strict = true
Expand Down
1 change: 1 addition & 0 deletions run_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ for PY_VER in $PY_VER_UNIT_TESTS; do
if [[ "$PY_VER_MYPY" == "3.13.9" ]] || [[ "$PY_VER_MYPY" == "3.14.0" ]]; then
echo "Skipping stubtest for Python $PY_VER_MYPY until positional argument decision is made"
else
echo "Running stubtest for Python $PY_VER_MYPY with API implementation: $API_IMPL"
PYTHONPATH=test/generated python3 -m mypy.stubtest ${CUSTOM_TYPESHED_DIR_ARG:+"$CUSTOM_TYPESHED_DIR_ARG"} --allowlist stubtest_allowlist.txt testproto
fi
fi
Expand Down
16 changes: 11 additions & 5 deletions stubtest_allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ testproto.nested.nested_pb2.AnotherNested.NestedMessage.NESTED_ENUM2
testproto.nested.nested_pb2.AnotherNested.INVALID
testproto.test_pb2.DEPRECATED_ONE
testproto.test_pb2.DEPRECATED_TWO
testproto.test_pb2.NOT_DEPRECATED

# Our enum types and helper types aren't there at runtime (Dynamic EnumTypeWrapper at runtime)
# .*\..*EnumTypeWrapper$
Expand Down Expand Up @@ -246,9 +247,14 @@ testproto.test_pb2.Extensions1.ext
.*_WhichOneofReturnType.*
.*_WhichOneofArgType.*

#
testproto.grpc.dummy_pb2_grpc.DeprecatedServiceStub.__init__
testproto.grpc.dummy_pb2_grpc.DummyServiceStub.__init__
testproto.grpc.dummy_pb2_grpc.ManyRPCsServiceStub.__init__
testproto.grpc.import_pb2_grpc.SimpleServiceStub.__init__
# Because we represent methods as properties, they do not exist at runtime
testproto.grpc.dummy_pb2_grpc.DeprecatedServiceStub.*
testproto.grpc.dummy_pb2_grpc.DummyServiceStub.*
testproto.grpc.dummy_pb2_grpc.ManyRPCsServiceStub.*
testproto.grpc.import_pb2_grpc.SimpleServiceStub.*
testproto.grpc.dummy_pb2_grpc.EmptyServiceStub.__init__

# Because we generate message fields as properties when deprecated they do not exist at runtime
testproto.grpc.dummy_pb2.DeprecatedRequest.old_field
testproto.test_pb2.DeprecatedMessage.deprecated_field
testproto.test_pb2.DeprecatedMessage.deprecated_field_with_comment
7 changes: 6 additions & 1 deletion test/generated/testproto/grpc/dummy_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ class DeprecatedRequest(google.protobuf.message.Message):
DESCRIPTOR: google.protobuf.descriptor.Descriptor

OLD_FIELD_FIELD_NUMBER: builtins.int
old_field: builtins.str
@property
@deprecated("""This field has been marked as deprecated using proto field options.""")
def old_field(self) -> builtins.str: ...
@old_field.setter
@deprecated("""This field has been marked as deprecated using proto field options.""")
def old_field(self, value: builtins.str) -> None: ...
def __init__(
self,
*,
Expand Down
Loading
Loading