Skip to content

Commit d8f24c3

Browse files
aidandjAidan Jensen
andauthored
initial edition 2024 support (#689)
Signed-off-by: Aidan Jensen <aidan.jensen@robust.ai> Co-authored-by: Aidan Jensen <aidan.jensen@robust.ai>
1 parent 6d95984 commit d8f24c3

19 files changed

+808
-637
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Drop testing support for protobuf <6.32 because they don't support editions
66
- With some more work this could be added back in a testing refactor
77
- Protobuf <6.32 still had the edition enums and field options, so it *should* still work. But is untested
8+
- Add support for editions (up to 2024)
89

910
## 3.7.0
1011

mypy_protobuf/main.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -540,8 +540,9 @@ def write_messages(
540540
# See https://github.com/nipunn1313/mypy-protobuf/issues/71
541541
wl("*,")
542542
for field in constructor_fields:
543+
implicit_presence = field.options.features.field_presence == d.FeatureSet.FieldPresence.IMPLICIT
543544
field_type = self.python_type(field, generic_container=True)
544-
if self.fd.syntax == "proto3" and is_scalar(field) and field.label != d.FieldDescriptorProto.LABEL_REPEATED and not self.relax_strict_optional_primitives and not field.proto3_optional:
545+
if (implicit_presence and self.fd.syntax == "editions") or (self.fd.syntax == "proto3" and is_scalar(field) and field.label != d.FieldDescriptorProto.LABEL_REPEATED and not self.relax_strict_optional_primitives and not field.proto3_optional):
545546
wl(f"{field.name}: {field_type} = ...,")
546547
else:
547548
wl(f"{field.name}: {field_type} | None = ...,")
@@ -561,7 +562,7 @@ def write_stringly_typed_fields(self, desc: d.DescriptorProto) -> None:
561562
# HasField only supports singular. ClearField supports repeated as well
562563
# In proto3, HasField only supports message fields and optional fields
563564
# HasField always supports oneof fields
564-
hf_fields = [f.name for f in desc.field if f.HasField("oneof_index") or (f.label != d.FieldDescriptorProto.LABEL_REPEATED and (self.fd.syntax != "proto3" or f.type == d.FieldDescriptorProto.TYPE_MESSAGE or f.proto3_optional))]
565+
hf_fields = [f.name for f in desc.field if f.HasField("oneof_index") or (self.fd.syntax == "editions" and f.options.features.field_presence != d.FeatureSet.FieldPresence.IMPLICIT) or (f.label != d.FieldDescriptorProto.LABEL_REPEATED and (self.fd.syntax in ("proto2", "") or f.type == d.FieldDescriptorProto.TYPE_MESSAGE or f.proto3_optional))]
565566
cf_fields = [f.name for f in desc.field]
566567
wo_fields = {oneof.name: [f.name for f in desc.field if f.HasField("oneof_index") and f.oneof_index == idx] for idx, oneof in enumerate(desc.oneof_decl)}
567568

@@ -1140,8 +1141,6 @@ def generate_mypy_stubs(
11401141
pkg_writer.write_enums(fd.enum_type, "", [d.FileDescriptorProto.ENUM_TYPE_FIELD_NUMBER])
11411142
pkg_writer.write_messages(fd.message_type, "", [d.FileDescriptorProto.MESSAGE_TYPE_FIELD_NUMBER])
11421143
pkg_writer.write_extensions(fd.extension, [d.FileDescriptorProto.EXTENSION_FIELD_NUMBER])
1143-
if fd.options.py_generic_services:
1144-
pkg_writer.write_services(fd.service, [d.FileDescriptorProto.SERVICE_FIELD_NUMBER])
11451144

11461145
assert name == fd.name
11471146
assert fd.name.endswith(".proto")
@@ -1199,6 +1198,10 @@ def code_generation() -> Iterator[Tuple[plugin_pb2.CodeGeneratorRequest, plugin_
11991198

12001199
# Declare support for optional proto3 fields
12011200
response.supported_features |= plugin_pb2.CodeGeneratorResponse.FEATURE_PROTO3_OPTIONAL
1201+
response.supported_features |= plugin_pb2.CodeGeneratorResponse.FEATURE_SUPPORTS_EDITIONS
1202+
1203+
response.minimum_edition = d.EDITION_LEGACY
1204+
response.maximum_edition = d.EDITION_2024
12021205

12031206
yield request, response
12041207

proto/testproto/edition2024.proto

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Edition version of proto2 file
2+
edition = "2024";
3+
4+
package test;
5+
6+
option features.utf8_validation = NONE;
7+
option features.enforce_naming_style = STYLE_LEGACY;
8+
option features.default_symbol_visibility = EXPORT_ALL;
9+
10+
message Editions2024SubMessage {
11+
string thing = 1;
12+
}
13+
14+
message Editions2024Test {
15+
// Expect to be always set
16+
string legacy = 1 [features.field_presence = LEGACY_REQUIRED];
17+
// Expect HasField generated
18+
string explicit_singular = 2 [features.field_presence = EXPLICIT];
19+
// Expect HasField generated?
20+
Editions2024SubMessage message_field = 3 [features.field_presence = EXPLICIT];
21+
// Expect implicit field presence, no HasField generated
22+
string implicit_singular = 4 [features.field_presence = IMPLICIT];
23+
// Not set, should default to EXPLICIT
24+
string default_singular = 5;
25+
26+
}

stubtest_allowlist.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ testproto.test_pb2.Simple1.no_package
103103
testproto.test_pb2.Simple1.outer_message_in_oneof
104104
testproto.test_pb2.Simple1.rep_inner_enum
105105
testproto.test_pb2.Simple1.rep_inner_message
106+
testproto.edition2024_pb2.Editions2024Test.message_field
106107

107108
# All messages now fail with something like this:
108109
# error: testproto.nested.nested_pb2.Nested is inconsistent, metaclass differs
@@ -150,6 +151,8 @@ testproto.test_pb2.Simple1.InnerMessage
150151
testproto.test_pb2.Simple2
151152
testproto.test_pb2.DeprecatedMessage
152153
testproto.test_pb2.DeprecatedMessageBadComment
154+
testproto.edition2024_pb2.Editions2024SubMessage
155+
testproto.edition2024_pb2.Editions2024Test
153156

154157
# All messages now fail with something like this:
155158
# error: testproto.nopackage_pb2.NoPackage2.DESCRIPTOR variable differs from runtime type google._upb._message.Descriptor
@@ -197,6 +200,8 @@ testproto.test_pb2.Simple1.InnerMessage.DESCRIPTOR
197200
testproto.test_pb2.Simple2.DESCRIPTOR
198201
testproto.test_pb2.DeprecatedMessage.DESCRIPTOR
199202
testproto.test_pb2.DeprecatedMessageBadComment.DESCRIPTOR
203+
testproto.edition2024_pb2.Editions2024SubMessage.DESCRIPTOR
204+
testproto.edition2024_pb2.Editions2024Test.DESCRIPTOR
200205
testproto.test_pb2.DESCRIPTOR
201206
testproto.test_no_generic_services_pb2.DESCRIPTOR
202207
testproto.test_extensions2_pb2.DESCRIPTOR
@@ -208,6 +213,7 @@ testproto.nested.nested_pb2.DESCRIPTOR
208213
testproto.inner.inner_pb2.DESCRIPTOR
209214
testproto.grpc.import_pb2.DESCRIPTOR
210215
testproto.grpc.dummy_pb2.DESCRIPTOR
216+
testproto.edition2024_pb2.DESCRIPTOR
211217
testproto.dot.com.test_pb2.DESCRIPTOR
212218
testproto.comment_special_chars_pb2.DESCRIPTOR
213219
testproto.Capitalized.Capitalized_pb2.DESCRIPTOR
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
@generated by mypy-protobuf. Do not edit manually!
3+
isort:skip_file
4+
Edition version of proto2 file"""
5+
6+
import builtins
7+
import google.protobuf.descriptor
8+
import google.protobuf.message
9+
import sys
10+
import typing
11+
12+
if sys.version_info >= (3, 10):
13+
import typing as typing_extensions
14+
else:
15+
import typing_extensions
16+
17+
DESCRIPTOR: google.protobuf.descriptor.FileDescriptor
18+
19+
@typing.final
20+
class Editions2024SubMessage(google.protobuf.message.Message):
21+
DESCRIPTOR: google.protobuf.descriptor.Descriptor
22+
23+
THING_FIELD_NUMBER: builtins.int
24+
thing: builtins.str
25+
def __init__(
26+
self,
27+
*,
28+
thing: builtins.str | None = ...,
29+
) -> None: ...
30+
def HasField(self, field_name: typing.Literal["thing", b"thing"]) -> builtins.bool: ...
31+
def ClearField(self, field_name: typing.Literal["thing", b"thing"]) -> None: ...
32+
33+
Global___Editions2024SubMessage: typing_extensions.TypeAlias = Editions2024SubMessage
34+
35+
@typing.final
36+
class Editions2024Test(google.protobuf.message.Message):
37+
DESCRIPTOR: google.protobuf.descriptor.Descriptor
38+
39+
LEGACY_FIELD_NUMBER: builtins.int
40+
EXPLICIT_SINGULAR_FIELD_NUMBER: builtins.int
41+
MESSAGE_FIELD_FIELD_NUMBER: builtins.int
42+
IMPLICIT_SINGULAR_FIELD_NUMBER: builtins.int
43+
DEFAULT_SINGULAR_FIELD_NUMBER: builtins.int
44+
legacy: builtins.str
45+
"""Expect to be always set"""
46+
explicit_singular: builtins.str
47+
"""Expect HasField generated"""
48+
implicit_singular: builtins.str
49+
"""Expect implicit field presence, no HasField generated"""
50+
default_singular: builtins.str
51+
"""Not set, should default to EXPLICIT"""
52+
@property
53+
def message_field(self) -> Global___Editions2024SubMessage:
54+
"""Expect HasField generated?"""
55+
56+
def __init__(
57+
self,
58+
*,
59+
legacy: builtins.str | None = ...,
60+
explicit_singular: builtins.str | None = ...,
61+
message_field: Global___Editions2024SubMessage | None = ...,
62+
implicit_singular: builtins.str = ...,
63+
default_singular: builtins.str | None = ...,
64+
) -> None: ...
65+
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: ...
66+
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: ...
67+
68+
Global___Editions2024Test: typing_extensions.TypeAlias = Editions2024Test

test/test_generated_mypy.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ def _is_summary(line: str) -> bool:
6868

6969
def test_generate_mypy_matches() -> None:
7070
proto_files = glob.glob("proto/**/*.proto", recursive=True)
71-
assert len(proto_files) == 17 # Just a sanity check that all the files show up
71+
assert len(proto_files) == 18 # Just a sanity check that all the files show up
7272

7373
pyi_files = glob.glob("test/generated/**/*.pyi", recursive=True)
74-
assert len(pyi_files) == 19 # Should be higher - because grpc files generate extra pyis
74+
assert len(pyi_files) == 20 # Should be higher - because grpc files generate extra pyis
7575

7676
failure_check_results = []
7777
for fn in proto_files:
@@ -121,7 +121,7 @@ def grab_expectations(filename: str, marker: str) -> Generator[Tuple[str, int],
121121
assert errors_39 == expected_errors_39
122122

123123
# Some sanity checks to make sure we don't mess this up. Please update as necessary.
124-
assert len(errors_39) == 81
124+
assert len(errors_39) == 83
125125

126126

127127
def test_func() -> None:
@@ -524,3 +524,30 @@ def test_reserved_keywords() -> None:
524524
prk = PythonReservedKeywords(none=none_instance, valid=PythonReservedKeywords.valid_in_finally)
525525
assert prk.none.valid == 5
526526
assert prk.valid == PythonReservedKeywords.valid_in_finally
527+
528+
529+
def test_editions_2024() -> None:
530+
from testproto.edition2024_pb2 import Editions2024SubMessage, Editions2024Test
531+
532+
submsg = Editions2024SubMessage(thing="example")
533+
534+
testmsg = Editions2024Test(
535+
legacy="legacy value",
536+
explicit_singular="explicit value",
537+
message_field=submsg,
538+
implicit_singular="implicit value",
539+
default_singular="default value",
540+
)
541+
assert testmsg.legacy == "legacy value"
542+
assert testmsg.explicit_singular == "explicit value"
543+
assert testmsg.message_field == submsg
544+
assert testmsg.implicit_singular == "implicit value"
545+
assert testmsg.default_singular == "default value"
546+
547+
assert testmsg.HasField("explicit_singular")
548+
assert testmsg.HasField("message_field")
549+
assert testmsg.HasField("legacy")
550+
assert testmsg.HasField("default_singular")
551+
552+
with pytest.raises(ValueError):
553+
testmsg.HasField("implicit_singular") # type: ignore

test_negative/negative.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import grpc
1212
import grpc.aio
1313
from testproto.dot.com.test_pb2 import TestMessage
14+
from testproto.edition2024_pb2 import Editions2024Test
1415
from testproto.grpc.dummy_pb2 import ( # E:3.8
1516
DeprecatedRequest,
1617
DummyReply,
@@ -318,3 +319,18 @@ def DeprecatedMethodNotDeprecatedRequest(
318319
async_stub2 = DeprecatedServiceAsyncStub(grpc.aio.insecure_channel("")) # Not deprecating async stub at this time
319320

320321
de = DeprecatedEnum.DEPRECATED_ONE
322+
323+
# Edition 2024 tests
324+
325+
ed = Editions2024Test(
326+
legacy=None,
327+
explicit_singular=None,
328+
message_field=None,
329+
default_singular=None,
330+
implicit_singular=None, # E:3.8
331+
)
332+
ed.HasField("implicit_singular") # E:3.8
333+
ed.HasField("legacy") # Should be generated
334+
ed.HasField("explicit_singular") # Should be generated
335+
ed.HasField("message_field") # Should be generated
336+
ed.HasField("default_singular") # Should be generated

0 commit comments

Comments
 (0)