7
\$\begingroup\$

I rewrote some code I posted earlier this month. It attempts to mimic C#'s nameof() by extracting names from bytecode instructions.

This seems more reliable than looking into the previous frame's locals and asserting that there is an id match between some value in the current scope. However, I'm unsure just how much because it uses dis:

Use of this module should not be considered to work across Python VMs or Python releases.

I tried reducing the amount of bad input possible, but there is a quirky case where if I pass the function name_of((Cls.member(), var)) it returns 'member', if I remove the parentheses it will raise a SyntaxError because it no longer mistakes them for the end of the function call, and attribute access has been broken, so what the hell does it make of var.

import dis as _dis
from itertools import dropwhile as _dropwhile, takewhile as _takewhile

__all__ = ['name_of']

_LOAD = (
    'LOAD_CONST',
    'LOAD_NAME',
    'LOAD_ATTR',
    'LOAD_GLOBAL',
    'LOAD_FAST',
    'LOAD_CLOSURE',
    'LOAD_DEREF',
    'LOAD_CLASSDEREF'
)


class _Scopes:
    known = {}


def _dis_f(frame):
    return iter(_dis.Bytecode(frame.f_code))


def _known():
    def scope():
        return scope.cached[scope.no]

    scope.cached = []
    scope.no = 0
    return scope


def _not_eq(instruction):
    return instruction.argrepr != 'name_of'


def _in_load(instruction):
    return instruction.opname in _LOAD


def _invalidate(name):
    _Scopes.known.clear()
    raise SyntaxError('Invalid: {!r}'.format(name))


def _is_ident(name):
    if not name.isidentifier():
        _invalidate(name)


def _eq(instruction):
    return instruction.opname == _LOAD[2]


def _all_attr(instructions, name):
    if instructions[:-1] and not all(
            _eq(instruction) for instruction in instructions[1:]
    ):
        _invalidate(name)


def _cache(bytecode, scope):
    while True:
        instructions = _dropwhile(_not_eq, bytecode)
        if next(instructions, None) is None:
            break
        instructions = list(_takewhile(_in_load, instructions))
        name = instructions[-1].argrepr
        _is_ident(name)
        _all_attr(instructions, name)
        scope.cached.append(name)


def _pos(frame_id, scope):
    scope.no += 1
    if scope.no == len(scope.cached):
        del _Scopes.known[frame_id]


def name_of(_):
    try:
        raise NameError
    except Exception as exc:
        frame = exc.__traceback__.tb_frame.f_back
    frame_id = id(frame)
    if frame_id not in _Scopes.known:
        bytecode = _dis_f(frame)
        scope = _Scopes.known[frame_id] = _known()
        _cache(bytecode, scope)
    else:
        scope = _Scopes.known[frame_id]
    name = scope()
    _pos(frame_id, scope)
    return name

Overall it seems to work as intended, in both 3.4.4 and 3.6.3.

\$\endgroup\$
2
\$\begingroup\$

I'm not sure why you'd want to do this, however your code is really hard to understand.

  • You merge your functions together better.

    name_of finds the parent frame, and then also goes on to handle creating and caching scope, along with finding the next name. This is defiantly breaking single responsibility.

    I'd also merge all your one/two line functions into _cache. This is so we can tell what your code is actually doing.

    What I'd do use:

    • the class for _Scopes,
    • a _scope function to build the scope,
    • a _get_frame function to get the current frame, and
    • the name_of function, to use _get_frame, and then call _Scopes.
  • To create _get_frame I'd move the try into it's own function.

  • To create _scope I'd:

    • Merge most of your code into _cache.
    • I manually perform the dropwhile and takewhile loops.
    • Move the code from _is_ident and _all_attr into the function.
    • Merge _all_attr with the takewhile loop. This is as you raise if i.opname in _LOAD. Unless it's LOAD_ATTR.
    • Remove the instructions list. It's not needed with the above, and you only want the last value anyway.
    • Don't mutate _Scopes from here.
    • Make this a generator of names, don't mutate anything.
  • To create _Scopes I'd:

    • Create a class, that inherits from collections.UserDict.
    • Move the rest of your name_of code into a next_name method.
    • You also can change the code so that it's better too.

      • Get the scope by using self.get. This means if it's not None then you can return without a second look-up.
      • Try to get the next(scope, None) if it's none, then you can implicitly remove the scope, by falling through to the creating code. Otherwise you can return the name.
      • If you wrap the call to _scope in a try, then you can perform the _Scopes.clear() function in the except clause.

And so I'd change your code to (untested):

import dis as _dis
from collections import UserDict

__all__ = ['name_of']

_ATTR = 'LOAD_ATTR'
_LOAD = set(
    'LOAD_CONST',
    'LOAD_NAME',
    'LOAD_GLOBAL',
    'LOAD_FAST',
    'LOAD_CLOSURE',
    'LOAD_DEREF',
    'LOAD_CLASSDEREF'
)


def _scope(frame):
    bytecode = iter(_dis.Bytecode(frame.f_code))
    while True:
        for instruction in bytecode:
            if instruction == 'name_of':
                break
        else:
            break

        name = None
        for instruction in bytecode:
            if instruction.opname != _ATTR:
                if instruction.opname not in _LOAD:
                    break
                raise SyntaxError('Invalid: {!r}'.format(name))
            name = instruction

        if name is None or not name.argrepr.isidentifier():
            raise SyntaxError('Invalid: {!r}'.format(name))
        yield name.argrepr


class _Scopes(UserDict):
    def next_name(self, frame):
        frame_id = id(frame)
        scope = self.get(frame_id, None)
        if scope is not None:
            name = next(scope, None)
            if name is not None:
                return name
        try:
            scope = self.data[frame_id] = _scope(frame)
        except SyntaxError:
            self.clear()
            raise
        return next(scope)


_scopes = _Scopes()


def _get_frame():
    try:
        raise Exception
    except Exception as e:
        return exc.__traceback__.tb_frame.f_back


def name_of(_):
    frame = _get_frame().f_back
    return _scopes.next_name(frame)
\$\endgroup\$

Your Answer

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