Skip to content

Type Checking

cbyte edited this page Jul 19, 2026 · 1 revision

Type Checking

One of the fork's biggest wins over upstream is first-class static type checking under Pylance / pyright. Every generated protobuf module ships with a full .pyi companion — attribute accesses on message fields resolve to their real types, and IDEs can autocomplete and refactor them.

What the fork ships

  • Full .pyi stubs for every _pb2 file via mypy-protobuf. Emitted alongside the runtime .py by protoc --mypy_out. Every CMsgX.field reads as its real type, every method resolves through Message's base signatures.
  • Modernised codegen output — protobuf 5.x/6.x _descriptor_pool.AddSerializedFile + _builder, ~20× smaller than the old _reflection.GeneratedProtocolMessageType pattern, and static-analyser-friendly (no dynamic type mutation at import time).
  • py.typed marker at steam/py.typed — signals to downstream projects that this package ships inline types (PEP 561).
  • Local stub overrides for typeshed bugs the fork hit — see below.
  • [tool.pyright] config in pyproject.toml — turns on stubPath = "typings" so the local overrides are picked up automatically.

Base class stubs — types-protobuf

The runtime protobuf Message base class (ParseFromString, SerializeToString, MergeFrom, HasField, WhichOneof, …) is typed via types-protobuf, a typeshed-first-party package. Pulled in as a dev dep:

[tool.poetry.group.dev.dependencies]
types-protobuf = ">=4.24"

Dev-only because it ships only .pyi files — zero runtime impact. Bumped past the 4.24 line so the emitted .pyi stubs from mypy-protobuf line up with what Pylance's typeshed already expects.

Domain stubs — mypy-protobuf

Generates the domain .pyi files (one per .proto). Every message class becomes a real Message subclass with each field's type declared:

# steam/protobufs/steammessages_clientserver_pb2.pyi (auto-generated excerpt)
class CMsgClientLogonResponse(google.protobuf.message.Message):
    DESCRIPTOR: google.protobuf.descriptor.Descriptor
    class OutOfGameEmoticonSlots: ...

    EMSG_FIELD_NUMBER: builtins.int
    ERESULT_FIELD_NUMBER: builtins.int

    @property
    def eresult(self) -> builtins.int: ...
    @property
    def out_of_game_heartbeat_seconds(self) -> builtins.int: ...
    # ...

Downstream:

from steam.core.msg import MsgProto
from steam.enums.emsg import EMsg

msg = MsgProto(EMsg.ClientLogOnResponse)
result = msg.body.eresult   # Pylance knows this is int

Dev dep:

mypy-protobuf = ">=3.6"

Local typeshed override — typings/

[tool.pyright] stubPath = "typings" in pyproject.toml points pyright at local stub overrides. Currently one file lives there.

typings/google/protobuf/internal/builder.pyi

Fixes a bug in the upstream typeshed stub for google.protobuf.internal.builder.BuildServices. Upstream declares:

def BuildServices(file_des: FileDescriptor, module_name: str, module: ModuleType) -> None: ...

But the runtime implementation does module[name] = ... — item assignment, which needs a MutableMapping, not a real ModuleType. The other three Build* functions in the same file ship the correct dict[str, Any] annotation; this override just aligns BuildServices with them.

Without the override, every steam/protobufs/*_pb2.py with a service declaration reports reportArgumentType on the _globals argument, even though the runtime code is correct. Once typeshed lands the fix upstream, this override can be deleted.

Post-process rules

scripts/pb_compile.py post-processes the raw protoc output. Two rules matter for type checking:

.py — sibling import prefix

Sibling protobuf imports get the steam.protobufs. prefix so runtime import works from any working directory:

# before
import steammessages_base_pb2

# after
import steam.protobufs.steammessages_base_pb2

Doesn't affect type checking directly, but fixes runtime ModuleNotFoundError under normal imports.

.pyiDESCRIPTOR override strip

Per-message DESCRIPTOR: _descriptor.Descriptor overrides inside message classes are stripped. Under types-protobuf 7.34+, the base Message.DESCRIPTOR is typed as Descriptor | _upb_Descriptor; mypy-protobuf emits the narrower single-type override, which trips reportIncompatibleVariableOverride (mutable-attribute variance forbids narrowing).

The fix: delete the override, let each Message subclass inherit the base's correctly-typed union. Module-level FileDescriptor and enum-wrapper EnumDescriptor overrides are left alone (they don't collide).

Regex is ^\s+DESCRIPTOR: _descriptor\.Descriptor\n — matches any leading whitespace, so deeply-nested (16-space) message classes are covered too.

Regenerating stubs

Both the .py and .pyi are emitted together by pb-compile:

poetry run pb-compile

That runs protoc --python_out=... --mypy_out=... .... Full details on Regenerating Protobufs.

Where things intentionally don't type-check

Some parts of the codebase — mostly the mixin classes in steam/client/builtins/* — need to reach for attributes their host class provides at runtime (via multiple inheritance in BuiltinBase). They use TYPE_CHECKING-only _HostBase protocols to declare those shapes without dragging concrete SteamClient / CMClient into a runtime import (which would circular-import at load time). Look at any steam/client/builtins/*.py — the class _XxxMixinHost: block explains the pattern.

The dynamic WebAPI interface catalogue is another intentional gap. Interfaces are populated at construction time from GetSupportedAPIList, so their names aren't knowable at design time. WebAPI declares __getattr__(...) -> Any under TYPE_CHECKING so api.ISteamUser.ResolveVanityURL(...) doesn't red-underline in your IDE — trading precise typing for existence of the attribute.

Editor setup

VS Code + Pylance / pyright picks up pyproject.toml automatically. If you want stricter checking, add to your workspace settings:

{
  "python.analysis.typeCheckingMode": "basic",
  "python.analysis.stubPath": "typings"
}

basic is what CI runs; strict catches more but flags the mixin _HostBase protocols as pointlessly indirect.

Where to go next

  • Regenerating stubs after upstream changes: Regenerating Protobufs.
  • Sibling package pyproject.toml settings and layout: check the fork's actual [tool.pyright] block for the source of truth.

Clone this wiki locally