Python: don't emit ClassVar for a file's top-level extensions (#29222)
Fixes #29221.
### Problem
`PyiGenerator::PrintExtensions` is a template instantiated for two different scopes, and emits `_ClassVar[int]` for both:
| call site | descriptor | output scope | `ClassVar` valid |
| --- | --- | --- | --- |
| `pyi_generator.cc:481` `PrintExtensions(message_descriptor)` | `Descriptor` | class body | yes |
| `pyi_generator.cc:656` `PrintExtensions(*public_dep)` | `FileDescriptor` | module | **no** |
| `pyi_generator.cc:669` `PrintExtensions(*file_)` | `FileDescriptor` | module | **no** |
PEP 526 restricts `ClassVar` to class bodies, so a proto declaring a file-level extension generates a `.pyi` that does not type-check.
Given:
```proto
syntax = "proto2";
package probe;
import "google/protobuf/descriptor.proto";
extend google.protobuf.FileOptions {
optional bool top_level_ext = 50001;
}
message Holder {
extend google.protobuf.MessageOptions {
optional bool nested_ext = 50002;
}
}
```
before:
```python
DESCRIPTOR: _descriptor.FileDescriptor
TOP_LEVEL_EXT_FIELD_NUMBER: _ClassVar[int] # module scope
top_level_ext: _descriptor.FieldDescriptor
class Holder(_message.Message):
__slots__ = ()
NESTED_EXT_FIELD_NUMBER: _ClassVar[int] # class body
nested_ext: _descriptor.FieldDescriptor
def __init__(self) -> None: ...
```
```
ext_pb2.pyi:7:1 - error: "ClassVar" is not allowed in this context (reportInvalidTypeForm)
```
Only the module-scope constant is flagged; the nested one is correct as-is.
### Change
`PrintEnumValues` in the same file already carries a `bool is_classvar = false` parameter for exactly this distinction. This gives `PrintExtensions` the same parameter and passes `true` only from the message call site, so the two `FileDescriptor` instantiations fall through to a plain `int` annotation. No template specialisation needed.
After:
```python
TOP_LEVEL_EXT_FIELD_NUMBER: int
```
The nested case is unchanged.
### Notes
- Affects any proto with a top-level `extend`, not only extension-only files — the repro above also contains a message.
- `.pyi` golden/expected outputs containing top-level extensions will need regenerating.
- I have not built or run the test suite locally; relying on CI for that.
Closes #29222
COPYBARA_INTEGRATE_REVIEW=https://github.com/protocolbuffers/protobuf/pull/29222 from folded:pyi-no-classvar-for-toplevel-extensions 53f1b7bb99829727d012b0cf5567ff7217d5aaef
FUTURE_COPYBARA_INTEGRATE_REVIEW=https://github.com/protocolbuffers/protobuf/pull/29222 from folded:pyi-no-classvar-for-toplevel-extensions 53f1b7bb99829727d012b0cf5567ff7217d5aaef
PiperOrigin-RevId: 970646537
diff --git a/src/google/protobuf/compiler/python/plugin_unittest.cc b/src/google/protobuf/compiler/python/plugin_unittest.cc
index e792566..0b8ae69 100644
--- a/src/google/protobuf/compiler/python/plugin_unittest.cc
+++ b/src/google/protobuf/compiler/python/plugin_unittest.cc
@@ -15,6 +15,7 @@
#include "google/protobuf/testing/file.h"
#include <gtest/gtest.h>
#include "absl/log/absl_check.h"
+#include "absl/strings/match.h"
#include "absl/strings/str_cat.h"
#include "absl/strings/str_split.h"
#include "absl/strings/substitute.h"
@@ -22,6 +23,7 @@
#include "google/protobuf/compiler/command_line_interface_tester.h"
#include "google/protobuf/compiler/cpp/generator.h"
#include "google/protobuf/compiler/python/generator.h"
+#include "google/protobuf/compiler/python/pyi_generator.h"
#include "google/protobuf/cpp_features.pb.h"
#include "google/protobuf/io/printer.h"
#include "google/protobuf/io/zero_copy_stream.h"
@@ -105,6 +107,63 @@
EXPECT_TRUE(found_expected_import);
}
+// `_ClassVar` is only a valid annotation inside a class body: at module level a
+// type checker rejects the whole stub. Nothing in the generator's structure
+// enforces that, so assert it over the generated file rather than pinning the
+// annotation each declaration happens to carry today.
+TEST(PythonPyiTest, ClassVarIsOnlyUsedInsideAClassBody) {
+ ABSL_CHECK_OK(
+ File::SetContents(absl::StrCat(::testing::TempDir(), "/extensions.proto"),
+ "syntax = \"proto2\";\n"
+ "package foo;\n"
+ "message Extendable {\n"
+ " extensions 1000 to max;\n"
+ "}\n"
+ "extend Extendable {\n"
+ " optional int32 top_level_ext = 1000;\n"
+ "}\n"
+ "message Holder {\n"
+ " extend Extendable {\n"
+ " optional int32 nested_ext = 1001;\n"
+ " }\n"
+ "}\n",
+ true));
+
+ compiler::CommandLineInterface cli;
+ cli.SetInputsAreProtoPathRelative(true);
+ python::PyiGenerator pyi_generator;
+ cli.RegisterGenerator("--pyi_out", &pyi_generator, "");
+ std::string proto_path = absl::StrCat("-I", ::testing::TempDir());
+ std::string pyi_out = absl::StrCat("--pyi_out=", ::testing::TempDir());
+ const char* argv[] = {"protoc", proto_path.c_str(), "-I.", pyi_out.c_str(),
+ "extensions.proto"};
+ ASSERT_EQ(0, cli.Run(5, argv));
+
+ std::string output;
+ ABSL_CHECK_OK(File::GetContents(
+ absl::StrCat(::testing::TempDir(), "/extensions_pb2.pyi"), &output,
+ true));
+
+ bool annotates_a_top_level_extension = false;
+ bool annotates_a_nested_extension = false;
+ for (absl::string_view line : absl::StrSplit(output, '\n')) {
+ if (absl::StartsWith(line, "TOP_LEVEL_EXT_FIELD_NUMBER")) {
+ annotates_a_top_level_extension = true;
+ }
+ if (absl::StrContains(line, "NESTED_EXT_FIELD_NUMBER")) {
+ annotates_a_nested_extension = true;
+ }
+ if (absl::StrContains(line, "_ClassVar[")) {
+ EXPECT_TRUE(absl::StartsWith(line, " "))
+ << "module-level _ClassVar annotation: " << line;
+ }
+ }
+ // Both extensions reach the stub, so neither branch of the assertion above is
+ // vacuous.
+ EXPECT_TRUE(annotates_a_top_level_extension);
+ EXPECT_TRUE(annotates_a_nested_extension);
+}
+
class PythonGeneratorTest : public CommandLineInterfaceTester,
public testing::WithParamInterface<bool> {
protected:
diff --git a/src/google/protobuf/compiler/python/pyi_generator.cc b/src/google/protobuf/compiler/python/pyi_generator.cc
index 541bec0..dfa4bc4 100644
--- a/src/google/protobuf/compiler/python/pyi_generator.cc
+++ b/src/google/protobuf/compiler/python/pyi_generator.cc
@@ -361,14 +361,21 @@
}
template <typename DescriptorT>
-void PyiGenerator::PrintExtensions(const DescriptorT& descriptor) const {
+void PyiGenerator::PrintExtensions(const DescriptorT& descriptor,
+ bool is_classvar) const {
for (int i = 0; i < descriptor.extension_count(); ++i) {
const FieldDescriptor* extension_field = descriptor.extension(i);
std::string constant_name =
absl::StrCat(extension_field->name(), "_FIELD_NUMBER");
absl::AsciiStrToUpper(&constant_name);
- printer_->Print("$constant_name$: _ClassVar[int]\n",
- "constant_name", constant_name);
+ // ClassVar is only a valid annotation inside a class body, so it is used
+ // for a message's nested extensions and not for a file's top-level ones.
+ if (is_classvar) {
+ printer_->Print("$constant_name$: _ClassVar[int]\n", "constant_name",
+ constant_name);
+ } else {
+ printer_->Print("$constant_name$: int\n", "constant_name", constant_name);
+ }
Annotate("constant_name", extension_field);
printer_->Print("$name$: _descriptor.FieldDescriptor\n",
"name", extension_field->name());
@@ -478,7 +485,7 @@
PrintMessage(*message_descriptor.nested_type(i), true);
}
- PrintExtensions(message_descriptor);
+ PrintExtensions(message_descriptor, /* is_classvar = */ true);
// Prints field number
for (int i = 0; i < message_descriptor.field_count(); ++i) {
diff --git a/src/google/protobuf/compiler/python/pyi_generator.h b/src/google/protobuf/compiler/python/pyi_generator.h
index 1beb4d1..adf752c 100644
--- a/src/google/protobuf/compiler/python/pyi_generator.h
+++ b/src/google/protobuf/compiler/python/pyi_generator.h
@@ -73,7 +73,8 @@
void PrintEnumValues(const EnumDescriptor& enum_descriptor,
bool is_classvar = false) const;
template <typename DescriptorT>
- void PrintExtensions(const DescriptorT& descriptor) const;
+ void PrintExtensions(const DescriptorT& descriptor,
+ bool is_classvar = false) const;
void PrintMessages() const;
void PrintMessage(const Descriptor& message_descriptor, bool is_nested) const;
void PrintServices() const;