Source code for npplus.itemattr

# Copyright (c) 2016, David H. Munro
# All rights reserved.
# This is Open Source software, released under the BSD 2-clause license,
# see http://opensource.org/licenses/BSD-2-Clause for details.
"""Access items of a mapping object as if they were attributes.

Provides class decorator `items_are_attrs` for classes that map str
keys to values.  With the decorator, items in such a class may be
accessed as attributes with the key as their name.  You can escape key
names which are python reserved words or class methods or attributes
by appending a trailing underscore when accessing the item as an
attribute.

Provides class `ADict` which wraps the builtin dict type in this way.

References
----------

See
http://stackoverflow.com/questions/4984647/accessing-dict-keys-like-an-attribute-in-python
for a good discussion which mentions the following three relatively
small python packages that implement attribute-accessible dicts:

https://github.com/dsc/bunch  (combines object __dict__ with dict superclass)

https://github.com/bcj/AttrDict  (inherits from dict and other classes)

https://github.com/mewwts/addict  (focuses on recursive setattr)

--------

"""

# See the above stackoverflow question for an introduction to this issue.
# The answers of @Kimvais and @Doug-R provide some background on the
# pros and cons of conflating dict items and instance attributes.

from functools import partial
import sys

__all__ = ['items_are_attrs', 'ADict', 'redict']


class ItemsAreAttrs(object):
    # Mark this class as an argument to items_are_attrs decorator.
    decorator_argument = True

    # You can subclass ItemsAreAttrs and override name2key if you
    # want to customize this.
    @staticmethod
    def name2key(name):
        return name[:-1] if name.endswith('_') else name

    def get(self, name):  # __getattribute__
        if (name.startswith('__') or
                name in object.__getattribute__(self, '_IAA_class_attrs_')):
            return object.__getattribute__(self, name)
        return self[ItemsAreAttrs.name2key(name)]

    def set(self, name, value):  # __setattr__
        if name.startswith('__'):
            raise ValueError("cannot access __-prefixed item as attribute")
        self[ItemsAreAttrs.name2key(name)] = value

    def delete(self, name):  # __delattr__
        if name.startswith('__'):
            raise ValueError("cannot access __-prefixed item as attribute")
        del self[ItemsAreAttrs.name2key(name)]


[docs]def items_are_attrs(cls=None, methods=ItemsAreAttrs): """Class decorator to convert attribute accesses to item accesses. If you decorate a class with ``@items_are_attrs``, instance attributes will be converted to items. That is, ``x.name`` will be equivalent to ``x['name']``. The underlying class must be a mapping from symbol-like str keys to values for this to make sense. Use `items_are_attrs` when you expect most accesses to items in a class instance will use quoted strings. Not only is ``x.name`` easier to type than ``x['name']`` for interactive use, it is also easier to read. For the same reasons, you should prefer ``x[name]`` to ``getattr(x, name)`` when name is not a quoted string. To permit attribute-like access to items whose keys are python reserved words, or methods or attributes of the underlying class, you may append a trailing underscore to the attribute name. That is, ``x.name_`` is also equivalent to ``x['name']``. Only a single trailing underscore is removed, so you have to treat any key which really does end in trailing underscore as if it were a reserved word. Furthermore, a trailing underscore is *not* removed from any name beginning with leading dunder (double underscore), to avoid confusion with python special method and attribute names and name-mangling rules. See Also -------- ADict : items_are_attrs-wrapped version of the builtin dict Notes ----- The underscore escape convention is inspired by the PEP8 recommendation for dealing with conflicts between variable or function names and python reserved words. The basic usage is:: @items_are_attrs class MyClass(...): ... Attributes of an `items_are_attrs` class instance are not really instance attributes; `x.missing` will generate `KeyError`, not `AttributeError`. The attribute access is merely syntactic sugar. You can provide an argument to `items_are_attrs` to override the trailing underscore escape convention or any other behavior:: @items_are_attrs(MyAttrMethods) class MyClass(...): ... The `MyAttrMethods` class is a container for methods `get`, `set`, and `delete`, which `items_as_attrs` will copy into `MyClass` as its `__getattribute__`, `__setattr__`, and `__delattr__` methods. The `MyAttrMethods` class should be a subclass of the default, which is `itemattr.ItemsAreAttrs`. """ if hasattr(cls, 'decorator_argument'): cls, methods = None, methods if cls is None: return partial(items_are_attrs, methods=methods) # set class attribute names for __getattribute__ names = [n for n in dir(cls) if not n.startswith('__')] cls._IAA_class_attrs_ = set(names) if len(names) > 4 else names # methods.name would call methods.name.__get__(...), incorrectly # making the name function an unbound method of methods, so: cls.__getattribute__ = methods.__dict__['get'] cls.__setattr__ = methods.__dict__['set'] cls.__delattr__ = methods.__dict__['delete'] return cls
[docs]class ADict(dict): """Subclass of dict permitting access to items as if they were attributes. For an ADict instance `x`:: value = x.name # same as value = x['name'] x.name = value # same as x['name'] = value del x.name # same as del x['name'] For these equivalences to work, obviously `x.name` must be legal python syntax. `x` may still contain keys which are reserved names, include punctuation characters or are not strings at all, but such items are accessible only using the usual ``x[...]`` syntax. However, for the case of names which are reserved words or dict method names, ADict will modify the name by removing a single trailing underscore:: x.name_ # always same as x['name'] The inspiration for this behavior is the PEP8 recommendation to avoid reserved word collisions in variable names by appending a trailing underscore, `class_` for `class`, `yield_` for `yield`, and so on. (As PEP8 points out, if the name is under your control, a synonym may be a better choice.) The convention is useful not only for python reserved words, but also for dict method names: Use `keys_` for `keys`, `items_` for `items`, etc. The downside is that when item keys really do end in '_', you must append an extra underscore. A trailing underscore will **not** be removed, however, for names beginning with double underscore, to avoid problems with python special method and attribute names. Use an ADict when you expect most accesses to items in a dict `x` will be quoted item names; ``x['name']`` is harder to read or to type in an interactive session than ``x.name``. However, even when `x` is an ADict, use ``x[name]`` not ``getattr(x, name)``. See Also -------- items_are_attrs : class decorator to provide this for any class redict : recursively toggle between dict and ADict """ __slots__ = [] def __getattr__(self, name): return self[ItemsAreAttrs.name2key(name)] def __setattr__(self, name, value): self[ItemsAreAttrs.name2key(name)] = value def __delattr__(self, name): del self[ItemsAreAttrs.name2key(name)] def __repr__(self): return "ADict(" + super(ADict, self).__repr__() + ")"
[docs]def redict(d, cls=None): """Recursively convert a nested dict to a nested ADict. Parameters ---------- d : dict or ADict instance A dict, possibly nested, to be converted. cls : dict or subclass of dict, optional The dict-like cls to recursively convert `d` and any sub-dicts into. By default, if `d` is an `ADict`, `cls` is `dict`, otherwise `cls` is `ADict`, so repeated calls to `redict` toggle between `dict` and `ADict`. Returns ------- dnew : dict or ADict A copy of `d` whose class is `cls`. Any items which are dict instances are similarly copied to be `cls` instances. Non-dict items are not copied unless assignment makes copies. """ if cls is None: cls = dict if isinstance(d, ADict) else ADict dnew = cls(d) for key, value in _iteritems(d): if isinstance(value, dict): dnew[key] = redict(value, cls) return dnew
_iteritems = dict.iteritems if sys.version_info[0] < 3 else dict.items