Skip to content

Commit fa1f4db

Browse files
committed
Re-factored dictable_namedtuple and related functions for py3.6 / 3.7 compat
1 parent 2f67854 commit fa1f4db

File tree

2 files changed

+157
-113
lines changed

2 files changed

+157
-113
lines changed

privex/helpers/collections.py

Lines changed: 112 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@
7979
* Can convert instance into a dict simply by casting: ``dict(john)``
8080
8181
* Can set new items/attributes on an instance, even if they weren't previously defined.
82-
82+
83+
* NOTE: You cannot edit an original namedtuple field defined on the type, those remain read only
8384
8485
There are three functions available for working with ``dictable_namedtuple`` classes/instances,
8586
each for different purposes.
@@ -203,9 +204,13 @@
203204
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
204205
205206
"""
207+
import inspect
206208
import sys
207209
from collections import namedtuple
208210
from typing import Dict, Optional, NamedTuple, Union, Type
211+
import logging
212+
213+
log = logging.getLogger(__name__)
209214

210215

211216
class DictObject(dict):
@@ -414,100 +419,117 @@ class type, but you set this to ``Man``, then this will return a ``Man`` class t
414419
module = named_type.__module__ if module is None else module
415420
read_only = kwargs.pop('read_only', False)
416421

417-
class DictableNamedTuple(named_type):
418-
"""
419-
Customized :func:`.namedtuple` class - part of :func:`privex.helpers.common.dictable_namedtuple`
422+
_dt = make_dict_tuple(typename, ' '.join(named_type._fields), read_only=read_only)
420423

421-
Unlike the normal :func:`.namedtuple` class, this class supports extra functionality:
424+
if module is None:
425+
try:
426+
module = sys._getframe(1).f_globals.get('__name__', '__main__')
427+
except (AttributeError, ValueError):
428+
pass
422429

423-
* Can access fields via item/key: ``john['first_name']``
430+
if module is not None:
431+
_dt.__module__ = module
432+
return _dt
424433

425-
* Can convert instance into a dict simply by casting: ``dict(john)``
426434

427-
* Can set new items/attributes on an instance, even if they weren't previously defined.
428-
429-
* NOTE: You cannot edit an original namedtuple field defined on the type, those remain read only
430-
431-
**Example - creating dictable_namedtuple, adding new item 'middle_name', and casting to dict**::
432-
433-
>>> from privex.helpers import dictable_namedtuple
434-
>>> Person = dictable_namedtuple('Person', 'first_name last_name')
435-
>>> john = Person('John', 'Doe')
436-
>>> john
437-
Person(first_name='John', last_name='Doe')
438-
>>> john['middle_name'] = 'Davis'
439-
>>> john
440-
first_name='John', last_name='Doe', middle_name='Davis'
441-
Person(first_name='John', last_name='Doe', middle_name='Davis')
442-
>>> dict(john)
443-
{'first_name': 'John', 'last_name': 'Doe', 'middle_name': 'Davis'}
444-
435+
def make_dict_tuple(typename, field_names, *args, **kwargs):
436+
"""
437+
Generates a :func:`collections.namedtuple` type, with added / modified methods injected to make it
438+
into a ``dictable_namedtuple``.
439+
440+
Note: You probably want to be using :func:`.dictable_namedtuple` instead of calling this directly.
441+
"""
442+
read_only = kwargs.pop('read_only', False)
443+
module = kwargs.pop('module', None)
444+
445+
# Create a namedtuple type to use as a base
446+
BaseNT = namedtuple(typename, field_names, **kwargs)
447+
448+
def __init__(self, *args, **kwargs):
449+
self.__dict__['_extra_items'] = dict()
450+
for i, a in enumerate(list(args)):
451+
self.__dict__[self._fields[i]] = a
452+
for k, a in kwargs.items():
453+
self.__dict__[k] = a
454+
455+
def __iter__(self):
456+
"""This ``__iter__`` method allows for casting a dictable_namedtuple instance using ``dict(my_nt)``"""
457+
for k in self._fields: yield (k, getattr(self, k),)
458+
459+
def __getitem__(self, item):
460+
"""Handles when a dictable_namedtuple instance is accessed like ``my_nt['abc']`` or ``my_nt[0]``"""
461+
if type(item) is int:
462+
return self.__dict__[self._fields[item]]
463+
return getattr(self, item)
464+
465+
def __getattr__(self, item):
466+
"""Handles when a dictable_namedtuple instance is accessed like ``my_nt.abcd``"""
467+
try:
468+
_v = object.__getattribute__(self, '_extra_items')
469+
return _v[item]
470+
except (KeyError, AttributeError):
471+
return object.__getattribute__(self, item)
472+
473+
def __setitem__(self, key, value):
474+
"""Handles when a dictable_namedtuple instance is accessed like ``my_nt['abc'] = 'def'``"""
475+
if hasattr(self, key):
476+
return tuple.__setattr__(self, key, value)
477+
if self._READ_ONLY:
478+
raise KeyError(f"{self.__class__.__name__} is read only. You cannot set a non-existent field.")
479+
self._extra_items[key] = value
480+
if key not in self._fields:
481+
tuple.__setattr__(self, '_fields', self._fields + (key,))
482+
483+
def __setattr__(self, key, value):
484+
"""Handles when a dictable_namedtuple instance is accessed like ``my_nt.abcd = 'def'``"""
485+
if key in ['_extra_items', '_fields'] or key in self._fields:
486+
return tuple.__setattr__(self, key, value)
487+
if self._READ_ONLY:
488+
raise AttributeError(f"{self.__class__.__name__} is read only. You cannot set a non-existent field.")
489+
self._extra_items[key] = value
490+
if key not in self._fields:
491+
tuple.__setattr__(self, '_fields', self._fields + (key,))
492+
493+
def _asdict(self):
445494
"""
446-
_READ_ONLY = read_only
447-
448-
def __init__(self, *args, **kwargs):
449-
self._extra_items = DictObject()
450-
451-
def __iter__(self):
452-
"""This ``__iter__`` method allows for casting a dictable_namedtuple instance using ``dict(my_nt)``"""
453-
for k in self._fields: yield (k, getattr(self, k),)
454-
455-
def __getitem__(self, item):
456-
"""Handles when a dictable_namedtuple instance is accessed like ``my_nt['abc']`` or ``my_nt[0]``"""
457-
if type(item) is int:
458-
return self.__getattribute__(self._fields[item])
459-
try:
460-
return self._extra_items[item]
461-
except (KeyError, AttributeError):
462-
return self.__getattribute__(item)
463-
464-
def __getattribute__(self, item):
465-
"""Handles when a dictable_namedtuple instance is accessed like ``my_nt.abcd``"""
466-
try:
467-
v = super().__getattribute__('_extra_items')
468-
v = v[item]
469-
except (KeyError, AttributeError):
470-
v = super().__getattribute__(item)
471-
return v
472-
473-
def __setitem__(self, key, value):
474-
"""Handles when a dictable_namedtuple instance is accessed like ``my_nt['abc'] = 'def'``"""
475-
if hasattr(self, key):
476-
return self.__setattr__(key, value)
477-
if self._READ_ONLY:
478-
raise KeyError(f"{self.__class__.__name__} is read only. You cannot set a non-existent field.")
479-
self._extra_items[key] = value
480-
if key not in self._fields:
481-
self._fields = self._fields + (key,)
482-
483-
def __setattr__(self, key, value):
484-
"""Handles when a dictable_namedtuple instance is accessed like ``my_nt.abcd = 'def'``"""
485-
if key in ['_extra_items', '_fields'] or key in self._fields:
486-
return super().__setattr__(key, value)
487-
if self._READ_ONLY:
488-
raise AttributeError(f"{self.__class__.__name__} is read only. You cannot set a non-existent field.")
489-
self._extra_items[key] = value
490-
if key not in self._fields:
491-
self._fields = self._fields + (key,)
492-
493-
def _asdict(self):
494-
"""
495-
The original namedtuple ``_asdict`` doesn't work with our :meth:`.__iter__`, so we override it
496-
for compatibility. Simply calls ``return dict(self)`` to convert the instance to a dict.
497-
"""
498-
return dict(self)
499-
500-
def __repr__(self):
501-
_n = ', '.join(f"{name}='{getattr(self, name)}'" for name in self._fields)
502-
return f"{self.__class__.__name__}({_n})"
495+
The original namedtuple ``_asdict`` doesn't work with our :meth:`.__iter__`, so we override it
496+
for compatibility. Simply calls ``return dict(self)`` to convert the instance to a dict.
497+
"""
498+
return dict(self)
499+
500+
def __repr__(self):
501+
_n = ', '.join(f"{name}='{getattr(self, name)}'" for name in self._fields)
502+
return f"{self.__class__.__name__}({_n})"
503+
504+
# Inject our methods defined above into the namedtuple type BaseNT
505+
BaseNT.__getattr__ = __getattr__
506+
BaseNT.__getitem__ = __getitem__
507+
BaseNT.__setitem__ = __setitem__
508+
BaseNT.__setattr__ = __setattr__
509+
BaseNT._asdict = _asdict
510+
BaseNT.__repr__ = __repr__
511+
BaseNT.__iter__ = __iter__
512+
BaseNT.__init__ = __init__
513+
BaseNT._READ_ONLY = read_only
514+
515+
# Create a class for BaseNT with tuple + object mixins, allowing things like __dict__ to function properly
516+
# and allowing for tuple.__setattr__ / object.__getattribute__ calls.
517+
class K(BaseNT, tuple, object):
518+
pass
519+
520+
# Get the calling module so we can overwrite the module name of the class.
521+
if module is None:
522+
try:
523+
module = sys._getframe(1).f_globals.get('__name__', '__main__')
524+
except (AttributeError, ValueError):
525+
pass
503526

504-
DictableNamedTuple.__name__ = typename
505-
DictableNamedTuple.__qualname__ = typename
506-
DictableNamedTuple.__bases__ = (named_type, tuple)
527+
# Overwrite the type name + module to match the originally requested typename
528+
K.__name__ = BaseNT.__name__
529+
K.__qualname__ = BaseNT.__qualname__
530+
K.__module__ = module
507531

508-
if module is not None:
509-
DictableNamedTuple.__module__ = module
510-
return DictableNamedTuple
532+
return K
511533

512534

513535
def dictable_namedtuple(typename, field_names, *args, **kwargs) -> Union[Type[namedtuple], dict]:
@@ -602,8 +624,6 @@ def dictable_namedtuple(typename, field_names, *args, **kwargs) -> Union[Type[na
602624
"""
603625
module = kwargs.get('module', None)
604626
read_only = kwargs.pop('read_only', False)
605-
# First we create a namedtuple "type" using the same arguments we were passed
606-
BaseNT = namedtuple(typename, field_names, *args, **kwargs)
607627

608628
# As per namedtuple's comment block, we need to set __module__ to the frame
609629
# where the named tuple is created, otherwise it can't be pickled properly.
@@ -613,11 +633,8 @@ def dictable_namedtuple(typename, field_names, *args, **kwargs) -> Union[Type[na
613633
module = sys._getframe(1).f_globals.get('__name__', '__main__')
614634
except (AttributeError, ValueError):
615635
pass
616-
# Then we create a class which inherits the namedtuple type we've created, so we can easily
617-
# override / add new attributes and methods
618-
# DictableNamedTuple = convert_dictable_namedtuple(BaseNT, typename=typename, module=module)
619-
DictableNamedTuple = subclass_dictable_namedtuple(BaseNT, module=module, read_only=read_only, *args, **kwargs)
620-
return DictableNamedTuple
636+
637+
return make_dict_tuple(typename, field_names, module=module, read_only=read_only, *args, **kwargs)
621638

622639

623640
class Dictable:

tests/test_tuple.py

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@
4040
"""
4141
import inspect
4242
from typing import Union
43-
from collections import namedtuple
44-
from privex.helpers import dictable_namedtuple, is_namedtuple
43+
from collections import namedtuple, OrderedDict
44+
from privex.helpers import dictable_namedtuple, is_namedtuple, subclass_dictable_namedtuple, convert_dictable_namedtuple
4545
from tests.base import PrivexBaseCase
4646
import logging
4747

@@ -245,11 +245,8 @@ def test_set_attr(self):
245245
def test_dict_cast(self):
246246
"""Test casting dictable_namedtuple using ``dict`` works as expected, but fails on normal namedtuple"""
247247
di, ni = self.example_items
248-
249-
dict_di = dict(di)
250-
self.assertIs(type(dict_di), dict)
251-
self.assertListEqual(['name', 'description'], list(dict_di.keys()))
252-
self.assertListEqual(['Box', 'Small Cardboard Box'], list(dict_di.values()))
248+
249+
self._check_cast_dict(di)
253250

254251
# The standard ``namedtuple`` instances cannot be casted to a dict, they should throw a ValueError
255252
# or a TypeError.
@@ -260,18 +257,48 @@ def test_asdict(self):
260257
"""Test ``._asdict`` works on both dictable + normal namedtuple"""
261258
di, ni = self.example_items
262259

263-
dict_di = di._asdict()
264-
self.assertIs(type(dict_di), dict)
265-
self.assertListEqual(['name', 'description'], list(dict_di.keys()))
266-
self.assertListEqual(['Box', 'Small Cardboard Box'], list(dict_di.values()))
267-
268-
dict_ni = ni._asdict()
269-
self.assertIs(type(dict_ni), dict)
270-
self.assertListEqual(['name', 'description'], list(dict_ni.keys()))
271-
self.assertListEqual(['Box', 'Small Cardboard Box'], list(dict_ni.values()))
272-
273-
260+
self._check_asdict(di)
274261

262+
self._check_asdict(ni)
275263

264+
def _check_asdict(self, inst):
265+
dict_inst = inst._asdict()
266+
self.assertIn(type(dict_inst), [dict, OrderedDict])
267+
self.assertListEqual(['name', 'description'], list(dict_inst.keys()))
268+
self.assertListEqual(['Box', 'Small Cardboard Box'], list(dict_inst.values()))
276269

270+
def _check_cast_dict(self, inst):
271+
dict_inst = dict(inst)
272+
self.assertIn(type(dict_inst), [dict, OrderedDict])
273+
self.assertListEqual(['name', 'description'], list(dict_inst.keys()))
274+
self.assertListEqual(['Box', 'Small Cardboard Box'], list(dict_inst.values()))
277275

276+
def test_subclass(self):
277+
"""Test subclass_dictable_namedtuple converts :attr:`.NmItem` into a dictable_namedtuple type """
278+
d_nt = subclass_dictable_namedtuple(self.NmItem)
279+
280+
di = d_nt('Box', 'Small Cardboard Box')
281+
self._check_dictable(di)
282+
283+
def test_convert(self):
284+
"""Test convert_dictable_namedtuple converts example namedtuple instance into a dictable_namedtuple instance"""
285+
ni = self.example_items[1]
286+
di = convert_dictable_namedtuple(ni)
287+
self._check_dictable(di)
288+
289+
def _check_dictable(self, di):
290+
# Confirm the object is named 'Item'
291+
self.assertIn('Item(', str(di))
292+
# Test accessing by attribute
293+
self.assertEqual(di.name, 'Box')
294+
self.assertEqual(di.description, 'Small Cardboard Box')
295+
# Test accessing by item/key
296+
self.assertEqual(di['name'], 'Box')
297+
self.assertEqual(di['description'], 'Small Cardboard Box')
298+
# Test accessing by tuple index
299+
self.assertEqual(di[0], 'Box')
300+
self.assertEqual(di[1], 'Small Cardboard Box')
301+
# Test converting to a dict (via dict())
302+
self._check_cast_dict(di)
303+
# Test converting to a dict (via ._asdict())
304+
self._check_asdict(di)

0 commit comments

Comments
 (0)