0

Code first

The goal is to design OuterBase such that the following passes:

class Outer(OuterBase):
    def __init__(self, foo: str) -> None:
        self.foo = foo

    class Inner:
        outer: Outer

        def get_foo(self) -> str:
            return self.outer.foo


inner = Outer("bar").Inner()
assert inner.get_foo() == "bar"

My question is closely related to this: How to access outer class from an inner class?

But it is decisively different in one relevant nuance. That question is about how to access an outer class from inside the inner class. This question is about access to a specific instance of the outer class.

Question

Given an Outer and an Inner class, where the latter is defined in the body of the former, and given an instance of Outer, can we pass that instance to the Inner constructor so as to bind the Inner instance to that Outer instance?

So if we did outer = Outer() and then inner = outer.Inner(), there would then be a reference to outer in an attribute of inner.

Secondary requirements

1) Simplest possible usage (minimal boilerplate)

It would be ideal, if the entire logic facilitating this binding of the instances were "hidden" in the Outer class.

Then there would be some OuterBase class that a user could inherit from and all he would have to do, is define the Inner class (with the agreed upon fixed name) and expect its outer attribute (also agreed upon) to hold a reference to an instance of the outer class.

Solutions involving decoration of the inner class or explicitly passing it a meta class or defining a special __init__ method and so on would be considered sub-optimal.

2) Type safety (to the greatest degree possible)

The code (both of the implementation and the usage) should ideally pass mypy --strict checks and obfuscate dynamic typing as little as possible.

Hypothetical real-life use case

Say the inner class is used as a settings container for instances of the outer class, similar to how Pydantic is designed. In Pydantic (possibly for various reasons) the inner Config class is a class-wide configuration, i.e. it applies to all instances of the model (outer class). With a setup like the one I am asking about here the usage of Pydantic models would remain unchanged, but now deviations in configuration would be possible on an instance level by binding a specific Config instance to a specific model instance.

Daniil Fajnberg
  • 12,753
  • 2
  • 10
  • 41

1 Answers1

0

A solution is to define a custom meta class for OuterBase, which will replace the Inner class defined inside the body of any Outer with an instance of a special descriptor.

That descriptor holds a reference to the actual Inner class. It also retains a reference to the Outer instance from which its __get__ method is called and has a generic __call__ method returning an instance of Inner, after assigning the reference to the Outer instance to its outer attribute.

from __future__ import annotations
from typing import Any, ClassVar, Generic, Optional, Protocol, TypeVar


class InnerProtocol(Protocol):
    outer: object


T = TypeVar("T", bound=InnerProtocol)
class InnerDescriptor(Generic[T]):
    """Replacement for the actual inner class during outer class creation."""
    InnerCls: type[T]
    outer_instance: Optional[object]

    def __init__(self, inner_class: type[T]) -> None:
        self.InnerCls = inner_class
        self.outer_instance = None

    def __get__(self, instance: object, owner: type) -> InnerDescriptor[T]:
        self.outer_instance = instance
        return self

    def __call__(self, *args: object, **kwargs: object) -> T:
        if self.outer_instance is None:
            raise RuntimeError("Inner class not bound to an outer instance")
        inner_instance = self.InnerCls(*args, **kwargs)
        inner_instance.outer = self.outer_instance
        return inner_instance
class OuterMeta(type):
    """Replaces an inner class in the outer namespace with a descriptor."""

    INNER_CLS_NAME: ClassVar[str] = "Inner"

    def __new__(
        mcs,
        name: str,
        bases: tuple[type],
        namespace: dict[str, Any],
        **kwargs: Any,
    ) -> OuterMeta:
        if mcs.INNER_CLS_NAME in namespace:
            namespace[mcs.INNER_CLS_NAME] = InnerDescriptor(
                namespace[mcs.INNER_CLS_NAME]
            )
        return super().__new__(mcs, name, bases, namespace, **kwargs)


class OuterBase(metaclass=OuterMeta):
    pass

Now this code passes (both runtime and static type checks):

class Outer(OuterBase):
    def __init__(self, foo: str) -> None:
        self.foo = foo

    class Inner:
        outer: Outer

        def get_foo(self) -> str:
            return self.outer.foo


inner = Outer("bar").Inner()
assert inner.get_foo() == "bar"

Ignoring type checkers, we could of course also omit the outer: Outer annotation.

If we were to instead compromise in the sense that we define some InnerBase for Inner to inherit from, we could define the outer attribute there. That would also eliminate the need for the custom InnerProtocol to make type checkers happy. But the trade-off is that users of OuterBase would always have to explicitly inherit when defining the inner class as class Inner(InnerBase): ....

Daniil Fajnberg
  • 12,753
  • 2
  • 10
  • 41