Type Parameter Scope Design¶
This note sketches the intended shape of pycroscope/type_params.py. The goal is
to make ownership of type parameters explicit without scattering owner-binding,
aliasing, and annotation-validity rules through name_check_visitor.py.
Terms¶
A type parameter is one of pycroscope’s canonical TypeParam values:
TypeVarParam, ParamSpecParam, or TypeVarTupleParam.
A type parameter owner is the class, function, or type alias definition that
binds a type parameter. Owners are represented by TypeParamOwner.
A type parameter identity is the thing used to recognize a type parameter in source or runtime annotations. It should be restricted to:
TypeParamIdentity = TypeVarLike | ast.AST
Use a TypeVarLike identity when there is a runtime TypeVar, ParamSpec, or
TypeVarTuple object. Use an AST identity when pycroscope synthesized the type
parameter while analyzing unimportable code and no runtime object exists. Avoid
using arbitrary object identities; every identity should be explainable as
either the runtime object or the syntax node that declares the parameter.
Avoid manually creating TypeVarLike objects at type checking time.
Proposed Model¶
ActiveTypeParams should be an explicit stack of nested type-parameter scopes.
Each scope owns the type parameters declared by one definition, plus the small
amount of state needed to validate references to those parameters while visiting
annotations.
@dataclass
class TypeParamScope:
owner: TypeParamOwner | None
bindings: dict[TypeParamIdentity, TypeParam]
disallowed: set[TypeParamIdentity]
owner¶
The owner for type parameters declared directly in this scope. It is None only
for helper scopes that do not declare parameters, such as a temporary annotation
validation scope.
This replaces caller-side owner reconstruction. Code that visits a PEP 695
TypeVar node should ask ActiveTypeParams for the current declaration owner
instead of duplicating class/function/alias owner logic in NameCheckVisitor.
bindings¶
Maps every known identity for a type parameter to its canonical TypeParam.
Usually this contains one identity: type_param.typevar. During import-failure
or synthetic-class handling, the same canonical parameter may also need an AST
identity or a pre-rebinding runtime identity. Those aliases should be recorded in
this mapping instead of passed around as parallel additional_identities lists.
The important invariant is that lookup returns the already owner-bound canonical
parameter. Callers should not need to call with_type_param_owner() after a
parameter has entered a scope.
disallowed¶
A set of identities that are lexically visible but invalid in the current annotation context. This is for rules such as “an outer class type parameter is not valid in this nested alias annotation” or other contexts where normal name resolution can find a type parameter but the typing spec does not permit using it there.
This should stay as scope state because it is about validity of a reference at a particular point in the syntax, not about the parameter’s owner or canonical identity.
Why There Is No kind Field¶
The scope does not need a kind field if callers provide the owner explicitly
when entering the definition. A field such as "class", "function", or
"alias" would duplicate information that is already represented by the owner
type (ClassOwner or runtime class, FunctionOwner, AliasOwner).
If a future rule truly depends on the syntactic kind of a scope instead of the owner, that should be modeled as a narrow field for that rule. It should not be part of the core owner-binding abstraction.
Binding API¶
The main operations should be:
def push_scope(owner: TypeParamOwner | None = None) -> AbstractContextManager[None]:
...
def declare(type_param: TypeParam, aliases: Iterable[TypeParamIdentity] = ()) -> TypeParam:
...
def lookup(identity: TypeParamIdentity) -> TypeParam | None:
...
def bind_all(type_params: Sequence[TypeParam], owner: TypeParamOwner) -> TypeParamBindingResult:
...
declare() should owner-bind the parameter using the current scope owner, record
all aliases in bindings, and return the canonical parameter.
bind_all() should replace NameCheckVisitor._bind_type_param_owners(). It
should return:
@dataclass(frozen=True)
class TypeParamBindingResult:
type_params: tuple[TypeParam, ...]
substitutions: TypeVarMap
aliases: tuple[frozenset[TypeParamIdentity], ...]
type_params¶
The canonical, owner-bound parameters.
substitutions¶
A map from the incoming unbound parameters to the canonical parameters. This is needed to rewrite bounds, constraints, defaults, function annotations, alias values, and class bases that mention the old parameter objects.
aliases¶
For each canonical parameter, the identities that should also resolve to it. This replaces ad hoc dictionaries and parallel alias lists in class registration.
Responsibilities¶
type_params.py should own:
Owner binding for classes, functions, aliases, and runtime generic objects.
Lookup from
TypeParamIdentityto canonicalTypeParam.Alias identities created while rebinding synthetic or runtime parameters.
Validity checks for direct type-parameter use in annotation contexts.
Legacy TypeVar rejection state.
name_check_visitor.py should still own:
Building the correct
TypeParamOwnerfor a class, function, or alias node.Deciding when to enter and exit scopes while visiting the AST.
Reporting errors through the existing visitor methods.
Variance collection can remain in type_params.py, but it should be separated
from owner-binding state. It is usage-analysis state, not identity or ownership
state.
Migration Plan¶
Add the typed identity alias and make all type-parameter lookup APIs accept
TypeParamIdentityinstead ofobject.Make
TypeParamScope.bindingsthe source of truth and remove the parallel_active_pep695_identitiesand_active_pep695_type_paramsstacks.Move owner binding and type-parameter component rewriting from
NameCheckVisitorinto abind_all()helper intype_params.py.Replace
additional_identitieswith explicit aliases passed todeclare()or returned bybind_all().Once every construction path has an owner, include owners in equality and hashing.