4
\$\begingroup\$

I don't claim this is useful nor that it adheres to best practices. There's also no good reason the hack is a context manager, and it won't work on the interactive shell.

__init__.py

from .context_manager import FrameHack
from .varname_setter import setvarname
from . import varnamespace

context_manager.py

from re import findall, split

from .instantiation_parser import assemble_cm, walk_f_back


class T:
    def __init__(self):
        pass


class TraceError(Exception):
    pass


class FrameHack:
    no = 0

    def __init__(self, *_):
        self.call_re = f'{self.__class__.__name__}\((.+)\).*'
        self.sep_re = '\s*,\s*'
        self.t = T()

    def __mul__(self, code):
        return self.call_re * code.count(self.__class__.__name__)

    def advance_pos(self, varnames):
        if isinstance(varnames, tuple):
            return len(varnames), varnames[self.__class__.no]
        else:
            return 1, varnames

    def setattrs(self, t, varnames, f_back):
        for name in split(self.sep_re, varnames.strip(', ')):
            if name.startswith('*'):
                for unpacked_name in walk_f_back(f_back, name.strip('* ')):
                    setattr(t, unpacked_name, unpacked_name)
            else:
                setattr(t, name, name)

    def normalize_pos(self, no):
        if self.__class__.no == no - 1:
            self.__class__.no = 0
        else:
            self.__class__.no += 1

    def __enter__(self):
        try:
            raise TraceError('Trace from raise')
        except Exception as exc:
            f_back = exc.__traceback__.tb_frame.f_back
            with open(f_back.f_code.co_filename) as f:
                code = assemble_cm(f.readlines(), f_back.f_lineno - 1)
            try:
                no, varnames = self.advance_pos(findall(self * code, code).pop())
            except IndexError as exc:
                raise TraceError('No arguments were passed to the constructor') from exc
            else:
                self.setattrs(self.t, varnames, f_back)
                self.normalize_pos(no)
                return self.t

    def __exit__(self, *_):
        for name in vars(self.t).copy():
            delattr(self.t, name)
        return False

Could not use contextlib.contextmanager on a function since co_filename would be the contextlib module so rolled with a class that allows keeping count of how many times the context manager was called between the with statement and the terminating colon.

Next module accounts for calls stretching across multiple lines.

instantiation_parser.py

UNWANTED = {ord(char): '' for char in '\\\r\n'}


def yield_ins(sequence, substring):
    line = ''
    sequence = iter(sequence)
    while substring not in line:
        line = next(sequence)
        yield line


def rev_readlines(startswith, code, f_lineno):
    if startswith not in code[f_lineno]:
        return list(yield_ins(reversed(code[:f_lineno]), startswith))[::-1]
    else:
        return []


def readlines(endswith, code, f_lineno):
    return list(yield_ins(code[f_lineno:], endswith))


def rm_unwanted(code):
    return code.translate(UNWANTED).strip()


def assemble_cm(*args):
    return ''.join(map(rm_unwanted, rev_readlines('with', *args) + readlines(':', *args)))


def walk_f_back(f_back, packed):
    unpacked = [unpacked_name for unpacked_name, v in f_back.f_locals.items() if v in f_back.f_locals[packed]]
    if bool(unpacked) is True:
        return unpacked
    else:
        return walk_f_back(f_back.f_back, packed)

walk_f_back deals with this scenario:

hello, world = 0, 1
packed = hello, world    

It will return the name of the first variable(s) whose value matches one in f_back.f_locals[packed]. I deem this acceptable. My goal was to create a sort of tunnel up the frames, bound to a variable name. It can be intercepted.

test.py

from unittest import TestCase, main

from . import FrameHack
from .context_manager import TraceError
from . import setvarname


class Test(TestCase):
    def test_multiline_nested(self):
        w, x, y, z = 0, 1, 2, 3
        _, *xy, _ = w, x, y, z
        with FrameHack(
                w, x
        ) as t0:
            with FrameHack \
                        (y
                        , z) as t1, FrameHack(
                *xy,
                w) as t2, FrameHack(w, z) as t3:
                self.assertEqual(vars(t0), {'x': 'x', 'w': 'w'})
                self.assertEqual(vars(t1), {'y': 'y', 'z': 'z'})
                self.assertEqual(vars(t2), {'x': 'x', 'y': 'y', 'w': 'w'})
                self.assertEqual(vars(t3), {'w': 'w', 'z': 'z'})
        self.assertTrue(vars(t0) == vars(t1) == vars(t2) == vars(t3) == {})

    def test_x_function(self):
        coro = setvarname()

        def func0(*args):
            var0, var1 = 10, 20
            with FrameHack(var0, *args, var1) as t:
                coro.send((t.x, 30))
                coro.send((t.y, 40))
                return vars(t).copy()

        def func1(*args):
            return func0(*args)

        def func2():
            w, x, y, z = 0, 1, 2, 3
            args = w, x, y, z
            return func1(*args)

        self.assertEqual(func2(), {'var0': 'var0', 'x': 'x', 'y': 'y', 'z': 'z', 'w': 'w', 'var1': 'var1'})
        from .varnamespace import x, y
        self.assertEqual(x + 10, y)
        self.assertEqual(coro.send(True), 0)
        coro.close()

    def test_indexerror(self):
        exc = ''
        try:
            with FrameHack() as _:
                pass
        except TraceError as trace_exc:
            exc = str(trace_exc)
        finally:
            self.assertEqual(exc, 'No arguments were passed to the constructor')


if __name__ == '__main__':
    main()

The unittests exemplify usage.

The following module is a work around for Python3's limitation on exec('var = 0') when used on local scopes. Set a variable by name on an empty module, from ... import ... the variable back in some other scope and you have a variable and not an attribute.

varname_setter.py

from . import varnamespace


def prime_coro(func):
    def deco(*args, **kwargs):
        coro = func(*args, **kwargs)
        next(coro)
        return coro

    return deco


@prime_coro
def setvarname(module=varnamespace):
    clean = module.__dict__.copy()
    cached = set()
    while True:
        var = yield len(cached)
        if var is True:
            for varname in cached.copy():
                module.__dict__.pop(varname)
                cached.remove(varname)
        elif var[0] in clean:
            continue
        else:
            setattr(module, *var)
            cached.add(var[0])

The coroutine yields back how many attributes were set in order to keep track of how dirty it's become and clean if necessary.

varnamespace.py -> empty

\$\endgroup\$
  • 1
    \$\begingroup\$ I think the fact that you need this hacky solution signifies something is very wrong with your design. What are you trying to reach? \$\endgroup\$ – Daniel Dec 27 '17 at 13:44
  • 1
    \$\begingroup\$ I don't need it, it's not part of a larger program. I read some solutions to the problem of getting a variable's name, read it's not a trivial meta programming task too, so this is merely exploratory. \$\endgroup\$ – user133955 Dec 27 '17 at 14:43
2
\$\begingroup\$

Disclaimer: I am not quite sure this can be considered as a proper review but this is definitly too big for a comment.

Not all variables are in f_locals

Not all variables that can be accessed from a frame are in frame.f_locals, you'll miss non-local variables which are:

  • enclosing variables
  • global variables
  • builtins

This is known as the LEGB Rule (Local Enclosing Global Builtin).

It is hard to tell whether this is relevant to the problem you are trying to solve but if you do, frame.f_globals and frame.f_builtins would interest you (I haven't found a way to access the variables in enclosing scope).

I've played with this as part of one of my pet project. Here's the parts that could be more relevant to you:

def merge_dict(*dicts):
    """Merge dicts and return a dictionnary mapping key to list of values.

    Order of the values corresponds to the order of the original dicts.
    """
    ret = dict()
    for dict_ in dicts:
        for key, val in dict_.items():
            ret.setdefault(key, []).append(val)
    return ret

ScopedObj = namedtuple('ScopedObj', 'obj scope')


def add_scope_to_dict(dict_, scope):
    """Convert name:obj dict to name:ScopedObj(obj,scope) dict."""
    return dict((k, ScopedObj(v, scope)) for k, v in dict_.items())


def get_objects_in_frame(frame):
    """Get objects defined in a given frame.

    This includes variable, types, builtins, etc.
    The function returns a dictionnary mapping names to a (non empty)
    list of ScopedObj objects in the order following the LEGB Rule.
    """
    # https://www.python.org/dev/peps/pep-0227/ PEP227 Statically Nested Scopes
    # "Under this proposal, it will not be possible to gain dictionary-style
    #      access to all visible scopes."
    # https://www.python.org/dev/peps/pep-3104/ PEP 3104 Access to Names in
    #      Outer Scopes
    # LEGB Rule : missing E (enclosing) at the moment.
    # I'm not sure if it can be fixed but if it can, suggestions
    # tagged TODO_ENCLOSING could be implemented (and tested).
    return merge_dict(
        add_scope_to_dict(frame.f_locals, 'local'),
        add_scope_to_dict(frame.f_globals, 'global'),
        add_scope_to_dict(frame.f_builtins, 'builtin'),
    )

Shallow review

self.call_re seems to be variable that does not change, is the same for all instance and is used only in __mul__.

You could define it as a "constant" or as a local variable in __mul__. The same thing applies to sep_re.

def __init__(self, *_):
    self.t = T()

def __mul__(self, code):
    CALL_RE = f'{self.__class__.__name__}\((.+)\).*'
    return CALL_RE * code.count(self.__class__.__name__)

def setattrs(self, t, varnames, f_back):
    SEP_RE = '\s*,\s*'
    for name in split(SEP_RE, varnames.strip(', ')):

I think that normalise_pos would be easier to understand if it was written like this:

def normalize_pos(self, no):
    self.__class__.no += 1
    if self.__class__.no == no:
        self.__class__.no = 0

in walk_f_back: if bool(unpacked) is True: can be re-written if unpacked:.

Also, Python does not handle deep recursions very well because it does not perform Tail Recursion Optimisation. If needed, you can rewrite the function:

def walk_f_back(f_back, packed):
    while True:
        unpacked = [unpacked_name for unpacked_name, v in f_back.f_locals.items() if v in f_back.f_locals[packed]]
        if unpacked:
            return unpacked
        f_back = f_back.f_back

The formatting in the tests make things hard to read for no good reason. It's ok to go a few characters above the 80-char limits if is makes the code better. There's nothing wrong with:

    with FrameHack(w, x) as t0:
        with FrameHack(y, z) as t1, FrameHack(*xy, w) as t2, FrameHack(w, z) as t3:

In setvarname, you can get rid of continue.

    if var is True:
        for varname in cached.copy():
            module.__dict__.pop(varname)
            cached.remove(varname)
    elif var[0] not in clean:
        setattr(module, *var)
        cached.add(var[0])

I'll try to have a real look at your code asap but I must confess it is quite hard to dive into that kind of things :)

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy