Skip to content

Commit d4fdd2b

Browse files
aidandjAidan Jensen
andauthored
Aidan/pep 702 support (#683)
* Add initial PEP702 support Signed-off-by: Aidan Jensen <aidan.jensen@robust.ai> * Pull comments from deprecation option for warning details Signed-off-by: Aidan Jensen <aidan.jensen@robust.ai> * Use ast.parse to make sure the comment string is a valid python string Signed-off-by: Aidan Jensen <aidan.jensen@robust.ai> --------- Signed-off-by: Aidan Jensen <aidan.jensen@robust.ai> Co-authored-by: Aidan Jensen <aidan.jensen@robust.ai>
1 parent 98e8d5b commit d4fdd2b

22 files changed

+870
-308
lines changed

.flake8

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
per-file-ignores =
33
*.py: E203, E301, E302, E305, E501
44
*.pyi: E301, E302, E305, E501, E701, E741, F401, F403, F405, F822, Y037
5-
*_pb2.pyi: E301, E302, E305, E501, E701, E741, F401, F403, F405, F822, Y037, Y021
6-
*_pb2_grpc.pyi: E301, E302, E305, E501, E701, E741, F401, F403, F405, F822, Y037, Y021, Y023
5+
*_pb2.pyi: E301, E302, E305, E501, E701, E741, F401, F403, F405, F822, Y037, Y021, Y053
6+
*_pb2_grpc.pyi: E301, E302, E305, E501, E701, E741, F401, F403, F405, F822, Y037, Y021, Y023, Y053
77

88
extend_exclude = venv*,*_pb2.py,*_pb2_grpc.py,build/

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
- Change the top-level mangling prefix from `global___` to `Global___` to respect
55
[Y042](https://github.com/PyCQA/flake8-pyi/blob/main/ERRORCODES.md#list-of-warnings) naming convention.
66
- Support client stub async typing overloads
7+
- Support [PEP702](https://peps.python.org/pep-0702/) deprecations
8+
- Message deprecations are supported
9+
- Field deprecations are not. This may be possible with init overloads
10+
- Service deprecations are supported for Sync stubs
11+
- Not for async stubs
12+
- Enum message deprecation is supported
13+
- Enum field deprecation is not
714

815
## 3.6.0
916

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,16 @@ However, it may be helpful when migrating existing proto2 code, where the distin
219219
protoc --python_out=output/location --mypy_out=relax_strict_optional_primitives:output/location
220220
```
221221

222+
### `use_default_deprecation_warnings`
223+
224+
By default mypy-protobuf will pull the leading and trailing comments from the deprecation option definition,
225+
and insert it into the deprecation warning. This option will instead use a standard deprecation warning instead of comments.
226+
227+
```
228+
protoc --python_out=output/location --mypy_out=use_default_deprecation_warning:output/location
229+
230+
```
231+
222232
### Output suppression
223233

224234
To suppress output, you can run

mypy_protobuf/main.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"""Protoc Plugin to generate mypy stubs."""
33
from __future__ import annotations
44

5+
import ast
56
import sys
67
from collections import defaultdict
78
from contextlib import contextmanager
@@ -149,12 +150,14 @@ def __init__(
149150
descriptors: Descriptors,
150151
readable_stubs: bool,
151152
relax_strict_optional_primitives: bool,
153+
use_default_deprecation_warnings: bool,
152154
grpc: bool,
153155
) -> None:
154156
self.fd = fd
155157
self.descriptors = descriptors
156158
self.readable_stubs = readable_stubs
157159
self.relax_strict_optional_primitives = relax_strict_optional_primitives
160+
self.use_default_depreaction_warnings = use_default_deprecation_warnings
158161
self.grpc = grpc
159162
self.lines: List[str] = []
160163
self.indent = ""
@@ -165,6 +168,7 @@ def __init__(
165168
# if {z} is None, then it shortens to `from {x} import {y}`
166169
self.from_imports: Dict[str, Set[Tuple[str, str | None]]] = defaultdict(set)
167170
self.typing_extensions_min: Optional[Tuple[int, int]] = None
171+
self.deprecated_min: Optional[Tuple[int, int]] = None
168172

169173
# Comments
170174
self.source_code_info_by_scl = {tuple(location.path): location for location in fd.source_code_info.location}
@@ -180,6 +184,11 @@ def _import(self, path: str, name: str) -> str:
180184
self.typing_extensions_min = stabilization[name]
181185
return "typing_extensions." + name
182186

187+
if path == "warnings" and name == "deprecated":
188+
if not self.deprecated_min or self.deprecated_min < (3, 11):
189+
self.deprecated_min = (3, 13)
190+
return name
191+
183192
imp = path.replace("/", ".")
184193
if self.readable_stubs:
185194
self.from_imports[imp].add((name, None))
@@ -251,6 +260,51 @@ def _has_comments(self, scl: SourceCodeLocation) -> bool:
251260
sci_loc = self.source_code_info_by_scl.get(tuple(scl))
252261
return sci_loc is not None and bool(sci_loc.leading_detached_comments or sci_loc.leading_comments or sci_loc.trailing_comments)
253262

263+
def _get_comments(self, scl: SourceCodeLocation) -> List[str]:
264+
"""Return list of comment lines"""
265+
if not self._has_comments(scl):
266+
return []
267+
268+
sci_loc = self.source_code_info_by_scl.get(tuple(scl))
269+
assert sci_loc is not None
270+
271+
leading_detached_lines = []
272+
leading_lines = []
273+
trailing_lines = []
274+
for leading_detached_comment in sci_loc.leading_detached_comments:
275+
leading_detached_lines = self._break_text(leading_detached_comment)
276+
if sci_loc.leading_comments is not None:
277+
leading_lines = self._break_text(sci_loc.leading_comments)
278+
# Trailing comments also go in the header - to make sure it gets into the docstring
279+
if sci_loc.trailing_comments is not None:
280+
trailing_lines = self._break_text(sci_loc.trailing_comments)
281+
282+
lines = leading_detached_lines
283+
if leading_detached_lines and (leading_lines or trailing_lines):
284+
lines.append("")
285+
lines.extend(leading_lines)
286+
lines.extend(trailing_lines)
287+
288+
return lines
289+
290+
def _write_deprecation_warning(self, scl: SourceCodeLocation, default_message: str) -> None:
291+
msg = default_message
292+
if not self.use_default_depreaction_warnings and (comments := self._get_comments(scl)):
293+
# Make sure the comment string is a valid python string literal
294+
joined = "\\n".join(comments)
295+
# Check that it is valid python string by using ast.parse
296+
try:
297+
ast.parse(f'"""{joined}"""')
298+
msg = joined
299+
except SyntaxError as e:
300+
print(f"Warning: Deprecation comment {joined} could not be parsed as a python string literal. Using default deprecation message. {e}", file=sys.stderr)
301+
pass
302+
self._write_line(
303+
'@{}("""{}""")',
304+
self._import("warnings", "deprecated"),
305+
msg,
306+
)
307+
254308
def _write_comments(self, scl: SourceCodeLocation) -> bool:
255309
"""Return true if any comments were written"""
256310
if not self._has_comments(scl):
@@ -364,6 +418,11 @@ def write_enums(
364418
)
365419
wl("")
366420

421+
if enum.options.deprecated:
422+
self._write_deprecation_warning(
423+
scl + [d.EnumDescriptorProto.OPTIONS_FIELD_NUMBER] + [d.EnumOptions.DEPRECATED_FIELD_NUMBER],
424+
"This enum has been marked as deprecated using proto enum options.",
425+
)
367426
if self._has_comments(scl):
368427
wl(f"class {class_name}({enum_helper_class}, metaclass={etw_helper_class}):")
369428
with self._indent():
@@ -409,6 +468,11 @@ def write_messages(
409468

410469
class_name = desc.name if desc.name not in PYTHON_RESERVED else "_r_" + desc.name
411470
message_class = self._import("google.protobuf.message", "Message")
471+
if desc.options.deprecated:
472+
self._write_deprecation_warning(
473+
scl_prefix + [i] + [d.DescriptorProto.OPTIONS_FIELD_NUMBER] + [d.MessageOptions.DEPRECATED_FIELD_NUMBER],
474+
"This message has been marked as deprecated using proto message options.",
475+
)
412476
wl("@{}", self._import("typing", "final"))
413477
wl(f"class {class_name}({message_class}{addl_base}):")
414478
with self._indent():
@@ -835,6 +899,11 @@ def write_grpc_services(
835899
self.write_grpc_type_vars(service)
836900

837901
# The stub client
902+
if service.options.deprecated:
903+
self._write_deprecation_warning(
904+
scl + [d.ServiceDescriptorProto.OPTIONS_FIELD_NUMBER] + [d.ServiceOptions.DEPRECATED_FIELD_NUMBER],
905+
"This stub has been marked as deprecated using proto service options.",
906+
)
838907
class_name = f"{service.name}Stub"
839908
wl(
840909
"class {}({}[{}]):",
@@ -875,6 +944,11 @@ def write_grpc_services(
875944
wl("")
876945

877946
# The service definition interface
947+
if service.options.deprecated:
948+
self._write_deprecation_warning(
949+
scl + [d.ServiceDescriptorProto.OPTIONS_FIELD_NUMBER] + [d.ServiceOptions.DEPRECATED_FIELD_NUMBER],
950+
"This servicer has been marked as deprecated using proto service options.",
951+
)
878952
wl(
879953
"class {}Servicer(metaclass={}):",
880954
service.name,
@@ -886,6 +960,11 @@ def write_grpc_services(
886960
self.write_grpc_methods(service, scl)
887961
server = self._import("grpc", "Server")
888962
aserver = self._import("grpc.aio", "Server")
963+
if service.options.deprecated:
964+
self._write_deprecation_warning(
965+
scl + [d.ServiceDescriptorProto.OPTIONS_FIELD_NUMBER] + [d.ServiceOptions.DEPRECATED_FIELD_NUMBER],
966+
"This servicer has been marked as deprecated using proto service options.",
967+
)
889968
wl(
890969
"def add_{}Servicer_to_server(servicer: {}Servicer, server: {}) -> None: ...",
891970
service.name,
@@ -1001,7 +1080,7 @@ def write(self) -> str:
10011080
# n,n to force a reexport (from x import y as y)
10021081
self.from_imports[reexport_imp].update((n, n) for n in names)
10031082

1004-
if self.typing_extensions_min:
1083+
if self.typing_extensions_min or self.deprecated_min:
10051084
self.imports.add("sys")
10061085
for pkg in sorted(self.imports):
10071086
self._write_line(f"import {pkg}")
@@ -1011,6 +1090,12 @@ def write(self) -> str:
10111090
self._write_line(" import typing as typing_extensions")
10121091
self._write_line("else:")
10131092
self._write_line(" import typing_extensions")
1093+
if self.deprecated_min:
1094+
self._write_line("")
1095+
self._write_line(f"if sys.version_info >= {self.deprecated_min}:")
1096+
self._write_line(" from warnings import deprecated")
1097+
self._write_line("else:")
1098+
self._write_line(" from typing_extensions import deprecated")
10141099

10151100
for pkg, items in sorted(self.from_imports.items()):
10161101
self._write_line(f"from {pkg} import (")
@@ -1041,13 +1126,15 @@ def generate_mypy_stubs(
10411126
quiet: bool,
10421127
readable_stubs: bool,
10431128
relax_strict_optional_primitives: bool,
1129+
use_default_deprecation_warnings: bool,
10441130
) -> None:
10451131
for name, fd in descriptors.to_generate.items():
10461132
pkg_writer = PkgWriter(
10471133
fd,
10481134
descriptors,
10491135
readable_stubs,
10501136
relax_strict_optional_primitives,
1137+
use_default_deprecation_warnings,
10511138
grpc=False,
10521139
)
10531140

@@ -1073,13 +1160,15 @@ def generate_mypy_grpc_stubs(
10731160
quiet: bool,
10741161
readable_stubs: bool,
10751162
relax_strict_optional_primitives: bool,
1163+
use_default_deprecation_warnings: bool,
10761164
) -> None:
10771165
for name, fd in descriptors.to_generate.items():
10781166
pkg_writer = PkgWriter(
10791167
fd,
10801168
descriptors,
10811169
readable_stubs,
10821170
relax_strict_optional_primitives,
1171+
use_default_deprecation_warnings,
10831172
grpc=True,
10841173
)
10851174
pkg_writer.write_grpc_async_hacks()
@@ -1131,6 +1220,7 @@ def main() -> None:
11311220
"quiet" in request.parameter,
11321221
"readable_stubs" in request.parameter,
11331222
"relax_strict_optional_primitives" in request.parameter,
1223+
"use_default_deprecation_warnings" in request.parameter,
11341224
)
11351225

11361226

@@ -1143,6 +1233,7 @@ def grpc() -> None:
11431233
"quiet" in request.parameter,
11441234
"readable_stubs" in request.parameter,
11451235
"relax_strict_optional_primitives" in request.parameter,
1236+
"use_default_deprecation_warnings" in request.parameter,
11461237
)
11471238

11481239

mypy_requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
# Requirements to run mypy itself. Mypy executable exists in a separate venv.
2-
mypy==1.13.0
2+
mypy==1.14.1

proto/testproto/grpc/dummy.proto

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,22 @@ service DummyService {
2222
// StreamStream
2323
rpc StreamStream (stream DummyRequest) returns (stream DummyReply) {}
2424
}
25+
26+
message DeprecatedRequest {
27+
option deprecated = true;
28+
string old_field = 1 [deprecated = true];
29+
}
30+
31+
// Marking the service as deprecated
32+
service DeprecatedService {
33+
// This service is deprecated
34+
option deprecated = true;
35+
// DeprecatedMethod
36+
rpc DeprecatedMethod (DeprecatedRequest) returns (DummyReply) {
37+
option deprecated = true;
38+
}
39+
// DeprecatedMethodNotDeprecatedRequest
40+
rpc DeprecatedMethodNotDeprecatedRequest (DummyRequest) returns (DummyReply) {
41+
option deprecated = true;
42+
}
43+
}

proto/testproto/test.proto

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,30 @@ message SelfField {
180180
// Field self -> must generate an __init__ method w/ different name
181181
optional int64 self = 1;
182182
}
183+
184+
message DeprecatedMessage {
185+
// This message is deprecated
186+
option deprecated = true;
187+
188+
optional string a_string = 1;
189+
}
190+
191+
message DeprecatedMessageBadComment {
192+
// This message is deprecated
193+
// """ triple quotes in comment
194+
option deprecated = true;
195+
196+
optional string a_string = 1;
197+
}
198+
199+
enum DeprecatedEnum {
200+
// This enum is deprecated
201+
// 2 lines of comments
202+
// "Quotes in comments"
203+
// and 'single quotes'
204+
option deprecated = true;
205+
// Trailing comment
206+
207+
DEPRECATED_ONE = 1;
208+
DEPRECATED_TWO = 2;
209+
}

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ extend_skip_glob = ["*_pb2.py"]
1818
[tool.mypy]
1919
strict = true
2020
show_error_codes = true
21+
enable_error_code = ["deprecated"]
22+
report_deprecated_as_note = true
2123

2224
[tool.pyright]
2325
venvPath = "."

run_test.sh

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@ NC='\033[0m'
66
PY_VER_MYPY_PROTOBUF=${PY_VER_MYPY_PROTOBUF:=3.11.4}
77
PY_VER_MYPY_PROTOBUF_SHORT=$(echo "$PY_VER_MYPY_PROTOBUF" | cut -d. -f1-2)
88
PY_VER_MYPY=${PY_VER_MYPY:=3.8.17}
9-
PY_VER_UNIT_TESTS="${PY_VER_UNIT_TESTS:=3.8.17}"
9+
PY_VER_UNIT_TESTS="${PY_VER_UNIT_TESTS:=3.8.17 3.13.9 3.14.0}"
10+
11+
1012

1113
if [ -e "$CUSTOM_TYPESHED_DIR" ]; then
1214
export MYPYPATH=$CUSTOM_TYPESHED_DIR/stubs/protobuf
15+
export CUSTOM_TYPESHED_DIR_ARG="--custom-typeshed-dir=$CUSTOM_TYPESHED_DIR"
16+
else
17+
# mypy does not emit deprecation warnings for typeshed stubs. Setting an empty custom-typeshed-dir was causing the current directory to be considered a typeshed dir, and hiding deprecation warnings.
18+
export CUSTOM_TYPESHED_DIR_ARG=""
1319
fi
1420

1521
# Install protoc
@@ -101,7 +107,7 @@ MYPY_PROTOBUF_VENV=venv_$PY_VER_MYPY_PROTOBUF
101107

102108
# Run mypy on mypy-protobuf internal code for developers to catch issues
103109
FILES="mypy_protobuf/main.py"
104-
"$MYPY_VENV/bin/mypy" --custom-typeshed-dir="$CUSTOM_TYPESHED_DIR" --python-executable="$MYPY_PROTOBUF_VENV/bin/python3" --python-version="$PY_VER_MYPY_PROTOBUF_SHORT" $FILES
110+
"$MYPY_VENV/bin/mypy" ${CUSTOM_TYPESHED_DIR_ARG:+"$CUSTOM_TYPESHED_DIR_ARG"} --python-executable="$MYPY_PROTOBUF_VENV/bin/python3" --python-version="$PY_VER_MYPY_PROTOBUF_SHORT" $FILES
105111

106112
# Generate protos
107113
python --version
@@ -124,6 +130,7 @@ MYPY_PROTOBUF_VENV=venv_$PY_VER_MYPY_PROTOBUF
124130
find proto -name "*.proto" -print0 | xargs -0 "$PROTOC" "${PROTOC_ARGS[@]}" --mypy_out=quiet:test/generated
125131
find proto -name "*.proto" -print0 | xargs -0 "$PROTOC" "${PROTOC_ARGS[@]}" --mypy_out=readable_stubs:test/generated
126132
find proto -name "*.proto" -print0 | xargs -0 "$PROTOC" "${PROTOC_ARGS[@]}" --mypy_out=relax_strict_optional_primitives:test/generated
133+
find proto -name "*.proto" -print0 | xargs -0 "$PROTOC" "${PROTOC_ARGS[@]}" --mypy_out=use_default_deprecation_warnings:test/generated
127134
# Overwrite w/ run with mypy-protobuf without flags
128135
find proto -name "*.proto" -print0 | xargs -0 "$PROTOC" "${PROTOC_ARGS[@]}" --mypy_out=test/generated
129136

@@ -153,13 +160,13 @@ for PY_VER in $PY_VER_UNIT_TESTS; do
153160

154161
# Run mypy
155162
MODULES=( -m test.test_generated_mypy -m test.test_grpc_usage -m test.test_grpc_async_usage )
156-
mypy --custom-typeshed-dir="$CUSTOM_TYPESHED_DIR" --python-executable="$UNIT_TESTS_VENV"/bin/python --python-version="$PY_VER_MYPY_TARGET" "${MODULES[@]}"
163+
mypy ${CUSTOM_TYPESHED_DIR_ARG:+"$CUSTOM_TYPESHED_DIR_ARG"} --python-executable="$UNIT_TESTS_VENV"/bin/python --python-version="$PY_VER_MYPY_TARGET" "${MODULES[@]}"
157164

158165
# Run stubtest. Stubtest does not work with python impl - only cpp impl
159166
pip install -r test_requirements.txt
160167
API_IMPL="$(python3 -c "import google.protobuf.internal.api_implementation as a ; print(a.Type())")"
161168
if [[ $API_IMPL != "python" ]]; then
162-
PYTHONPATH=test/generated python3 -m mypy.stubtest --custom-typeshed-dir="$CUSTOM_TYPESHED_DIR" --allowlist stubtest_allowlist.txt testproto
169+
PYTHONPATH=test/generated python3 -m mypy.stubtest ${CUSTOM_TYPESHED_DIR_ARG:+"$CUSTOM_TYPESHED_DIR_ARG"} --allowlist stubtest_allowlist.txt testproto
163170
fi
164171

165172
# run mypy on negative-tests (expected mypy failures)
@@ -171,7 +178,7 @@ for PY_VER in $PY_VER_UNIT_TESTS; do
171178
PY_VER_MYPY_TARGET=$(echo "$1" | cut -d. -f1-2)
172179
export MYPYPATH=$MYPYPATH:test/generated
173180
# Use --no-incremental to avoid caching issues: https://github.com/python/mypy/issues/16363
174-
mypy --custom-typeshed-dir="$CUSTOM_TYPESHED_DIR" --python-executable="venv_$1/bin/python" --no-incremental --python-version="$PY_VER_MYPY_TARGET" "${@: 2}" > "$MYPY_OUTPUT/mypy_output" || true
181+
mypy ${CUSTOM_TYPESHED_DIR_ARG:+"$CUSTOM_TYPESHED_DIR_ARG"} --python-executable="venv_$1/bin/python" --no-incremental --python-version="$PY_VER_MYPY_TARGET" "${@: 2}" > "$MYPY_OUTPUT/mypy_output" || true
175182
cut -d: -f1,3- "$MYPY_OUTPUT/mypy_output" > "$MYPY_OUTPUT/mypy_output.omit_linenos"
176183
}
177184

0 commit comments

Comments
 (0)