MRO backtracing

stubpy’s defining feature is MRO backtracing — walking a class’s Method Resolution Order to expand **kwargs into the concrete named parameters they actually represent. This page explains how it works and what the generated stubs look like.

The problem

Python inheritance commonly uses **kwargs to forward keyword arguments up the chain without listing every parameter in every subclass:

class Widget:
    def __init__(self, color: str = "black", size: int = 12) -> None: ...

class Button(Widget):
    def __init__(self, label: str, **kwargs) -> None:
        super().__init__(**kwargs)

Without MRO backtracing, a stub generator would emit:

class Button(Widget):
    def __init__(self, label: str, **kwargs) -> None: ...  # IDE sees no kwargs

With **kwargs opaque, IDEs cannot autocomplete color or size on Button(), and type checkers cannot catch typos like Button("OK", colour="red").

What stubpy does

stubpy imports the module, inspects the live class hierarchy, and walks the MRO to find where every **kwargs terminates:

# Generated stub — full concrete signature
class Button(Widget):
    def __init__(
        self,
        label: str,
        color: str = 'black',
        size: int = 12,
    ) -> None: ...

The IDE now auto-completes color and size on Button() and type checkers catch Button("OK", colour="red") as an error.

Three-level inheritance

MRO backtracing works across arbitrarily deep hierarchies:

class Element:
    def __init__(self, x: float = 0.0, y: float = 0.0,
                 visible: bool = True) -> None: ...

class Container(Element):
    def __init__(self, clip: bool = False, **kwargs) -> None:
        super().__init__(**kwargs)

class Scene(Container):
    def __init__(self, width: float, height: float, **kwargs) -> None:
        super().__init__(**kwargs)

Generated stubs:

class Container(Element):
    def __init__(
        self,
        clip: bool = False,
        x: float = 0.0,
        y: float = 0.0,
        visible: bool = True,
    ) -> None: ...

class Scene(Container):
    def __init__(
        self,
        width: float,
        height: float,
        clip: bool = False,
        x: float = 0.0,
        y: float = 0.0,
        visible: bool = True,
    ) -> None: ...

@classmethod with cls(...) pattern

When a @classmethod forwards **kwargs into cls(...), stubpy detects that pattern via AST analysis and resolves the kwargs against cls.__init__ rather than the MRO siblings:

class Circle(Element):
    def __init__(self, radius: float, **kwargs) -> None:
        super().__init__(**kwargs)

    @classmethod
    def unit(cls, **kwargs) -> "Circle":
        return cls(radius=1.0, **kwargs)

Generated stub:

class Circle(Element):
    def __init__(
        self,
        radius: float,
        x: float = 0.0,
        y: float = 0.0,
        visible: bool = True,
    ) -> None: ...

    @classmethod
    def unit(
        cls,
        x: float = 0.0,
        y: float = 0.0,
        visible: bool = True,
    ) -> Circle: ...

Module-level function forwarding

stubpy also expands **kwargs for standalone functions. The AST body is scanned to detect which callable receives the forwarded kwargs:

def make_color(r: float, g: float, b: float, a: float = 1.0) -> Color: ...

def make_red(r: float = 1.0, **kwargs) -> Color:
    return make_color(r=r, **kwargs)

Generated stub:

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

Positional-only parameters (/)

When a parent method has positional-only parameters and a child absorbs them via **kwargs, stubpy promotes them to POSITIONAL_OR_KEYWORD — callers pass them by keyword through the child’s interface, so emitting them as positional-only would produce an invalid / placement:

class Renderer:
    def draw(self, x: float, y: float, /, *, antialias: bool = True): ...

class Canvas(Renderer):
    def draw(self, **kwargs): ...   # absorbs x, y via **kwargs

Generated stub:

class Canvas(Renderer):
    # x and y promoted from POSITIONAL_ONLY to POSITIONAL_OR_KEYWORD
    def draw(self, x: float, y: float, *, antialias: bool = True): ...

Default-ordering enforcement

When absorbed parameters (which have no default) would follow a parameter that has a default, stubpy automatically promotes the absorbed parameters to keyword-only — they are semantically keyword arguments anyway, since they arrived via **kwargs:

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

# Without promotion: make_red(r=1.0, g, b, a=1.0) — INVALID (non-default after default)
# With promotion:
def make_red(r: float = 1.0, *, g: float, b: float, a: float = 1.0) -> Color: ...