Stobber
Stobber

Reputation: 164

How to use a mixin metaclass to modify instance object on initialization

I'm working through the Advent of Code 2017 problems for fun, and a few of them require that you interpret and execute pseudo-assembly programs. I want to write a reuse module that provides the necessary infrastructure for this. Specifically, I want to provide sandboxed exec and eval functions, and a function that resolves a variable in the interpretation namespace. I read about metaclasses and started framing one up, but I'm getting stuck on the implementation details. I envision being able to do this:

class InterpreterMixinMeta(type):
    """see below for my attempt"""
    pass

class InterpreterMixin(metaclass=InterpreterMixinMeta):
    pass

class Program(InterpreterMixin):
    """does the actual work of running a pseudo-assembly program"""
    pass

Here's my initial attempt at InterpreterMixinMeta. How do I create properties and methods on the object that will use the mixin? Properties like a unique copy of the locals dict for execs and evals, methods like val().

class InterpreterMixinMeta(type):
    """
    A constructor for InterpreterMixin. Creates a new mixin class that
    exposes functions needed for interpreting pseudo-assembly programs:
    `execs`, `evals`, and `val`
    """

    @staticmethod
    def init_execs(exc_locals: dict):
        """Creates a persistent sandbox environment for calls to `exec`"""
        sbox_globals = {'__builtins__': None}

        def sbox_exec(source: str):
            return exec(source, sbox_globals, exc_locals)

        return sbox_exec

    @staticmethod
    def init_evals(exc_locals: dict):
        """Creates a persistent sandbox environment for calls to `eval`"""
        sbox_globals = {'__builtins__': None}

        def sbox_eval(expr: str):
            return eval(expr, sbox_globals, exc_locals)

        return sbox_eval

    def val(self, x: str) -> str:
        """Returns the integer value of the input, formatted as a string"""
        if x.isdigit():
            return x
        else:
            return str(self.evals(x))

    def __new__(mcs, name, bases, nspace):
        # how do I let each object define a myLocals dict?
        nspace['execs'] = InterpreterMixinMeta.init_execs(myLocals)
        return super().__new__(mcs, name, bases, nspace)

Upvotes: 1

Views: 183

Answers (1)

Stobber
Stobber

Reputation: 164

I solved this problem without using a metaclass. I first had to step back and rethink my object model. Ultimately, the roles and relationships I want to define are:

  • Executor: A class instance that defines the state of a sandboxed namespace and exposes an API for interacting with it
  • Interpreter: A class that defines an API for translating specific input grammar into specific Python grammar. It uses an interpreter (instance) to execute the final grammar.
  • Program: A list of statements to be executed in order and a covenient API for executing them and retrieving information about their results.

Each executor (instance) needs to initialize itself with a sandbox for the exec and eval functions. That's not what metaclasses are for: metaclasses are for initializing class objects...not class instance objects. To intialize a class instance object, I need to write a class. That's it: just a class.

So I wrote an Executor that initializes executor objects with their own locals and globals dicts. It also needs to provide functions that always use these dicts when called. That's what closures are for. And my approach to creating closures in each instance object was to monkey patch them in after initializing the object.

Here's the resultant Executor class:

from types import MethodType

class Executor:
    """Is a sandboxed executor and evaluator of Python statements."""

    def __init__(self, sbox_globals: dict=None, sbox_locals: dict=None):

        # Set up execution context for this instance
        if sbox_globals is not None:
            sbox_globals.setdefault('__builtins__', None)
            self.sbox_globals = sbox_globals
        else:
            self.sbox_globals = {'__builtins__': None}
        self.sbox_locals = {} if sbox_locals is None else sbox_locals

        # Monkey patch closures onto this instance
        def sbox_exec(self, source: str):
            """A persistent sandbox environment for calls to `exec`"""
            return exec(source, self.sbox_globals, self.sbox_locals)

        def sbox_eval(self, expr: str):
            """A persistent sandbox environment for calls to `eval`"""
            return eval(expr, self.sbox_globals, self.sbox_locals)

        self.execs = MethodType(sbox_exec, self)
        self.evals = MethodType(sbox_eval, self)

Armed with this tool, I wrote a class that implements the Interpreter and Program roles at once, calling it Interpreter. An interpreter (instance) composes an executor (instance) and implements the iterator protocol. It looks something like this:

from collections.abc import Iterator

class Interpreter(Iterator):
    """
    Is a translator from input grammar to Python grammar. Is also an iterator
      that executes one statement per call.
    Has an Executor and uses it when called in iteration.
    """

    def __init__(self, instruction_set: list):
        self.exc = Executor(sbox_locals={})

        # iterator protocol
        self.instrs = instruction_set
        self.i = 0

    def __iter__(self):
        return super().__iter__()

    def __next__(self):
        if 0 <= self.i < len(self.instrs):

            instr = self.instrs[self.i][0]
            param = self.instrs[self.i][1]

            if instr == 'foo':
                cmd = f'py_foo({param})'
                self.exc.execs(cmd)
                ret = f'{self.i}: ' + cmd
            elif instr == 'bar':
                cmd = f'py_bar({param})'
                self.exc.execs(cmd)
                ret = f'{self.i}: ' + cmd
            else:
                ret = f'{self.i}: {instr} no-op'

            self.i += 1
            return ret
        else:
            raise StopIteration

Upvotes: 1

Related Questions