Repository: beetbox/beets

Issue:
zero: Can nullify fields in the library
I have been experimenting some behaviour contrary to the intended functionality. The plugin does delete the fields in the files' tags, but it also nullifies the fields in the library. This is contrary to the [docs](http://beets.readthedocs.org/en/v1.3.7/plugins/zero.html), where one can read:

> This plugin only affects files’ tags; the beets database is unchanged.

This can be confirmed by querying the library for any of those items which have been zeroed, specifying a value for some of the zeroed fields in the query, as in `beet ls <field>:<whatever>`. The items should be returned by the query. However, I have found zeroed items to **not** be returned when querying by some of the zeroed fields, which means the value of that field has been modified in the library, which should not happen.

The relevant parts of my config are:

```
plugins: fromfilename fetchart web info zero

zero:
    fields: comments genre album artist
```

I'm using beets 1.3.7.

zero: Can nullify fields in the library
I have been experimenting some behaviour contrary to the intended functionality. The plugin does delete the fields in the files' tags, but it also nullifies the fields in the library. This is contrary to the [docs](http://beets.readthedocs.org/en/v1.3.7/plugins/zero.html), where one can read:

> This plugin only affects files’ tags; the beets database is unchanged.

This can be confirmed by querying the library for any of those items which have been zeroed, specifying a value for some of the zeroed fields in the query, as in `beet ls <field>:<whatever>`. The items should be returned by the query. However, I have found zeroed items to **not** be returned when querying by some of the zeroed fields, which means the value of that field has been modified in the library, which should not happen.

The relevant parts of my config are:

```
plugins: fromfilename fetchart web info zero

zero:
    fields: comments genre album artist
```

I'm using beets 1.3.7.



Discussion:
Indeed—the plugin's write event handler seems to modify the Item object itself. Those modifications stick around and could eventually propagate to the database if `store` is eventually called. We instead need a way to only affect the tags transferred to the MediaFile.

Indeed—the plugin's write event handler seems to modify the Item object itself. Those modifications stick around and could eventually propagate to the database if `store` is eventually called. We instead need a way to only affect the tags transferred to the MediaFile.


Resolving patch:
diff --git a/beets/library.py b/beets/library.py
--- a/beets/library.py
+++ b/beets/library.py
@@ -473,7 +473,8 @@ def write(self, path=None):
         else:
             path = normpath(path)
 
-        plugins.send('write', item=self, path=path)
+        tags = dict(self)
+        plugins.send('write', item=self, path=path, tags=tags)
 
         try:
             mediafile = MediaFile(syspath(path),
@@ -481,7 +482,7 @@ def write(self, path=None):
         except (OSError, IOError, UnreadableFileError) as exc:
             raise ReadError(self.path, exc)
 
-        mediafile.update(self)
+        mediafile.update(tags)
         try:
             mediafile.save()
         except (OSError, IOError, MutagenError) as exc:
diff --git a/beetsplug/zero.py b/beetsplug/zero.py
--- a/beetsplug/zero.py
+++ b/beetsplug/zero.py
@@ -78,18 +78,18 @@ def match_patterns(cls, field, patterns):
                 return True
         return False
 
-    def write_event(self, item):
+    def write_event(self, item, path, tags):
         """Listen for write event."""
         if not self.patterns:
             log.warn(u'[zero] no fields, nothing to do')
             return
 
         for field, patterns in self.patterns.items():
-            if field not in item.keys():
+            if field not in tags:
                 log.error(u'[zero] no such field: {0}'.format(field))
                 continue
 
-            value = item[field]
+            value = tags[field]
             if self.match_patterns(value, patterns):
                 log.debug(u'[zero] {0}: {1} -> None'.format(field, value))
-                item[field] = None
+                tags[field] = None


Test patch:
diff --git a/test/test_plugins.py b/test/test_plugins.py
--- a/test/test_plugins.py
+++ b/test/test_plugins.py
@@ -14,16 +14,17 @@
 
 from mock import patch
 from _common import unittest
-from helper import TestHelper
+import helper
 
 from beets import plugins
 from beets.library import Item
 from beets.dbcore import types
+from beets.mediafile import MediaFile
 
 
-class PluginTest(unittest.TestCase, TestHelper):
+class TestHelper(helper.TestHelper):
 
-    def setUp(self):
+    def setup_plugin_loader(self):
         # FIXME the mocking code is horrific, but this is the lowest and
         # earliest level of the plugin mechanism we can hook into.
         self._plugin_loader_patch = patch('beets.plugins.load_plugins')
@@ -35,9 +36,22 @@ def myload(names=()):
         load_plugins.side_effect = myload
         self.setup_beets()
 
-    def tearDown(self):
+    def teardown_plugin_loader(self):
         self._plugin_loader_patch.stop()
         self.unload_plugins()
+
+    def register_plugin(self, plugin_class):
+        self._plugin_classes.add(plugin_class)
+
+
+class ItemTypesTest(unittest.TestCase, TestHelper):
+
+    def setUp(self):
+        self.setup_plugin_loader()
+        self.setup_beets()
+
+    def tearDown(self):
+        self.teardown_plugin_loader()
         self.teardown_beets()
 
     def test_flex_field_type(self):
@@ -64,8 +78,38 @@ class RatingPlugin(plugins.BeetsPlugin):
         out = self.run_with_output('ls', 'rating:3..5')
         self.assertNotIn('aaa', out)
 
-    def register_plugin(self, plugin_class):
-        self._plugin_classes.add(plugin_class)
+
+class ItemWriteTest(unittest.TestCase, TestHelper):
+
+    def setUp(self):
+        self.setup_plugin_loader()
+        self.setup_beets()
+
+        class EventListenerPlugin(plugins.BeetsPlugin):
+            pass
+        self.event_listener_plugin = EventListenerPlugin
+        self.register_plugin(EventListenerPlugin)
+
+    def tearDown(self):
+        self.teardown_plugin_loader()
+        self.teardown_beets()
+
+    def test_change_tags(self):
+
+        def on_write(item=None, path=None, tags=None):
+            if tags['artist'] == 'XXX':
+                tags['artist'] = 'YYY'
+
+        self.register_listener('write', on_write)
+
+        item = self.add_item_fixture(artist='XXX')
+        item.write()
+
+        mediafile = MediaFile(item.path)
+        self.assertEqual(mediafile.artist, 'YYY')
+
+    def register_listener(self, event, func):
+        self.event_listener_plugin.register_listener(event, func)
 
 
 def suite():
diff --git a/test/test_zero.py b/test/test_zero.py
--- a/test/test_zero.py
+++ b/test/test_zero.py
@@ -18,37 +18,38 @@ def tearDown(self):
         self.unload_plugins()
 
     def test_no_patterns(self):
-        i = Item(
-            comments='test comment',
-            day=13,
-            month=3,
-            year=2012,
-        )
+        tags = {
+            'comments': 'test comment',
+            'day': 13,
+            'month': 3,
+            'year': 2012,
+        }
         z = ZeroPlugin()
         z.debug = False
         z.fields = ['comments', 'month', 'day']
         z.patterns = {'comments': ['.'],
                       'month': ['.'],
                       'day': ['.']}
-        z.write_event(i)
-        self.assertEqual(i.comments, '')
-        self.assertEqual(i.day, 0)
-        self.assertEqual(i.month, 0)
-        self.assertEqual(i.year, 2012)
+        z.write_event(None, None, tags)
+        self.assertEqual(tags['comments'], None)
+        self.assertEqual(tags['day'], None)
+        self.assertEqual(tags['month'], None)
+        self.assertEqual(tags['year'], 2012)
 
     def test_patterns(self):
-        i = Item(
-            comments='from lame collection, ripped by eac',
-            year=2012,
-        )
         z = ZeroPlugin()
         z.debug = False
         z.fields = ['comments', 'year']
         z.patterns = {'comments': 'eac lame'.split(),
                       'year': '2098 2099'.split()}
-        z.write_event(i)
-        self.assertEqual(i.comments, '')
-        self.assertEqual(i.year, 2012)
+
+        tags = {
+            'comments': 'from lame collection, ripped by eac',
+            'year': 2012,
+        }
+        z.write_event(None, None, tags)
+        self.assertEqual(tags['comments'], None)
+        self.assertEqual(tags['year'], 2012)
 
     def test_delete_replaygain_tag(self):
         path = self.create_mediafile_fixture()
@@ -70,6 +71,17 @@ def test_delete_replaygain_tag(self):
         self.assertIsNone(mediafile.rg_track_peak)
         self.assertIsNone(mediafile.rg_track_gain)
 
+    def test_do_not_change_database(self):
+        item = self.add_item_fixture(year=2000)
+        mediafile = MediaFile(item.path)
+
+        config['zero'] = {'fields': ['year']}
+        self.load_plugins('zero')
+
+        item.write()
+        self.assertEqual(item['year'], 2000)
+        self.assertIsNone(mediafile.year)
+
 
 def suite():
     return unittest.TestLoader().loadTestsFromName(__name__)