stubpy.resolver

stubpy.resolver

Parameter resolution — the core **kwargs / *args backtracing logic.

Two public entry points cover every resolution scenario:

  • resolve_params() — resolves a method on a class using the class MRO. Three strategies are applied in priority order:

    1. No variadics — own parameters returned unchanged.

    2. cls()-call detection — a @classmethod body contains cls(..., **kwargs); resolve against cls.__init__.

    3. MRO walk — iterate ancestors that define the same method, collecting concrete parameters until all variadics are resolved. If variadics remain after the MRO is exhausted, module-level function targets (from ast_info) are resolved via namespace.

  • resolve_function_params() — resolves a module-level function using AST-detected forwarding targets and a runtime namespace lookup. When a target is a class constructor (a type), resolution delegates to resolve_params() so the chain continues correctly.

Both entry points call each other when mixed chains are encountered (method → function → method, function → class → method, etc.). Cycle detection via a _seen frozenset prevents infinite recursion.

Shared low-level helpers

  • _normalise_kind() — promotes POSITIONAL_ONLY to POSITIONAL_OR_KEYWORD when a param is absorbed by **kwargs.

  • _merge_concrete_params() — deduplicates and normalises params from a source list into an accumulated output list.

  • _finalise_variadics() — re-inserts *args and residual **kwargs in the correct position after merging.

Architecture note

AST scanning (detecting where **kwargs / *args is forwarded) lives exclusively in stubpy.ast_pass — specifically in _harvest_function(), which populates kwargs_forwarded_to and args_forwarded_to for every function and method definition.

This module handles resolution only — it reads pre-scanned metadata and performs runtime lookups, never re-parsing source code except in the _detect_cls_call fallback path (backward compat for callers that omit ast_info).

Class-method resolution

resolve_params(cls: type, method_name: str, ast_info: FunctionInfo | None = None, namespace: dict[str, Any] | None = None, ast_info_by_name: dict[str, FunctionInfo] | None = None, _seen: frozenset[str] | None = None) list[ParamWithHints][source]

Return the fully-merged parameter list for method_name on cls.

Expands **kwargs and *args into the concrete parameters they absorb using one of three strategies applied in order:

  1. No variadics — if the method has neither *args nor **kwargs, return its own parameters unchanged.

  2. cls()-call detection — if the method is a @classmethod whose body contains cls(..., **kwargs), resolve against cls.__init__. Parameters hardcoded in the call are excluded.

  3. MRO walk — iterate ancestors that define the same method, collecting concrete parameters until all variadics are resolved. If variadics remain after MRO exhaustion and namespace is provided, falls back to module-level function lookup (handles method → function forwarding chains).

self and cls are always excluded from the result.

Parameters:
  • cls (type) – The class owning (or inheriting) the method.

  • method_name (str) – Name of the method to resolve.

  • ast_info (FunctionInfo, optional) – Pre-scanned metadata from ASTHarvester. When provided, avoids a redundant inline AST parse inside _detect_cls_call() and enables post-MRO namespace fallback.

  • namespace (dict, optional) – Module __dict__ — used for method → function chain resolution. Pass ctx.module_namespace from the emitter.

  • ast_info_by_name (dict, optional) – Maps function name → FunctionInfo — enables recursive resolution when a namespace target also has variadics.

  • _seen (frozenset, optional) – Cycle-detection set. Do not pass manually.

Returns:

list of ParamWithHints – Ordered (Parameter, hints_dict) tuples. Own parameters come first, then ancestor parameters in MRO order. Unresolvable **kwargs and explicitly-typed *args are appended last.

See also

resolve_function_params

Entry point for standalone module-level functions.

stubpy.emitter.generate_method_stub

Consumes the output of this function.

Examples

>>> class Base:
...     def __init__(self, color: str, opacity: float = 1.0) -> None: ...
>>> class Child(Base):
...     def __init__(self, label: str, **kwargs) -> None: ...
>>> params = resolve_params(Child, "__init__")
>>> [p.name for p, _ in params]
['label', 'color', 'opacity']

Module-level function resolution

resolve_function_params(live_fn: Any, ast_info: FunctionInfo | None, namespace: dict[str, Any], *, ast_info_by_name: dict[str, FunctionInfo] | None = None, _seen: frozenset[str] | None = None) list[ParamWithHints][source]

Return the fully-merged parameter list for a module-level function.

Expands **kwargs and *args into concrete parameters by following AST-detected forwarding targets looked up in namespace.

Unlike resolve_params() (which walks the class MRO), this handles standalone functions where there is no class hierarchy. It relies on kwargs_forwarded_to and args_forwarded_to — pre-populated by _harvest_function().

When a forwarding target is a class (type), resolution delegates to resolve_params() on that class’s __init__. This handles function → class constructor chains. Similarly, resolve_params() falls back to this function when a method forwards to a standalone function — enabling arbitrarily deep mixed chains.

All parameter kinds are handled correctly:

  • POSITIONAL_ONLY (/) — promoted to POSITIONAL_OR_KEYWORD when absorbed through **kwargs (callers use keyword form).

  • POSITIONAL_OR_KEYWORD — merged as-is.

  • VAR_POSITIONAL (*args) — resolved via args_forwarded_to; kept if still unresolved or explicitly typed.

  • KEYWORD_ONLY (after * / *args) — merged as-is; the emitter inserts the bare * separator automatically.

  • VAR_KEYWORD (**kwargs) — resolved via kwargs_forwarded_to; kept as residual only when no target fully resolved it.

Strategy (in order)

  1. No variadics — own parameters returned unchanged.

  2. No targets — own parameters returned unchanged (variadics preserved; no information to expand them).

  3. Target resolution — look each name up in namespace, merge its concrete params (recursively for chained forwarding), with cycle detection via _seen. Class targets are handled via resolve_params() on their __init__.

  4. Finalise — re-insert *args and residual **kwargs.

param live_fn:

The live function object from the loaded module.

type live_fn:

callable

param ast_info:

Pre-scanned metadata from ASTHarvester.

type ast_info:

FunctionInfo or None

param namespace:

The module’s __dict__ — used to look up live callable targets.

type namespace:

dict

param ast_info_by_name:

Maps function name → FunctionInfo for functions in the same module. Enables recursive resolution when a forwarding target also has variadics.

type ast_info_by_name:

dict, optional

param _seen:

Names already on the call stack — prevents infinite recursion in mutually-recursive forwarding patterns. Do not pass manually.

type _seen:

frozenset, optional

returns:

list of ParamWithHints – Ordered (Parameter, hints_dict) tuples, variadics expanded as far as possible.

See also

resolve_params

Entry point for class method resolution via MRO.

Examples

>>> def make_color(r: float, g: float, b: float, a: float = 1.0): ...
>>> def make_red(r: float = 1.0, **kwargs): make_color(r=r, **kwargs)

Shared helpers

_normalise_kind(p: Parameter) Parameter[source]

Promote POSITIONAL_ONLY to POSITIONAL_OR_KEYWORD.

When a positional-only parameter from a parent is absorbed by a child’s **kwargs, callers pass it by keyword through the child interface. Keeping it POSITIONAL_ONLY would emit a misplaced / separator. This helper corrects that.

_merge_concrete_params(base: list[tuple[Parameter, dict[str, Any]]], seen: set[str], source: list[tuple[Parameter, dict[str, Any]]]) None[source]

Merge non-variadic params from source into base, deduplicating by name.

VAR_POSITIONAL and VAR_KEYWORD entries in source are skipped — those are handled separately by _finalise_variadics(). POSITIONAL_ONLY parameters are promoted via _normalise_kind().

Parameters:
  • base (list of ParamWithHints) – Accumulated output — modified in place.

  • seen (set of str) – Names already present in base — modified in place.

  • source (list of ParamWithHints) – Resolved parameter list to merge from.

_finalise_variadics(merged: list[tuple[Parameter, dict[str, Any]]], own_params: list[Parameter], own_hints: dict[str, Any], still_var_pos: bool, still_var_kw: bool) list[tuple[Parameter, dict[str, Any]]][source]

Re-insert *args and residual **kwargs at correct positions.

Called after all concrete parameters have been merged.

  • *args is inserted before the first KEYWORD_ONLY or VAR_KEYWORD parameter, if still unresolved or if the original *args carried an explicit type annotation.

  • **kwargs is appended last, only when still unresolved.

Parameters:
  • merged (list of ParamWithHints) – Merged concrete-param list. Modified in place.

  • own_params (list of inspect.Parameter) – Original parameter list — source of the *args / **kwargs objects (with their annotations and defaults).

  • own_hints (dict) – Type hints for own_params.

  • still_var_pos (bool) – True when *args was not fully resolved.

  • still_var_kw (bool) – True when **kwargs was not fully resolved.

Returns:

list of ParamWithHintsmerged with variadics inserted / appended.

_enforce_signature_validity(merged: list[tuple[Parameter, dict[str, Any]]]) list[tuple[Parameter, dict[str, Any]]][source]

Promote non-default params to KEYWORD_ONLY when they follow a default param.

Python requires that a parameter without a default value never follows a parameter that has one (outside of / or * separation). This situation arises when params are absorbed from a forwarding target:

def make_color(r, g, b, a=1.0): ...
def make_red(r=1.0, **kwargs): make_color(r=r, **kwargs)
#  → merges to: make_red(r=1.0, g, b, a=1.0)  ← invalid!
#  → fixed to:  make_red(r=1.0, *, g, b, a=1.0)  ← valid

The fix is correct semantically: absorbed params came in via **kwargs, so callers must supply them by keyword anyway. Promoting them to KEYWORD_ONLY makes that contract explicit in the stub.

VAR_POSITIONAL and VAR_KEYWORD sentinels are left untouched.

Parameters:

merged (list of ParamWithHints) – The fully-merged list (before adding * / / sentinels).

Returns:

list of ParamWithHints – Same list with any offending parameters promoted to KEYWORD_ONLY.

Resolution strategies

resolve_params() (class methods) applies three strategies in order:

  1. No variadics — return own parameters unchanged.

  2. cls()-call detection — if the body contains cls(..., **kwargs), resolve against cls.__init__. Hardcoded keyword names are excluded.

  3. MRO walk — collect concrete params from ancestors until all variadics are resolved or the MRO is exhausted.

resolve_function_params() (standalone functions) applies:

  1. No variadics — return own parameters unchanged.

  2. No targets — return own parameters unchanged (variadics preserved).

  3. Target resolution — look each name up in the module namespace and merge its concrete parameters. Recursive for chained forwarding; cycle-safe.

  4. Default-ordering enforcement — absorbed non-default params following a defaulted own-param are promoted to KEYWORD_ONLY.

Parameter ordering (both resolvers)

  1. The function/method’s own concrete parameters (source order).

  2. Parameters from the first resolved target / ancestor.

  3. Parameters from further targets / ancestors, in resolution order.

  4. *args — placed before keyword-only params and before **kwargs.

  5. Residual **kwargs — appended last if still unresolved.

Positional-only parameters

POSITIONAL_ONLY parameters absorbed via **kwargs are promoted to POSITIONAL_OR_KEYWORD by _normalise_kind() — a positional-only param passed through **kwargs must be supplied by keyword.

Default-ordering rule

_enforce_signature_validity() promotes any non-default parameter that follows a defaulted parameter in the positional portion to KEYWORD_ONLY. This is correct semantically: absorbed params arrive via **kwargs and are keyword arguments in practice.