protocolbuffers / protocolbuffers/protobuf
Python: --pyi_out emits ClassVar[int] at module scope for file-level extensions
@JasonLunn is already working on this.
Since Aug 21, 2026.
- Dominant language
- C++
- Stars
- 72k
- Forks
- 16.3k
- Avg merge
- 1d 17h
- Merged PRs (30d)
- 140
Description
What version of protobuf and what language are you using?
Version: libprotoc 33.5 (also present on main)
Language: Python (--pyi_out)
What did you do?
Compile a proto that declares a file-level extension:
syntax = "proto2";
package probe;
import "google/protobuf/descriptor.proto";
// file-level extension -> emitted at MODULE scope
extend google.protobuf.FileOptions {
optional bool top_level_ext = 50001;
}
message Holder {
// nested extension -> emitted inside the class body
extend google.protobuf.MessageOptions {
optional bool nested_ext = 50002;
}
}
protoc --pyi_out=. ext.proto
What did you expect to see?
A .pyi that type-checks.
What did you see instead?
ClassVar is emitted at module scope, where it is not a valid annotation:
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, correct
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 one is flagged; the nested one is fine. PEP 526 restricts ClassVar to class bodies.
Cause
PyiGenerator::PrintExtensions (src/google/protobuf/compiler/python/pyi_generator.cc:364) is a template over DescriptorT and emits _ClassVar[int] unconditionally:
template <typename DescriptorT>
void PyiGenerator::PrintExtensions(const DescriptorT& descriptor) const {
...
printer_->Print("$constant_name$: _ClassVar[int]\n",
"constant_name", constant_name);
It is instantiated for both scopes:
| call site | descriptor | output scope | _ClassVar valid |
|---|---|---|---|
:481 PrintExtensions(message_descriptor) |
Descriptor |
class body | yes |
:656 PrintExtensions(*public_dep) |
FileDescriptor |
module | no |
:669 PrintExtensions(*file_) |
FileDescriptor |
module | no |
Suggested fix
PrintEnumValues in the same file already models this distinction:
void PyiGenerator::PrintEnumValues(const EnumDescriptor& enum_descriptor,
bool is_classvar) const {
...
if (is_classvar) {
printer_->Print("$name$: _ClassVar[$module_enum_name$]\n", ...);
} else {
printer_->Print("$name$: $module_enum_name$\n", ...);
Giving PrintExtensions the same bool is_classvar = false parameter and passing true only from the message call site would match that idiom without needing a template specialisation.
Happy to send a PR.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.