Skip to content

Commit

Permalink
Added DefaultMunch, which returns a special value for missing attributes
Browse files Browse the repository at this point in the history
 - Added a subclass of Munch, DefaultMunch, which returns a user-
   defined value when a requested key is not in the collection.
   The interface and behaviour is similar to
   collections.defaultdict, except that:

    - Only a constant value is returned; there is no
      default_factory method.

    - Retrieval of a missing value does not change the size of
      the collection.
  • Loading branch information
Alex Fraser committed Jul 3, 2017
1 parent e323f44 commit fb0a119
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 12 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ detox-test:
travis-test: test

test: env
.env/bin/py.test tests munch --doctest-modules
.env/bin/py.test test_munch.py munch --doctest-modules

coverage-test: env
.env/bin/coverage run .env/bin/nosetests -w tests
Expand Down
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,39 @@ In addition, Munch instances will have a ``toYAML()`` method that returns the YA
Finally, Munch converts easily and recursively to (``unmunchify()``, ``Munch.toDict()``) and from (``munchify()``, ``Munch.fromDict()``) a normal ``dict``, making it easy to cleanly serialize them in other formats.


Default Values
--------------

``DefaultMunch`` instances return some default value when an attribute is missing from the collection. Like ``collections.defaultdict``, the first argument is the value to use for missing keys:

````py
>>> undefined = object()
>>> b = DefaultMunch(undefined, {'hello': 'world!'})
>>> b.hello
'world!'
>>> b.foo is undefined
True
````

``DefaultMunch.fromDict()`` also takes the ``default`` argument:

````py
>>> undefined = object()
>>> b = DefaultMunch.fromDict({'recursively': {'nested': 'value'}}, undefined)
>>> b.recursively.nested == 'value'
True
>>> b.recursively.foo is undefined
True
````


Miscellaneous
-------------

* It is safe to ``import *`` from this module. You'll get: ``Munch``, ``munchify``, and ``unmunchify``.
* It is safe to ``import *`` from this module. You'll get: ``Munch``, ``DefaultMunch``, ``munchify`` and ``unmunchify``.
* Ample Tests. Just run ``pip install tox && tox`` from the project root.

Feedback
--------

Open a ticket / fork the project on [GitHub](http://github.com/Infinidat/munch).

67 changes: 59 additions & 8 deletions munch/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
__version__ = '2.1.1'
VERSION = tuple(map(int, __version__.split('.')))

__all__ = ('Munch', 'munchify', 'unmunchify')
__all__ = ('Munch', 'munchify', 'DefaultMunch', 'unmunchify')

from .python3_compat import * # pylint: disable=wildcard-import

Expand Down Expand Up @@ -187,8 +187,8 @@ def __dir__(self):

__members__ = __dir__ # for python2.x compatibility

@staticmethod
def fromDict(d):
@classmethod
def fromDict(cls, d):
""" Recursively transforms a dictionary into a Munch via copy.
>>> b = Munch.fromDict({'urmom': {'sez': {'what': 'what'}}})
Expand All @@ -197,10 +197,61 @@ def fromDict(d):
See munchify for more info.
"""
return munchify(d)
return munchify(d, cls)

def copy(self):
return Munch.fromDict(super(Munch, self).copy())
return Munch.fromDict(self)


class DefaultMunch(Munch):
"""
A Munch that returns a user-specified value for missing keys.
"""

def __init__(self, *args, **kwargs):
""" Construct a new DefaultMunch. Like collections.defaultdict, the
first argument is the default value; subsequent arguments are the
same as those for dict.
"""
# Mimic collections.defaultdict constructor
if args:
default = args[0]
args = args[1:]
else:
default = None
super(DefaultMunch, self).__init__(*args, **kwargs)
self.__default__ = default

def __getattr__(self, k):
""" Gets key if it exists, otherwise returns the default value."""
try:
return super(DefaultMunch, self).__getattr__(k)
except AttributeError:
return self.__default__

def __setattr__(self, k, v):
if k == '__default__':
object.__setattr__(self, k, v)
else:
return super(DefaultMunch, self).__setattr__(k, v)

def __getitem__(self, k):
""" Gets key if it exists, otherwise returns the default value."""
try:
return super(DefaultMunch, self).__getitem__(k)
except KeyError:
return self.__default__

@classmethod
def fromDict(cls, d, default=None):
return munchify(d, factory=lambda d_: cls(default, d_))

def copy(self):
return DefaultMunch.fromDict(self, default=self.__default__)

def __repr__(self):
return '%s(%r, %s)' % (
type(self).__name__, self.__undefined__, dict.__repr__(self))


# While we could convert abstract types like Mapping or Iterable, I think
Expand All @@ -210,7 +261,7 @@ def copy(self):
# Should you disagree, it is not difficult to duplicate this function with
# more aggressive coercion to suit your own purposes.

def munchify(x):
def munchify(x, factory=Munch):
""" Recursively transforms a dictionary into a Munch via copy.
>>> b = munchify({'urmom': {'sez': {'what': 'what'}}})
Expand All @@ -230,9 +281,9 @@ def munchify(x):
nb. As dicts are not hashable, they cannot be nested in sets/frozensets.
"""
if isinstance(x, dict):
return Munch((k, munchify(v)) for k, v in iteritems(x))
return factory((k, munchify(v, factory)) for k, v in iteritems(x))
elif isinstance(x, (list, tuple)):
return type(x)(munchify(v) for v in x)
return type(x)(munchify(v, factory) for v in x)
else:
return x

Expand Down
73 changes: 72 additions & 1 deletion test_munch.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json
import pytest
from munch import Munch, munchify, unmunchify
from munch import DefaultMunch, Munch, munchify, unmunchify


def test_base():
Expand Down Expand Up @@ -111,7 +111,11 @@ def test_fromDict():
def test_copy():
m = Munch(urmom=Munch(sez=Munch(what='what')))
c = m.copy()
assert c is not m
assert c.urmom is not m.urmom
assert c.urmom.sez is not m.urmom.sez
assert c.urmom.sez.what == 'what'
assert c == m


def test_munchify():
Expand Down Expand Up @@ -171,3 +175,70 @@ def test_reserved_attributes(attrname):
assert attr == {}
else:
assert callable(attr)


def test_getattr_default():
b = DefaultMunch(bar='baz', lol={})
assert b.foo is None
assert b['foo'] is None

assert b.bar == 'baz'
assert getattr(b, 'bar') == 'baz'
assert b['bar'] == 'baz'
assert b.lol is b['lol']
assert b.lol is getattr(b, 'lol')

undefined = object()
b = DefaultMunch(undefined, bar='baz', lol={})
assert b.foo is undefined
assert b['foo'] is undefined


def test_setattr_default():
b = DefaultMunch(foo='bar', this_is='useful when subclassing')
assert hasattr(b.values, '__call__')

b.values = 'uh oh'
assert b.values == 'uh oh'
assert b['values'] is None

assert b.__default__ is None
assert '__default__' not in b


def test_delattr_default():
b = DefaultMunch(lol=42)
del b.lol

assert b.lol is None
assert b['lol'] is None


def test_fromDict_default():
undefined = object()
b = DefaultMunch.fromDict({'urmom': {'sez': {'what': 'what'}}}, undefined)
assert b.urmom.sez.what == 'what'
assert b.urmom.sez.foo is undefined


def test_copy_default():
undefined = object()
m = DefaultMunch.fromDict({'urmom': {'sez': {'what': 'what'}}}, undefined)
c = m.copy()
assert c is not m
assert c.urmom is not m.urmom
assert c.urmom.sez is not m.urmom.sez
assert c.urmom.sez.what == 'what'
assert c == m
assert c.urmom.sez.foo is undefined
assert c.urmom.sez.__undefined__ is undefined


def test_munchify_default():
undefined = object()
b = munchify(
{'urmom': {'sez': {'what': 'what'}}},
lambda d: DefaultMunch(undefined, d))
assert b.urmom.sez.what == 'what'
assert b.urdad is undefined
assert b.urmom.sez.ni is undefined

0 comments on commit fb0a119

Please sign in to comment.