Summary
MetadataDependent.get_inherited_dependencies memoizes into a plain class attribute and reads it back with ordinary attribute lookup. Because that lookup walks the MRO, running a metadata pass with a bare CSTTransformer/CSTVisitor caches an empty frozenset onto the base class, and every subclass that has not yet computed its own cache then inherits that empty set — raising METADATA_DEPENDENCIES KeyErrors even though the subclass declares the provider correctly.
The poisoning is global and permanent for the process, and it surfaces arbitrarily far from the cause.
Reproduction
import libcst as cst
from libcst.metadata import MetadataWrapper, PositionProvider
class PositionAware(cst.CSTTransformer):
METADATA_DEPENDENCIES = (PositionProvider,)
def leave_Pass(self, original_node, updated_node):
self.get_metadata(PositionProvider, original_node)
return updated_node
def run(visitor):
MetadataWrapper(cst.parse_module("pass\n")).visit(visitor)
run(PositionAware()) # fine
run(cst.CSTTransformer()) # <-- poisons the base class
run(PositionAware()) # KeyError
KeyError: 'PositionProvider is not declared as a dependency in PositionAware.METADATA_DEPENDENCIES.'
Observed on libcst 1.8.6 (current release) with CPython 3.14.6; the same code is present on main.
Cause
try:
return cls._INHERITED_METADATA_DEPENDENCIES_CACHE
except AttributeError:
...
cls._INHERITED_METADATA_DEPENDENCIES_CACHE = frozenset(dependencies)
return cls._INHERITED_METADATA_DEPENDENCIES_CACHE
cls._INHERITED_METADATA_DEPENDENCIES_CACHE resolves through the MRO, so the try succeeds against a base class entry. Resolving CSTTransformer itself writes frozenset() there, since the base declares no dependencies. Every later subclass without its own entry reads it.
Note the trigger is resolution, not construction — cst.CSTTransformer() alone is harmless; the cache is only written when get_inherited_dependencies() runs during a metadata pass.
Impact
This is hard to diagnose in a test suite. One helper that ran a bare transformer broke 38 tests across five unrelated files in our codebase, and bisecting by file or by single test finds nothing, because the poison needs one specific test to have run first and is then permanent for the interpreter. The reported error also points at the innocent subclass, which reads as a bug in that class rather than in shared state.
Suggested fix
Read only the class's own entry, so a base-class cache can never be inherited:
@classmethod
def get_inherited_dependencies(cls) -> Collection["ProviderT"]:
cache = cls.__dict__.get("_INHERITED_METADATA_DEPENDENCIES_CACHE")
if cache is None:
dependencies = set()
for c in inspect.getmro(cls):
if issubclass(c, MetadataDependent):
dependencies.update(c.METADATA_DEPENDENCIES)
cache = frozenset(dependencies)
cls._INHERITED_METADATA_DEPENDENCIES_CACHE = cache
return cache
This keeps the memoization and its per-class semantics, and costs one dict lookup. Happy to send this as a PR if the approach looks right.
Summary
MetadataDependent.get_inherited_dependenciesmemoizes into a plain class attribute and reads it back with ordinary attribute lookup. Because that lookup walks the MRO, running a metadata pass with a bareCSTTransformer/CSTVisitorcaches an emptyfrozensetonto the base class, and every subclass that has not yet computed its own cache then inherits that empty set — raisingMETADATA_DEPENDENCIESKeyErrors even though the subclass declares the provider correctly.The poisoning is global and permanent for the process, and it surfaces arbitrarily far from the cause.
Reproduction
Observed on libcst 1.8.6 (current release) with CPython 3.14.6; the same code is present on
main.Cause
cls._INHERITED_METADATA_DEPENDENCIES_CACHEresolves through the MRO, so thetrysucceeds against a base class entry. ResolvingCSTTransformeritself writesfrozenset()there, since the base declares no dependencies. Every later subclass without its own entry reads it.Note the trigger is resolution, not construction —
cst.CSTTransformer()alone is harmless; the cache is only written whenget_inherited_dependencies()runs during a metadata pass.Impact
This is hard to diagnose in a test suite. One helper that ran a bare transformer broke 38 tests across five unrelated files in our codebase, and bisecting by file or by single test finds nothing, because the poison needs one specific test to have run first and is then permanent for the interpreter. The reported error also points at the innocent subclass, which reads as a bug in that class rather than in shared state.
Suggested fix
Read only the class's own entry, so a base-class cache can never be inherited:
This keeps the memoization and its per-class semantics, and costs one dict lookup. Happy to send this as a PR if the approach looks right.