protocolbuffers / protocolbuffers/protobuf

Python: --pyi_out emits ClassVar[int] at module scope for file-level extensions

Open
#29,221 3 comments 0 reactions 1 assignee View on GitHub

@JasonLunn is already working on this.

Since Aug 21, 2026.

python
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.