Skip to content

pygx.detouring

Redirecting class instantiation to other classes or functions in a scope.

detouring

Redirecting class instantiation to other classes or functions in a scope.

It is straightforward to symbolize existing classes and functions, but in order to use them, we need to replace the classes that were used in existing code with the symbolic ones. Sometimes, we just cannot modify existing source code. Or in some other cases, objects created within a function or a class method are not exposed to the external, therefore we cannot manipulate them as a part of the symbolic tree. For example:

@pg.symbolize
def foo():
  # Object `a` is not a part of `foo`'s interface,
  # therefore it cannot be seen from the symbolic tree
  # that contains a `foo` object.
  a = A(1)
  return a.do_something()

Symbolic detour is introduced to address these use cases, which redirects the __new__ method of a class to another class or function when it’s evaluated under a context manager. Symbolic detour is not dependent on symbolization, so in theory it can be used for detouring any classes. Therefore, it does not require the presence of symbolic objects for mutating the program.

current_mappings

current_mappings() -> dict[type[Any], type[Any] | FunctionType]

Returns the detour mappings in effect on the current thread.

Nested scopes are already merged into the returned dict (with outer-scope and transitive mappings resolved). Returns an empty dict when no detour scope is active.

Source code in pygx/detouring/_class_detour.py
def current_mappings() -> dict[type[Any], type[Any] | types.FunctionType]:
    """Returns the detour mappings in effect on the current thread.

    Nested scopes are already merged into the returned dict (with
    outer-scope and transitive mappings resolved). Returns an empty dict
    when no detour scope is active.
    """
    return _global_detour_context.current_mappings

detour

detour(
    mappings: Sequence[tuple[type[Any], type[Any] | FunctionType]],
) -> Iterator[dict[type[Any], type[Any] | FunctionType]]

Context manager for detouring object creation.

At times, we want to replace an object of a class with an object of a different class. Usually, we do so by passing the object as a function argument using dependency injection. However, it's not always possible to expose those internal objects as parameters to the class, as we cannot predict what needs to be customized in the future. Also, exposing too many arguments hurts usability; it's a big burden for a user to figure out 20 arguments of a function just to get started.

pg.detour provides another option for object replacement in Python, which creates a context in which some source classes can be detoured to specified destination classes or functions. For example, the code snippet below will detour instantiation of class A to class B, and vice-versa:

class A:
  pass

class B:
  pass

# Exchange class A and class B.
with pg.detour([(A, B), (B, A)]):
  a = A()   # a is a B object.
  b = B()   # b is an A object.

Detour destination can be a function, which allows users to intercept the arguments passed to the class constructor. For example:

class Foo:
  def __init__(self, value):
    self.value = value

class Bar:
  def __init__(self, value):
    self.value = value

def detoured_foo(cls, value):
  # cls is the original class before detour.
  return Bar(value + 1)

with pg.detour([(Foo, detoured_foo)]):
  f = Foo(1)   # f will be Bar(2).

Detours can be nested. The outer scope mappings take precedence over the mappings from the inner scope, allowing users to change object creation behaviors from the outside. For example, the following code will detour class A to class C:

with pg.detour([(A, C)]):
  with pg.detour([(A, B)]):
    a = A()   # a is a C object.

Detour is transitive across the inner and outer scopes. For example, the code below will detour class A to class C through B:

with pg.detour([(B, C)]):
  a1 = A()     # a1 is an A object.
  with pg.detour([(A, B)]):
    a2 = A()   # a2 is a C object. (A -> B -> C)

Detour is thread-safe: mappings only take effect on the thread that entered the context manager.

Parameters:

Name Type Description Default
mappings Sequence[tuple[type[Any], type[Any] | FunctionType]]

A sequence of tuples (src_cls, dest_cls_or_fn) as mappings for the detour - src_cls is the source class to be detoured, while dest_cls_or_fn is the destination class or function. When it's a class, its __init__ method should have the same signature as the __init__ of the original class. When it's a function, it should accept a positional argument cls, for passing the original class that is being detoured, followed by all the arguments that the original class should accept. For example, a class with __init__(self, x, *args, y, **kwargs) can be detoured to a function with signature (cls, x, *args, y, **kwargs).

required

Yields:

Type Description
dict[type[Any], type[Any] | FunctionType]

The resolved detour mappings in effect for the new scope: a dict

dict[type[Any], type[Any] | FunctionType]

mapping each source class to its destination, with outer-scope

dict[type[Any], type[Any] | FunctionType]

precedence and transitivity applied.

Raises:

Type Description
TypeError

If the first item in each mapping is not a class, or the second item in each mapping is neither a class nor a function.

Source code in pygx/detouring/_class_detour.py
@contextlib.contextmanager
def detour(
    mappings: Sequence[
        tuple[
            type[Any],  # Source class
            type[Any] | types.FunctionType,  # Target class or function
        ]
    ],
) -> Iterator[dict[type[Any], type[Any] | types.FunctionType]]:
    """Context manager for detouring object creation.

    At times, we want to replace an object of a class with an object of a
    different class. Usually, we do so by passing the object as a function
    argument using dependency injection. However, it's not always possible to
    expose those internal objects as parameters to the class, as we cannot
    predict what needs to be customized in the future. Also, exposing too many
    arguments hurts usability; it's a big burden for a user to figure out 20
    arguments of a function just to get started.

    ``pg.detour`` provides another option for object replacement in Python,
    which creates a context in which some source classes can be detoured to
    specified destination classes or functions. For example, the code snippet
    below will detour instantiation of class A to class B, and vice-versa:

    ```python
    class A:
      pass

    class B:
      pass

    # Exchange class A and class B.
    with pg.detour([(A, B), (B, A)]):
      a = A()   # a is a B object.
      b = B()   # b is an A object.
    ```

    Detour destination can be a function, which allows users to intercept the
    arguments passed to the class constructor. For example:

    ```python
    class Foo:
      def __init__(self, value):
        self.value = value

    class Bar:
      def __init__(self, value):
        self.value = value

    def detoured_foo(cls, value):
      # cls is the original class before detour.
      return Bar(value + 1)

    with pg.detour([(Foo, detoured_foo)]):
      f = Foo(1)   # f will be Bar(2).
    ```

    Detours can be nested. The outer scope mappings take precedence over the
    mappings from the inner scope, allowing users to change object creation
    behaviors from the outside. For example, the following code will detour
    class A to class C:

    ```python
    with pg.detour([(A, C)]):
      with pg.detour([(A, B)]):
        a = A()   # a is a C object.
    ```

    Detour is transitive across the inner and outer scopes. For example, the
    code below will detour class A to class C through B:

    ```python
    with pg.detour([(B, C)]):
      a1 = A()     # a1 is an A object.
      with pg.detour([(A, B)]):
        a2 = A()   # a2 is a C object. (A -> B -> C)
    ```

    Detour is thread-safe: mappings only take effect on the thread that
    entered the context manager.

    Args:
      mappings: A sequence of tuples (src_cls, dest_cls_or_fn) as mappings
        for the detour - ``src_cls`` is the source class to be detoured,
        while ``dest_cls_or_fn`` is the destination class or function. When
        it's a class, its ``__init__`` method should have the same signature
        as the ``__init__`` of the original class. When it's a function, it
        should accept a positional argument ``cls``, for passing the original
        class that is being detoured, followed by all the arguments that the
        original class should accept. For example, a class with
        ``__init__(self, x, *args, y, **kwargs)`` can be detoured to a
        function with signature ``(cls, x, *args, y, **kwargs)``.

    Yields:
      The resolved detour mappings in effect for the new scope: a dict
      mapping each source class to its destination, with outer-scope
      precedence and transitivity applied.

    Raises:
      TypeError: If the first item in each mapping is not a class, or the second
        item in each mapping is neither a class nor a function.
    """
    # Placeholder for Google-internal usage instrumentation.

    for src, dest in mappings:
        if not inspect.isclass(src):
            raise TypeError(f'Detour source {src!r} is not a class.')
        if not inspect.isclass(dest) and not inspect.isfunction(dest):
            raise TypeError(
                f'Detour destination {dest!r} is not a class or a function.'
            )

    try:
        yield _global_detour_context.enter_scope(mappings)
    finally:
        _global_detour_context.leave_scope()

undetoured_new

undetoured_new(cls: type[Any], *args: Any, **kwargs: Any) -> Any

Creates an instance of cls, bypassing any detour on cls itself.

Only the top-level creation is undetoured: if cls.__init__ creates sub-objects, those creations still honor the detour mappings in scope. For example:

class A:
  def __init__(self, x):
    if x < 0:
      self.child = A(-x)
    else:
      self.x = x

class B(A):
  pass

with pg.detour([(A, B)]):
  a = pg.detouring.undetoured_new(A, -1)
  assert type(a) is A          # `a` itself is not detoured.
  assert type(a.child) is B    # But its sub-object is.

Parameters:

Name Type Description Default
cls type[Any]

The class whose instance will be created.

required
*args Any

Positional arguments to be passed to the class __init__.

()
**kwargs Any

Keyword arguments to be passed to the class __init__.

{}

Returns:

Type Description
Any

An instance of cls.

Source code in pygx/detouring/_class_detour.py
def undetoured_new(cls: type[Any], *args: Any, **kwargs: Any) -> Any:
    """Creates an instance of ``cls``, bypassing any detour on ``cls`` itself.

    Only the top-level creation is undetoured: if ``cls.__init__`` creates
    sub-objects, those creations still honor the detour mappings in scope.
    For example:

    ```python
    class A:
      def __init__(self, x):
        if x < 0:
          self.child = A(-x)
        else:
          self.x = x

    class B(A):
      pass

    with pg.detour([(A, B)]):
      a = pg.detouring.undetoured_new(A, -1)
      assert type(a) is A          # `a` itself is not detoured.
      assert type(a.child) is B    # But its sub-object is.
    ```

    Args:
      cls: The class whose instance will be created.
      *args: Positional arguments to be passed to the class ``__init__``.
      **kwargs: Keyword arguments to be passed to the class ``__init__``.

    Returns:
      An instance of ``cls``.
    """
    new_method = _global_detour_context.get_original_new(cls)
    if new_method is object.__new__:
        instance = new_method(cls)
    else:
        instance = new_method(cls, *args, **kwargs)
    # pylint: disable-next=unnecessary-dunder-call
    instance.__init__(*args, **kwargs)
    return instance

options: show_root_heading: false show_root_toc_entry: false members_order: source heading_level: 2