Skip to content

Commit ce770d9

Browse files
jpinner-lyftgarrettheel
authored andcommitted
Support setting attributes on "raw" MapAttribute instances. (#344)
1 parent 415f652 commit ce770d9

File tree

3 files changed

+151
-48
lines changed

3 files changed

+151
-48
lines changed

pynamodb/attributes.py

Lines changed: 139 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from datetime import datetime
99
from dateutil.parser import parse
1010
from dateutil.tz import tzutc
11+
from inspect import getargspec
1112
from pynamodb.constants import (
1213
STRING, STRING_SHORT, NUMBER, BINARY, UTC, DATETIME_FORMAT, BINARY_SET, STRING_SET, NUMBER_SET,
1314
MAP, MAP_SHORT, LIST, LIST_SHORT, DEFAULT_ENCODING, BOOLEAN, ATTR_TYPE_MAP, NUMBER_SHORT, NULL, SHORT_ATTR_TYPES
@@ -20,7 +21,6 @@ class Attribute(object):
2021
"""
2122
An attribute of a model
2223
"""
23-
attr_name = None
2424
attr_type = None
2525
null = False
2626

@@ -36,8 +36,7 @@ def __init__(self,
3636
self.null = null
3737
self.is_hash_key = hash_key
3838
self.is_range_key = range_key
39-
if attr_name is not None:
40-
self.attr_name = attr_name
39+
self.attr_name = attr_name
4140

4241
def __set__(self, instance, value):
4342
if instance and not self._is_map_attribute_class_object(instance):
@@ -52,7 +51,7 @@ def __get__(self, instance, owner):
5251
return self
5352

5453
def _is_map_attribute_class_object(self, instance):
55-
return isinstance(instance, MapAttribute) and getattr(instance, '_class_object', False)
54+
return isinstance(instance, MapAttribute) and not instance._is_attribute_container()
5655

5756
def serialize(self, value):
5857
"""
@@ -176,14 +175,9 @@ def _initialize_attributes(cls):
176175
if issubclass(item_cls, Attribute):
177176
instance = getattr(cls, item_name)
178177
if isinstance(instance, MapAttribute):
179-
# Attributes are data descriptors that bind their value to the containing object.
180-
# When subclassing MapAttribute and using them as AttributeContainers on a Model,
181-
# their internal attributes are bound to the instance in the Model class.
182-
# The `_class_object` attribute is used to indicate that the MapAttribute instance
183-
# belongs to a class object and not a class instance, overriding the binding.
184-
# Without this, Model.MapAttribute().attribute would the value and not the object;
185-
# whereas Model.attribute always returns the object.
186-
instance._class_object = True
178+
# MapAttribute instances that are class attributes of an AttributeContainer class
179+
# should behave like an Attribute instance and not an AttributeContainer instance.
180+
instance._make_attribute()
187181

188182
cls._attributes[item_name] = instance
189183
if instance.attr_name is not None:
@@ -195,6 +189,16 @@ def _initialize_attributes(cls):
195189
@add_metaclass(AttributeContainerMeta)
196190
class AttributeContainer(object):
197191

192+
def __init__(self, **attributes):
193+
# The `attribute_values` dictionary is used by the Attribute data descriptors in cls._attributes
194+
# to store the values that are bound to this instance. Attributes store values in the dictionary
195+
# using the `python_attr_name` as the dictionary key. "Raw" (i.e. non-subclassed) MapAttribute
196+
# instances do not have any Attributes defined and instead use this dictionary to store their
197+
# collection of name-value pairs.
198+
self.attribute_values = {}
199+
self._set_defaults()
200+
self._set_attributes(**attributes)
201+
198202
@classmethod
199203
def _get_attributes(cls):
200204
"""
@@ -226,6 +230,15 @@ def _set_defaults(self):
226230
if value is not None:
227231
setattr(self, name, value)
228232

233+
def _set_attributes(self, **attributes):
234+
"""
235+
Sets the attributes for this object
236+
"""
237+
for attr_name, attr_value in six.iteritems(attributes):
238+
if attr_name not in self._get_attributes():
239+
raise ValueError("Attribute {0} specified does not exist".format(attr_name))
240+
setattr(self, attr_name, attr_value)
241+
229242

230243
class SetMixin(object):
231244
"""
@@ -513,18 +526,101 @@ class MapAttributeMeta(AttributeContainerMeta):
513526

514527

515528
@add_metaclass(MapAttributeMeta)
516-
class MapAttribute(AttributeContainer, Attribute):
529+
class MapAttribute(Attribute, AttributeContainer):
530+
"""
531+
A Map Attribute
532+
533+
The MapAttribute class can be used to store a JSON document as "raw" name-value pairs, or
534+
it can be subclassed and the document fields represented as class attributes using Attribute instances.
535+
536+
To support the ability to subclass MapAttribute and use it as an AttributeContainer, instances of
537+
MapAttribute behave differently based both on where they are instantiated and on their type.
538+
Because of this complicated behavior, a bit of an introduction is warranted.
539+
540+
Models that contain a MapAttribute define its properties using a class attribute on the model.
541+
For example, below we define "MyModel" which contains a MapAttribute "my_map":
542+
543+
class MyModel(Model):
544+
my_map = MapAttribute(attr_name="dynamo_name", default={})
545+
546+
When instantiated in this manner (as a class attribute of an AttributeContainer class), the MapAttribute
547+
class acts as an instance of the Attribute class. The instance stores data about the attribute (in this
548+
example the dynamo name and default value), and acts as a data descriptor, storing any value bound to it
549+
on the `attribute_values` dictionary of the containing instance (in this case an instance of MyModel).
550+
551+
Unlike other Attribute types, the value that gets bound to the containing instance is a new instance of
552+
MapAttribute, not an instance of the primitive type. For example, a UnicodeAttribute stores strings in
553+
the `attribute_values` of the containing instance; a MapAttribute does not store a dict but instead stores
554+
a new instance of itself. This difference in behavior is necessary when subclassing MapAttribute in order
555+
to access the Attribute data descriptors that represent the document fields.
556+
557+
For example, below we redefine "MyModel" to use a subclass of MapAttribute as "my_map":
558+
559+
class MyMapAttribute(MapAttribute):
560+
my_internal_map = MapAttribute()
561+
562+
class MyModel(Model):
563+
my_map = MyMapAttribute(attr_name="dynamo_name", default = {})
564+
565+
In order to set the value of my_internal_map on an instance of MyModel we need the bound value for "my_map"
566+
to be an instance of MapAttribute so that it acts as a data descriptor:
567+
568+
MyModel().my_map.my_internal_map = {'foo': 'bar'}
569+
570+
That is the attribute access of "my_map" must return a MyMapAttribute instance and not a dict.
571+
572+
When an instance is used in this manner (bound to an instance of an AttributeContainer class),
573+
the MapAttribute class acts as an AttributeContainer class itself. The instance does not store data
574+
about the attribute, and does not act as a data descriptor. The instance stores name-value pairs in its
575+
internal `attribute_values` dictionary.
576+
577+
Thus while MapAttribute multiply inherits from Attribute and AttributeContainer, a MapAttribute instance
578+
does not behave as both an Attribute AND an AttributeContainer. Rather an instance of MapAttribute behaves
579+
EITHER as an Attribute OR as an AttributeContainer, depending on where it was instantiated.
580+
581+
So, how do we create this dichotomous behavior? Using the AttributeContainerMeta metaclass.
582+
All MapAttribute instances are initialized as AttributeContainers only. During construction of
583+
AttributeContainer classes (subclasses of MapAttribute and Model), any instances that are class attributes
584+
are transformed from AttributeContainers to Attributes (via the `_make_attribute` method call).
585+
"""
517586
attr_type = MAP
518587

519-
def __init__(self, hash_key=False, range_key=False, null=None, default=None, attr_name=None, **attrs):
520-
super(MapAttribute, self).__init__(hash_key=hash_key,
521-
range_key=range_key,
522-
null=null,
523-
default=default,
524-
attr_name=attr_name)
525-
self.attribute_values = {}
526-
self._set_defaults()
527-
self._set_attributes(**attrs)
588+
attribute_args = getargspec(Attribute.__init__).args[1:]
589+
590+
def __init__(self, **attributes):
591+
# Store the kwargs used by Attribute.__init__ in case `_make_attribute` is called.
592+
self.attribute_kwargs = dict((arg, attributes.pop(arg)) for arg in self.attribute_args if arg in attributes)
593+
594+
# Assume all instances should behave like an AttributeContainer. Instances that are intended to be
595+
# used as Attributes will be transformed by AttributeContainerMeta during creation of the containing class.
596+
# Because of this do not use MRO or cooperative multiple inheritance, call the parent class directly.
597+
AttributeContainer.__init__(self, **attributes)
598+
599+
# It is possible that attributes names can collide with argument names of Attribute.__init__.
600+
# Assume that this is the case if any of the following are true:
601+
# - the user passed in other attributes that did not match any argument names
602+
# - this is "raw" (i.e. non-subclassed) MapAttribute instance and attempting to store the attributes
603+
# cannot raise a ValueError (if this assumption is wrong, calling `_make_attribute` removes them)
604+
# - the names of all attributes in self.attribute_kwargs match attributes defined on the class
605+
if self.attribute_kwargs and (
606+
attributes or self.is_raw() or all(arg in self._get_attributes() for arg in self.attribute_kwargs)):
607+
self._set_attributes(**self.attribute_kwargs)
608+
609+
def _is_attribute_container(self):
610+
# Determine if this instance is being used as an AttributeContainer or an Attribute.
611+
# AttributeContainer instances have an internal `attribute_values` dictionary that is removed
612+
# by the `_make_attribute` call during initialization of the containing class.
613+
return 'attribute_values' in self.__dict__
614+
615+
def _make_attribute(self):
616+
# WARNING! This function is only intended to be called from the AttributeContainerMeta metaclass.
617+
if not self._is_attribute_container():
618+
return
619+
# During initialization the kwargs were stored in `attribute_kwargs`. Remove them and re-initialize the class.
620+
kwargs = self.attribute_kwargs
621+
del self.attribute_kwargs
622+
del self.attribute_values
623+
Attribute.__init__(self, **kwargs)
528624

529625
def __iter__(self):
530626
return iter(self.attribute_values)
@@ -533,12 +629,22 @@ def __getitem__(self, item):
533629
return self.attribute_values[item]
534630

535631
def __getattr__(self, attr):
536-
# Should only be called for non-subclassed, otherwise we would go through
537-
# the descriptor instead.
538-
try:
539-
return self.attribute_values[attr]
540-
except KeyError:
541-
raise AttributeError("'{0}' has no attribute '{1}'".format(self.__class__.__name__, attr))
632+
# This should only be called for "raw" (i.e. non-subclassed) MapAttribute instances.
633+
# MapAttribute subclasses should access attributes via the Attribute descriptors.
634+
if self.is_raw() and self._is_attribute_container():
635+
try:
636+
return self.attribute_values[attr]
637+
except KeyError:
638+
pass
639+
raise AttributeError("'{0}' has no attribute '{1}'".format(self.__class__.__name__, attr))
640+
641+
def __setattr__(self, name, value):
642+
# "Raw" (i.e. non-subclassed) instances set their name-value pairs in the `attribute_values` dictionary.
643+
# MapAttribute subclasses should set attributes via the Attribute descriptors.
644+
if self.is_raw() and self._is_attribute_container():
645+
self.attribute_values[name] = value
646+
else:
647+
object.__setattr__(self, name, value)
542648

543649
def __set__(self, instance, value):
544650
if isinstance(value, collections.Mapping):
@@ -549,14 +655,11 @@ def _set_attributes(self, **attrs):
549655
"""
550656
Sets the attributes for this object
551657
"""
552-
for attr_name, value in six.iteritems(attrs):
553-
attribute = self._get_attributes().get(attr_name)
554-
if self.is_raw():
555-
self.attribute_values[attr_name] = value
556-
elif not attribute:
557-
raise AttributeError("Attribute {0} specified does not exist".format(attr_name))
558-
else:
559-
setattr(self, attr_name, value)
658+
if self.is_raw():
659+
for name, value in six.iteritems(attrs):
660+
setattr(self, name, value)
661+
else:
662+
super(MapAttribute, self)._set_attributes(**attrs)
560663

561664
def is_correctly_typed(self, key, attr):
562665
can_be_null = attr.null

pynamodb/models.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,6 @@ def __init__(self, hash_key=None, range_key=None, **attrs):
221221
:param range_key: Only required if the table has a range key attribute.
222222
:param attrs: A dictionary of attributes to set on this object.
223223
"""
224-
self.attribute_values = {}
225-
self._set_defaults()
226224
if hash_key is not None:
227225
attrs[self._dynamo_to_python_attr(self._get_meta_data().hash_keyname)] = hash_key
228226
if range_key is not None:
@@ -232,7 +230,7 @@ def __init__(self, hash_key=None, range_key=None, **attrs):
232230
"This table has no range key, but a range key value was provided: {0}".format(range_key)
233231
)
234232
attrs[self._dynamo_to_python_attr(range_keyname)] = range_key
235-
self._set_attributes(**attrs)
233+
super(Model, self).__init__(**attrs)
236234

237235
@classmethod
238236
def has_map_or_list_attributes(cls):
@@ -1263,15 +1261,6 @@ def _batch_get_page(cls, keys_to_get, consistent_read, attributes_to_get):
12631261
unprocessed_items = data.get(UNPROCESSED_KEYS).get(cls.Meta.table_name, {}).get(KEYS, None)
12641262
return item_data, unprocessed_items
12651263

1266-
def _set_attributes(self, **attrs):
1267-
"""
1268-
Sets the attributes for this object
1269-
"""
1270-
for attr_name, attr_value in six.iteritems(attrs):
1271-
if attr_name not in self._get_attributes():
1272-
raise ValueError("Attribute {0} specified does not exist".format(attr_name))
1273-
setattr(self, attr_name, attr_value)
1274-
12751264
@classmethod
12761265
def add_throttle_record(cls, records):
12771266
"""

pynamodb/tests/test_attributes.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,17 @@ def test_defaults(self):
595595
}
596596
}
597597

598+
def test_raw_set_attr(self):
599+
item = AttributeTestModel()
600+
item.map_attr = {}
601+
item.map_attr.foo = 'bar'
602+
item.map_attr.num = 3
603+
item.map_attr.nested = {'nestedfoo': 'nestedbar'}
604+
605+
assert item.map_attr['foo'] == 'bar'
606+
assert item.map_attr['num'] == 3
607+
assert item.map_attr['nested']['nestedfoo'] == 'nestedbar'
608+
598609
def test_raw_map_from_dict(self):
599610
item = AttributeTestModel(
600611
map_attr={

0 commit comments

Comments
 (0)