Repository: jacebrowning/yorm

Issue:
Reload the file only after a configurable timeout has occurred
Or, only read from the file if it **has** actually changed.

<bountysource-plugin>
---
Want to back this issue? **[Post a bounty on it!](https://www.bountysource.com/issues/4360121-reload-the-file-only-after-a-configurable-timeout-has-occurred?utm_campaign=plugin&utm_content=tracker%2F1536163&utm_medium=issues&utm_source=github)** We accept bounties via [Bountysource](https://www.bountysource.com/?utm_campaign=plugin&utm_content=tracker%2F1536163&utm_medium=issues&utm_source=github).
</bountysource-plugin>

Discussion:

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 6116a4c..9653aeb 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,6 +1,11 @@
 Changelog
 =========
 
+0.3 (dev)
+---------
+
+- Updated mapped objects to only read from the filesystem if there are changes.
+
 0.2.1 (2015-2-12)
 -----------------
 
diff --git a/yorm/__init__.py b/yorm/__init__.py
index b2e342a..967c38f 100644
--- a/yorm/__init__.py
+++ b/yorm/__init__.py
@@ -3,7 +3,7 @@
 import sys
 
 __project__ = 'YORM'
-__version__ = '0.2.1'
+__version__ = '0.3dev'
 
 VERSION = __project__ + '-' + __version__
 
diff --git a/yorm/base.py b/yorm/base.py
index 79cc2c0..14929e5 100644
--- a/yorm/base.py
+++ b/yorm/base.py
@@ -18,11 +18,11 @@ class Mappable(metaclass=abc.ABCMeta):  # pylint:disable=R0921
         try:
             value = object.__getattribute__(self, name)
         except AttributeError:
-            self.yorm_mapper.retrieve(self)
+            self.yorm_mapper.retrieve(self, self.yorm_attrs)
             value = object.__getattribute__(self, name)
         else:
             if name in self.yorm_attrs:
-                self.yorm_mapper.retrieve(self)
+                self.yorm_mapper.retrieve(self, self.yorm_attrs)
                 value = object.__getattribute__(self, name)
 
         return value
@@ -36,7 +36,7 @@ class Mappable(metaclass=abc.ABCMeta):  # pylint:disable=R0921
 
         if hasattr(self, 'yorm_attrs') and name in self.yorm_attrs:
             if hasattr(self, 'yorm_mapper') and self.yorm_mapper.auto:
-                self.yorm_mapper.store(self)
+                self.yorm_mapper.store(self, self.yorm_attrs)
             else:
                 log.trace("automatic storage is off")
 
@@ -46,7 +46,7 @@ class Mappable(metaclass=abc.ABCMeta):  # pylint:disable=R0921
 
     def __exit__(self, *_):
         log.debug("turning on automatic storage...")
-        self.yorm_mapper.store(self)
+        self.yorm_mapper.store(self, self.yorm_attrs)
 
 
 class Converter(metaclass=abc.ABCMeta):  # pylint:disable=R0921
diff --git a/yorm/common.py b/yorm/common.py
index 0771870..b8f7024 100644
--- a/yorm/common.py
+++ b/yorm/common.py
@@ -144,6 +144,11 @@ def touch(path):
         write_text('', path)
 
 
+def stamp(path):
+    """Get the modification timestamp from a file."""
+    return os.path.getmtime(path)
+
+
 def delete(path):
     """Delete a file or directory with error handling."""
     if os.path.isdir(path):
diff --git a/yorm/mapper.py b/yorm/mapper.py
index 9917c1a..53fcf1f 100644
--- a/yorm/mapper.py
+++ b/yorm/mapper.py
@@ -49,60 +49,68 @@ class Mapper:
     def __init__(self, path):
         self.path = path
         self.auto = False
-        self.exists = True
-        self.retrieving = False
-        self.storing = False
-        # TODO: replace this variable with a timeout or modification check
-        self.retrieved = False
+        self.exists = os.path.isfile(self.path)
+        self._retrieving = False
+        self._storing = False
+        self._timestamp = 0
 
     def __str__(self):
         return str(self.path)
 
+    @property
+    def _fake(self):  # pylint: disable=R0201
+        """Get a string indicating the fake setting to use in logging."""
+        return "(fake) " if settings.fake else ''
+
     def create(self, obj):
         """Create a new file for the object."""
-        log.critical((self.path, obj))
-        if self._fake or not os.path.isfile(self.path):
-            log.info("mapping %r to %s'%s'...", obj, self._fake, self)
-            if not self._fake:
-                common.create_dirname(self.path)
-                common.touch(self.path)
+        log.info("creating %s'%s' for %r...", self._fake, self, obj)
+        if self.exists:
+            log.warning("already created: %s", self)
+            return
+        if not self._fake:
+            common.create_dirname(self.path)
+            common.touch(self.path)
+        self.modified = False
         self.exists = True
 
     @readwrite
-    def retrieve(self, obj):
-        """Load the object's properties from its file."""
-        if self.storing:
+    def retrieve(self, obj, attrs):
+        """Load the object's mapped attributes from its file."""
+        if self._storing:
+            return
+        if not self.modified:
             return
-        self.retrieving = True
+        self._retrieving = True
         log.debug("retrieving %r from %s'%s'...", obj, self._fake, self.path)
 
         # Parse data from file
         if self._fake:
             text = getattr(obj, 'yorm_fake', "")
         else:
-            text = self.read()
-        data = self.load(text, self.path)
+            text = self._read()
+        data = self._load(text, self.path)
         log.trace("loaded: {}".format(data))
 
         # Update attributes
         for name, data in data.items():
             try:
-                converter = obj.yorm_attrs[name]
+                converter = attrs[name]
             except KeyError:
                 # TODO: determine if this runtime import is the best way to do this
                 from . import standard
                 converter = standard.match(name, data)
-                obj.yorm_attrs[name] = converter
+                attrs[name] = converter
             value = converter.to_value(data)
             log.trace("value retrieved: '{}' = {}".format(name, repr(value)))
             setattr(obj, name, value)
 
         # Set meta attributes
-        self.retrieving = False
-        self.retrieved = True
+        self.modified = False
+        self._retrieving = False
 
     @readwrite
-    def read(self):
+    def _read(self):
         """Read text from the object's file.
 
         :param path: path to a text file
@@ -113,7 +121,7 @@ class Mapper:
         return common.read_text(self.path)
 
     @staticmethod
-    def load(text, path):
+    def _load(text, path):
         """Load YAML data from text.
 
         :param text: text read from a file
@@ -125,16 +133,16 @@ class Mapper:
         return common.load_yaml(text, path)
 
     @readwrite
-    def store(self, obj):
-        """Format and save the object's properties to its file."""
-        if self.retrieving:
+    def store(self, obj, attrs):
+        """Format and save the object's mapped attributes to its file."""
+        if self._retrieving:
             return
-        self.storing = True
+        self._storing = True
         log.debug("storing %r to %s'%s'...", obj, self._fake, self.path)
 
         # Format the data items
         data = {}
-        for name, converter in obj.yorm_attrs.items():
+        for name, converter in attrs.items():
             try:
                 value = getattr(obj, name)
             except AttributeError as exc:
@@ -145,17 +153,18 @@ class Mapper:
             data[name] = data2
 
         # Dump data to file
-        text = self.dump(data)
+        text = self._dump(data)
         if self._fake:
             obj.yorm_fake = text
         else:
-            self.write(text)
+            self._write(text)
 
         # Set meta attributes
-        self.storing = False
+        self.modified = False
+        self._storing = False
 
     @staticmethod
-    def dump(data):
+    def _dump(data):
         """Dump YAML data to text.
 
         :param data: dictionary of YAML data
@@ -166,7 +175,7 @@ class Mapper:
         return yaml.dump(data, default_flow_style=False, allow_unicode=True)
 
     @readwrite
-    def write(self, text):
+    def _write(self, text):
         """Write text to the object's file.
 
         :param text: text to write to a file
@@ -175,18 +184,42 @@ class Mapper:
         """
         common.write_text(text, self.path)
 
+    @property
+    def modified(self):
+        """Determine if the file has been modified."""
+        if self._fake:
+            log.trace("file is modified (it is fake)")
+            return True
+        elif not self.exists:
+            log.trace("file is modified (it is deleted)")
+            return True
+        else:
+            was = self._timestamp
+            now = common.stamp(self.path)
+            log.trace("file is %smodified (%s -> %s)",
+                      "not " if was == now else "",
+                      was, now)
+            return was != now
+
+    @modified.setter
+    def modified(self, changes):
+        """Mark the file as modified if there are changes."""
+        if changes:
+            log.trace("marked %sfile as modified", self._fake)
+            self._timestamp = 0
+        else:
+            if self._fake:
+                self._timestamp = None
+            else:
+                self._timestamp = common.stamp(self.path)
+            log.trace("marked %sfile as not modified", self._fake)
+
     def delete(self):
         """Delete the object's file from the file system."""
         if self.exists:
             log.info("deleting %s'%s'...", self._fake, self.path)
             if not self._fake:
                 common.delete(self.path)
-            self.retrieved = False
             self.exists = False
         else:
             log.warning("already deleted: %s", self)
-
-    @property
-    def _fake(self):  # pylint: disable=R0201
-        """Return a string indicating the fake setting to use in logging."""
-        return "(fake) " if settings.fake else ''
diff --git a/yorm/utilities.py b/yorm/utilities.py
index 1a51f75..403e15f 100644
--- a/yorm/utilities.py
+++ b/yorm/utilities.py
@@ -1,6 +1,5 @@
 """Functions and decorators."""
 
-import os
 import uuid
 
 from . import common
@@ -36,12 +35,12 @@ def store(instance, path, mapping=None, auto=True):
     instance.yorm_path = path
     instance.yorm_mapper = Mapper(instance.yorm_path)
 
-    if not os.path.exists(instance.yorm_path):
+    if not instance.yorm_mapper.exists:
         instance.yorm_mapper.create(instance)
         if auto:
-            instance.yorm_mapper.store(instance)
+            instance.yorm_mapper.store(instance, instance.yorm_attrs)
     else:
-        instance.yorm_mapper.retrieve(instance)
+        instance.yorm_mapper.retrieve(instance, instance.yorm_attrs)
 
     instance.yorm_mapper.auto = auto
 
@@ -84,12 +83,12 @@ def store_instances(path_format, format_spec=None, mapping=None, auto=True):
                 self.yorm_path = path_format.format(**format_spec2)
                 self.yorm_mapper = Mapper(self.yorm_path)
 
-                if not os.path.exists(self.yorm_path):
+                if not self.yorm_mapper.exists:
                     self.yorm_mapper.create(self)
                     if auto:
-                        self.yorm_mapper.store(self)
+                        self.yorm_mapper.store(self, self.yorm_attrs)
                 else:
-                    self.yorm_mapper.retrieve(self)
+                    self.yorm_mapper.retrieve(self, self.yorm_attrs)
 
                 self.yorm_mapper.auto = auto
 


Test patch:
diff --git a/yorm/test/test_all.py b/yorm/test/test_all.py
index ea57b45..0d60905 100644
--- a/yorm/test/test_all.py
+++ b/yorm/test/test_all.py
@@ -3,6 +3,9 @@
 
 """Integration tests for the `yorm` package."""
 
+import time
+import logging
+
 import pytest
 
 from yorm import store, store_instances, map_attr, Converter
@@ -134,7 +137,7 @@ class SampleStandardDecorated:
 @store_instances("sample.yml", auto=False)
 class SampleDecoratedNoAuto:
 
-    """Sample class with automatic storate turned off."""
+    """Sample class with automatic storage turned off."""
 
     def __init__(self):
         self.string = ""
@@ -193,6 +196,13 @@ class SampleCustomDecorated:
 # tests #######################################################################
 
 
+def refresh_file_modification_times(seconds=1.1):
+    """Sleep to allow file modification times to refresh."""
+    logging.info("delaying for %s second%s...", seconds,
+                 "" if seconds == 1 else "s")
+    time.sleep(seconds)
+
+
 def test_imports():
     """Verify the package namespace is mapped correctly."""
     # pylint: disable=W0404,W0612,W0621
@@ -260,6 +270,7 @@ class TestStandard:
         """.strip().replace("        ", "") + '\n' == text
 
         # change file values
+        refresh_file_modification_times()
         text = """
         array: [4, 5, 6]
         'false': null
@@ -297,7 +308,7 @@ class TestStandard:
         assert "path/to/directory/sample.yml" == sample.yorm_path
 
         # check defaults
-        assert {'key': ''} == sample.object
+        assert {} == sample.object
         assert [] == sample.array
         assert "" == sample.string
         assert 0 == sample.number_int
@@ -374,7 +385,7 @@ class TestStandard:
         assert "" == text
 
         # store value
-        sample.yorm_mapper.store(sample)
+        sample.yorm_mapper.store(sample, sample.yorm_attrs)
         sample.yorm_mapper.auto = True
 
         # check for changed file values
@@ -447,6 +458,7 @@ class TestContainers:
         """.strip().replace("        ", "") + '\n' == text
 
         # change file values
+        refresh_file_modification_times()
         text = """
         count: 3
         other: 4.2
@@ -472,6 +484,7 @@ class TestContainers:
         sample = SampleEmptyDecorated()
 
         # change file values
+        refresh_file_modification_times()
         text = """
         object: {'key': 'value'}
         array: [1, '2', '3.0']
@@ -479,8 +492,8 @@ class TestContainers:
         with open(sample.yorm_path, 'w') as stream:
             stream.write(text)
 
-            # (a mapped attribute must be read first to trigger retrieving)
-        sample.yorm_mapper.retrieve(sample)
+        # (a mapped attribute must be read first to trigger retrieving)
+        sample.yorm_mapper.retrieve(sample, sample.yorm_attrs)
 
         # check object values
         assert {'key': 'value'} == sample.object
@@ -519,6 +532,7 @@ class TestExtended:
         assert "" == sample.text
 
         # change object values
+        refresh_file_modification_times()
         sample.text = """
         This is the first sentence. This is the second sentence.
         This is the third sentence.
@@ -535,6 +549,7 @@ class TestExtended:
         """.strip().replace("        ", "") + '\n' == text
 
         # change file values
+        refresh_file_modification_times()
         text = """
         text: |
           This is a
@@ -571,6 +586,7 @@ class TestCustom:
         """.strip().replace("        ", "") + '\n' == text
 
         # change file values
+        refresh_file_modification_times()
         text = """
         level: 1
         """.strip().replace("        ", "") + '\n'
@@ -593,7 +609,9 @@ class TestInit:
         sample2 = SampleStandardDecorated('sample')
         assert sample2.yorm_path == sample.yorm_path
 
-        # change object values
+        refresh_file_modification_times()
+
+        logging.info("changing values in object 1...")
         sample.object = {'key2': 'value'}
         sample.array = [0, 1, 2]
         sample.string = "Hello, world!"
@@ -602,8 +620,8 @@ class TestInit:
         sample.true = True
         sample.false = False
 
-        # check object values
-        assert {'key2': 'value', 'status': False} == sample2.object
+        logging.info("reading changed values in object 2...")
+        assert 'value' == sample2.object.get('key2')
         assert [0, 1, 2] == sample2.array
         assert "Hello, world!" == sample2.string
         assert 42 == sample2.number_int
diff --git a/yorm/test/test_base.py b/yorm/test/test_base.py
index 1b2d03f..2692b81 100644
--- a/yorm/test/test_base.py
+++ b/yorm/test/test_base.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-# pylint:disable=W0201,W0613,R0201
+# pylint:disable=W0201,W0613,R0201,W0212
 
 """Unit tests for the `base` module."""
 
@@ -18,15 +18,26 @@ class MockMapper(Mapper):
     def __init__(self, path):
         super().__init__(path)
         self._mock_file = None
+        self._mock_modified = True
+        self.exists = True
 
-    def read(self):
+    def _read(self):
         text = self._mock_file
         logging.debug("mock read:\n%s", text.strip())
         return text
 
-    def write(self, text):
+    def _write(self, text):
         logging.debug("mock write:\n%s", text.strip())
         self._mock_file = text
+        self.modified = True
+
+    @property
+    def modified(self):
+        return self._mock_modified
+
+    @modified.setter
+    def modified(self, changes):  # pylint: disable=W0221
+        self._mock_modified = changes
 
 
 # sample classes ##############################################################
@@ -48,7 +59,7 @@ class SampleMappable(Mappable):
                            'var2': Integer,
                            'var3': Boolean}
         self.yorm_mapper = MockMapper(self.yorm_path)
-        self.yorm_mapper.store(self)
+        self.yorm_mapper.store(self, self.yorm_attrs)
         self.yorm_mapper.auto = True
 
     def __repr__(self):
@@ -68,7 +79,7 @@ class TestMappable:
 
     def test_init(self):
         """Verify files are created after initialized."""
-        text = self.sample.yorm_mapper.read()
+        text = self.sample.yorm_mapper._read()
         assert """
         var1: ''
         var2: 0
@@ -80,7 +91,7 @@ class TestMappable:
         self.sample.var1 = "abc123"
         self.sample.var2 = 1
         self.sample.var3 = True
-        text = self.sample.yorm_mapper.read()
+        text = self.sample.yorm_mapper._read()
         assert """
         var1: abc123
         var2: 1
@@ -92,7 +103,7 @@ class TestMappable:
         self.sample.var1 = 42
         self.sample.var2 = "1"
         self.sample.var3 = 'off'
-        text = self.sample.yorm_mapper.read()
+        text = self.sample.yorm_mapper._read()
         assert """
         var1: '42'
         var2: 1
@@ -111,7 +122,7 @@ class TestMappable:
         var2: 42
         var3: off
         """.strip().replace("        ", "") + '\n'
-        self.sample.yorm_mapper.write(text)
+        self.sample.yorm_mapper._write(text)
         assert"def456" == self.sample.var1
         assert 42 == self.sample.var2
         assert False is self.sample.var3
@@ -121,7 +132,7 @@ class TestMappable:
         text = """
         invalid: -
         """.strip().replace("        ", "") + '\n'
-        self.sample.yorm_mapper.write(text)
+        self.sample.yorm_mapper._write(text)
         with pytest.raises(ValueError):
             print(self.sample.var1)
 
@@ -130,7 +141,7 @@ class TestMappable:
         text = """
         not a dictionary
         """.strip().replace("        ", "") + '\n'
-        self.sample.yorm_mapper.write(text)
+        self.sample.yorm_mapper._write(text)
         with pytest.raises(ValueError):
             print(self.sample.var1)
 
@@ -139,14 +150,14 @@ class TestMappable:
         with self.sample:
             self.sample.var1 = "abc123"
 
-            text = self.sample.yorm_mapper.read()
+            text = self.sample.yorm_mapper._read()
             assert """
             var1: ''
             var2: 0
             var3: false
             """.strip().replace("            ", "") + '\n' == text
 
-        text = self.sample.yorm_mapper.read()
+        text = self.sample.yorm_mapper._read()
         assert """
         var1: abc123
         var2: 0
@@ -158,7 +169,7 @@ class TestMappable:
         text = """
         new: 42
         """.strip().replace("        ", "") + '\n'
-        self.sample.yorm_mapper.write(text)
+        self.sample.yorm_mapper._write(text)
         assert 42 == self.sample.new
 
     def test_new_unknown(self):
@@ -166,7 +177,7 @@ class TestMappable:
         text = """
         new: !!timestamp 2001-12-15T02:59:43.1Z
         """.strip().replace("        ", "") + '\n'
-        self.sample.yorm_mapper.write(text)
+        self.sample.yorm_mapper._write(text)
         with pytest.raises(ValueError):
             print(self.sample.var1)
 
diff --git a/yorm/test/test_mapper.py b/yorm/test/test_mapper.py
index d86b193..6592461 100644
--- a/yorm/test/test_mapper.py
+++ b/yorm/test/test_mapper.py
@@ -30,6 +30,17 @@ class TestFake:
 
         assert not os.path.exists(mapped.path)
 
+    def test_modified(self):
+        """Verify a fake file is always modified."""
+        mapped = mapper.Mapper("fake/path/to/file")
+        mapped.create(None)
+
+        assert mapped.modified
+
+        mapped.modified = False
+
+        assert mapped.modified
+
 
 class TestReal:
 
@@ -43,14 +54,14 @@ class TestReal:
 
         assert os.path.isfile(mapped.path)
 
-    def test_create_exists(self, tmpdir):
-        """Verify files are only created if they don't exist."""
+    def test_create_twice(self, tmpdir):
+        """Verify the second creation is ignored."""
         tmpdir.chdir()
         mapped = mapper.Mapper("real/path/to/file")
-        with patch('os.path.isfile', Mock(return_value=True)):
-            mapped.create(None)
+        mapped.create(None)
+        mapped.create(None)
 
-        assert not os.path.isfile(mapped.path)
+        assert os.path.isfile(mapped.path)
 
     def test_delete(self, tmpdir):
         """Verify files can be deleted."""
@@ -61,6 +72,32 @@ class TestReal:
 
         assert not os.path.exists(mapped.path)
 
+    def test_delete_twice(self, tmpdir):
+        """Verify the second deletion is ignored."""
+        tmpdir.chdir()
+        mapped = mapper.Mapper("real/path/to/file")
+        mapped.delete()
+
+        assert not os.path.exists(mapped.path)
+
+    def test_modified(self, tmpdir):
+        """Verify files track modifications."""
+        tmpdir.chdir()
+        mapped = mapper.Mapper("real/path/to/file")
+        mapped.create(None)
+
+        assert not mapped.modified
+
+        mapped.modified = True
+
+        assert mapped.modified
+
+    def test_modified_deleted(self):
+        """Verify a deleted file is always modified."""
+        mapped = mapper.Mapper("fake/path/to/file")
+
+        assert mapped.modified
+
 
 if __name__ == '__main__':
     pytest.main()
diff --git a/yorm/test/test_utilities.py b/yorm/test/test_utilities.py
index 6b6d91d..90fe6d2 100644
--- a/yorm/test/test_utilities.py
+++ b/yorm/test/test_utilities.py
@@ -56,6 +56,7 @@ class MockConverter4(MockConverter):
 
 
 @patch('yorm.common.write_text', Mock())
+@patch('yorm.common.stamp', Mock())
 class TestStore:
 
     """Unit tests for the `store` function."""
@@ -83,7 +84,7 @@ class TestStore:
         with pytest.raises(common.UseageError):
             utilities.store(sample, "sample.yml")
 
-    @patch('os.path.exists', Mock(return_value=True))
+    @patch('os.path.isfile', Mock(return_value=True))
     @patch('yorm.common.read_text', Mock(return_value="abc: 123"))
     def test_init_existing(self):
         """Verify an existing file is read."""
@@ -97,7 +98,7 @@ class TestStore:
         with patch.object(sample, 'yorm_mapper') as mock_yorm_mapper:
             setattr(sample, 'var1', None)
         mock_yorm_mapper.retrieve.assert_never_called()
-        mock_yorm_mapper.store.assert_called_once_with(sample)
+        mock_yorm_mapper.store.assert_called_once_with(sample, mapping)
 
     def test_retrieve(self):
         """Verify retrieve is called when getting an attribute."""
@@ -105,12 +106,13 @@ class TestStore:
         sample = utilities.store(self.Sample(), "sample.yml", mapping)
         with patch.object(sample, 'yorm_mapper') as mock_yorm_mapper:
             getattr(sample, 'var1', None)
-        mock_yorm_mapper.retrieve.assert_called_once_with(sample)
+        mock_yorm_mapper.retrieve.assert_called_once_with(sample, mapping)
         mock_yorm_mapper.store.assert_never_called()
 
 
 @patch('yorm.common.create_dirname', Mock())
 @patch('yorm.common.write_text', Mock())
+@patch('yorm.common.stamp', Mock())
 class TestStoreInstances:
 
     """Unit tests for the `store_instances` decorator."""
@@ -120,11 +122,17 @@ class TestStoreInstances:
 
         """Sample decorated class using a single path."""
 
+        def __repr__(self):
+            return "<decorated {}>".format(id(self))
+
     @utilities.store_instances("{UUID}.yml")
     class SampleDecoratedIdentifiers:
 
         """Sample decorated class using UUIDs for paths."""
 
+        def __repr__(self):
+            return "<decorated w/ UUID {}>".format(id(self))
+
     @utilities.store_instances("path/to/{n}.yml", {'n': 'name'})
     class SampleDecoratedAttributes:
 
@@ -133,6 +141,9 @@ class TestStoreInstances:
         def __init__(self, name):
             self.name = name
 
+        def __repr__(self):
+            return "<decorated w/ specified attributes {}>".format(id(self))
+
     @utilities.store_instances("path/to/{self.name}.yml")
     class SampleDecoratedAttributesAutomatic:
 
@@ -141,6 +152,9 @@ class TestStoreInstances:
         def __init__(self, name):
             self.name = name
 
+        def __repr__(self):
+            return "<decorated w/ automatic attributes {}>".format(id(self))
+
     @utilities.store_instances("{self.a}/{self.b}/{c}.yml",
                                {'self.b': 'b', 'c': 'c'})
     class SampleDecoratedAttributesCombination:
@@ -152,6 +166,9 @@ class TestStoreInstances:
             self.b = b
             self.c = c
 
+        def __repr__(self):
+            return "<decorated w/ attributes {}>".format(id(self))
+
     @utilities.store_instances("sample.yml", mapping={'var1': MockConverter})
     class SampleDecoratedWithAttributes:
 
@@ -169,7 +186,7 @@ class TestStoreInstances:
         assert "sample.yml" == sample.yorm_path
         assert ['var1'] == list(sample.yorm_attrs.keys())
 
-    @patch('os.path.exists', Mock(return_value=True))
+    @patch('os.path.isfile', Mock(return_value=True))
     @patch('yorm.common.read_text', Mock(return_value="abc: 123"))
     def test_init_existing(self):
         """Verify an existing file is read."""
@@ -210,18 +227,21 @@ class TestStoreInstances:
         with patch.object(sample, 'yorm_mapper') as mock_yorm_mapper:
             setattr(sample, 'var1', None)
         mock_yorm_mapper.retrieve.assert_never_called()
-        mock_yorm_mapper.store.assert_called_once_with(sample)
+        mock_yorm_mapper.store.assert_called_once_with(sample,
+                                                       sample.yorm_attrs)
 
     def test_retrieve(self):
         """Verify retrieve is called when getting an attribute."""
         sample = self.SampleDecoratedWithAttributes()
         with patch.object(sample, 'yorm_mapper') as mock_yorm_mapper:
             getattr(sample, 'var1', None)
-        mock_yorm_mapper.retrieve.assert_called_once_with(sample)
+        mock_yorm_mapper.retrieve.assert_called_once_with(sample,
+                                                          sample.yorm_attrs)
         mock_yorm_mapper.store.assert_never_called()
 
 
 @patch('yorm.common.write_text', Mock())
+@patch('yorm.common.stamp', Mock())
 class TestMapAttr:
 
     """Unit tests for the `map_attr` decorator."""