Skip to content

Symbolic Detour

PyGX offers the pg.detour feature to redirect a class to another class or function, allowing the object creation behavior of that class to be changed dynamically at runtime.

Motivation

Symbolizing existing classes and functions is straightforward, but in order to use them, the original classes used in existing code must be replaced with the symbolic versions. However, modifying the source code may not always be possible, or objects created within a function or class method may not be accessible externally, making it impossible to manipulate them as part of the symbolic tree.

For example:

@pg.symbolize
def foo():
    # Object `a` is not 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 (SD) is a solution for these scenarios. It redirects the __new__ method of a class to another class or function when it's evaluated under a context manager. It is not dependent on symbolization and can be used to detour any classes; it does not require the presence of symbolic objects to modify the program.

Usage

Redirecting classes to classes

The code below illustrates class Foo being detoured to Bar under the context manager of pg.detour:

class Foo:

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __call__(self):
        return self.x + self.y


class Bar:

    def __init__(self, a, b):
        self.a = a
        self.b = b

    def __call__(self):
        return self.a * self.b


def my_fun():
    # Parameters of `Foo` are not exposed as arguments of `my_fun`.
    return Foo(1, 2)() + 2


# Symbolically detour `Foo` to `Bar` under the context manager,
# which changes the behavior inside `my_fun` while not requiring
# modifications to its source code.
with pg.detour([(Foo, Bar)]):
    v = my_fun()

assert v == (1 * 2) + 2

# Executing `my_fun` outside the context manager results in
# the creation of an original `Foo` object.
v2 = my_fun()
assert v2 == (1 + 2) + 2

Redirecting classes to functions

Symbolic detour can redirect classes to functions, but it has a limitation: if the function returns an object of the same type (or a subtype) as the original class, the object's __init__ method will be called again with the original arguments. This means that using detour to change argument values won't work. For example:

def foo_with_incremented_x(cls, x, y):
    return cls(x + 1, y)


with pg.detour([(Foo, foo_with_incremented_x)]):
    v = my_fun(1, 2)

# Fails: though argument `x` is incremented by the function,
# Python calls `__init__` again with the original value 1,
# so Foo's value remains unchanged.
assert v == (2 * 2) + 2

A simple solution is to create an instance of the symbolized class instead of the original class. Symbolic classes have built-in handling for re-initialization, which allows them to do nothing when __init__ is called after an object is already initialized. For example:

SymbolicFoo = pg.symbolize(Foo)


def foo_with_incremented_x(cls, x, y):
    return SymbolicFoo(x + 1, y)


with pg.detour([(Foo, foo_with_incremented_x)]):
    v = my_fun(1, 2)

# Okay now!
assert v == (2 * 2) + 2

The nesting rules

Symbolic detour can be nested, with outer-scope mappings taking precedence over inner mappings, allowing users to change object creation behaviors from the outside. For example, the following code detours class A to class C:

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

Detour is transitive across the inner and outer scope. For example:

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

For more details about symbolic detour, see pg.detour.