Merge pull request #1706 from sampsyo/editor

Edit plugin
This commit is contained in:
Adrian Sampson 2015-12-12 18:00:25 -08:00
commit 6f6794f560
6 changed files with 633 additions and 0 deletions

View file

@ -467,6 +467,11 @@ class Model(object):
return cls._type(key).parse(string)
def set_parse(self, key, string):
"""Set the object's key to a value represented by a string.
"""
self[key] = self._parse(key, string)
# Database controller and supporting interfaces.

326
beetsplug/edit.py Normal file
View file

@ -0,0 +1,326 @@
# This file is part of beets.
# Copyright 2015
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Open metadata information in a text editor to let the user edit it.
"""
from __future__ import (division, absolute_import, print_function,
unicode_literals)
from beets import plugins
from beets import util
from beets import ui
from beets.dbcore import types
from beets.ui.commands import _do_query
import subprocess
import yaml
from tempfile import NamedTemporaryFile
import os
# These "safe" types can avoid the format/parse cycle that most fields go
# through: they are safe to edit with native YAML types.
SAFE_TYPES = (types.Float, types.Integer, types.Boolean)
class ParseError(Exception):
"""The modified file is unreadable. The user should be offered a chance to
fix the error.
"""
def edit(filename):
"""Open `filename` in a text editor.
"""
cmd = util.shlex_split(util.editor_command())
cmd.append(filename)
subprocess.call(cmd)
def dump(arg):
"""Dump a sequence of dictionaries as YAML for editing.
"""
return yaml.safe_dump_all(
arg,
allow_unicode=True,
default_flow_style=False,
)
def load(s):
"""Read a sequence of YAML documents back to a list of dictionaries
with string keys.
Can raise a `ParseError`.
"""
try:
out = []
for d in yaml.load_all(s):
if not isinstance(d, dict):
raise ParseError(
'each entry must be a dictionary; found {}'.format(
type(d).__name__
)
)
# Convert all keys to strings. They started out as strings,
# but the user may have inadvertently messed this up.
out.append({unicode(k): v for k, v in d.items()})
except yaml.YAMLError as e:
raise ParseError('invalid YAML: {}'.format(e))
return out
def _safe_value(obj, key, value):
"""Check whether the `value` is safe to represent in YAML and trust as
returned from parsed YAML.
This ensures that values do not change their type when the user edits their
YAML representation.
"""
typ = obj._type(key)
return isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type)
def flatten(obj, fields):
"""Represent `obj`, a `dbcore.Model` object, as a dictionary for
serialization. Only include the given `fields` if provided;
otherwise, include everything.
The resulting dictionary's keys are strings and the values are
safely YAML-serializable types.
"""
# Format each value.
d = {}
for key in obj.keys():
value = obj[key]
if _safe_value(obj, key, value):
# A safe value that is faithfully representable in YAML.
d[key] = value
else:
# A value that should be edited as a string.
d[key] = obj.formatted()[key]
# Possibly filter field names.
if fields:
return {k: v for k, v in d.items() if k in fields}
else:
return d
def apply(obj, data):
"""Set the fields of a `dbcore.Model` object according to a
dictionary.
This is the opposite of `flatten`. The `data` dictionary should have
strings as values.
"""
for key, value in data.items():
if _safe_value(obj, key, value):
# A safe value *stayed* represented as a safe type. Assign it
# directly.
obj[key] = value
else:
# Either the field was stringified originally or the user changed
# it from a safe type to an unsafe one. Parse it as a string.
obj.set_parse(key, unicode(value))
class EditPlugin(plugins.BeetsPlugin):
def __init__(self):
super(EditPlugin, self).__init__()
self.config.add({
# The default fields to edit.
'albumfields': 'album albumartist',
'itemfields': 'track title artist album',
# Silently ignore any changes to these fields.
'ignore_fields': 'id path',
})
def commands(self):
edit_command = ui.Subcommand(
'edit',
help='interactively edit metadata'
)
edit_command.parser.add_option(
'-f', '--field',
metavar='FIELD',
action='append',
help='edit this field also',
)
edit_command.parser.add_option(
'--all',
action='store_true', dest='all',
help='edit all fields',
)
edit_command.parser.add_album_option()
edit_command.func = self._edit_command
return [edit_command]
def _edit_command(self, lib, opts, args):
"""The CLI command function for the `beet edit` command.
"""
# Get the objects to edit.
query = ui.decargs(args)
items, albums = _do_query(lib, query, opts.album, False)
objs = albums if opts.album else items
if not objs:
ui.print_('Nothing to edit.')
return
# Get the fields to edit.
if opts.all:
fields = None
else:
fields = self._get_fields(opts.album, opts.field)
self.edit(opts.album, objs, fields)
def _get_fields(self, album, extra):
"""Get the set of fields to edit.
"""
# Start with the configured base fields.
if album:
fields = self.config['albumfields'].as_str_seq()
else:
fields = self.config['itemfields'].as_str_seq()
# Add the requested extra fields.
if extra:
fields += extra
# Ensure we always have the `id` field for identification.
fields.append('id')
return set(fields)
def edit(self, album, objs, fields):
"""The core editor function.
- `album`: A flag indicating whether we're editing Items or Albums.
- `objs`: The `Item`s or `Album`s to edit.
- `fields`: The set of field names to edit (or None to edit
everything).
"""
# Present the YAML to the user and let her change it.
success = self.edit_objects(objs, fields)
# Save the new data.
if success:
self.save_write(objs)
def edit_objects(self, objs, fields):
"""Dump a set of Model objects to a file as text, ask the user
to edit it, and apply any changes to the objects.
Return a boolean indicating whether the edit succeeded.
"""
# Get the content to edit as raw data structures.
old_data = [flatten(o, fields) for o in objs]
# Set up a temporary file with the initial data for editing.
new = NamedTemporaryFile(suffix='.yaml', delete=False)
old_str = dump(old_data)
new.write(old_str)
new.close()
# Loop until we have parseable data and the user confirms.
try:
while True:
# Ask the user to edit the data.
edit(new.name)
# Read the data back after editing and check whether anything
# changed.
with open(new.name) as f:
new_str = f.read()
if new_str == old_str:
ui.print_("No changes; aborting.")
return False
# Parse the updated data.
try:
new_data = load(new_str)
except ParseError as e:
ui.print_("Could not read data: {}".format(e))
if ui.input_yn("Edit again to fix? (Y/n)", True):
continue
else:
return False
# Show the changes.
self.apply_data(objs, old_data, new_data)
changed = False
for obj in objs:
changed |= ui.show_model_changes(obj)
if not changed:
ui.print_('No changes to apply.')
return False
# Confirm the changes.
choice = ui.input_options(
('continue Editing', 'apply', 'cancel')
)
if choice == 'a': # Apply.
return True
elif choice == 'c': # Cancel.
return False
elif choice == 'e': # Keep editing.
# Reset the temporary changes to the objects.
for obj in objs:
obj.read()
continue
# Remove the temporary file before returning.
finally:
os.remove(new.name)
def apply_data(self, objs, old_data, new_data):
"""Take potentially-updated data and apply it to a set of Model
objects.
The objects are not written back to the database, so the changes
are temporary.
"""
if len(old_data) != len(new_data):
self._log.warn('number of objects changed from {} to {}',
len(old_data), len(new_data))
obj_by_id = {o.id: o for o in objs}
ignore_fields = self.config['ignore_fields'].as_str_seq()
for old_dict, new_dict in zip(old_data, new_data):
# Prohibit any changes to forbidden fields to avoid
# clobbering `id` and such by mistake.
forbidden = False
for key in ignore_fields:
if old_dict.get(key) != new_dict.get(key):
self._log.warn('ignoring object whose {} changed', key)
forbidden = True
break
if forbidden:
continue
id = int(old_dict['id'])
apply(obj_by_id[id], new_dict)
def save_write(self, objs):
"""Save a list of updated Model objects to the database.
"""
# Save to the database and possibly write tags.
for ob in objs:
if ob._dirty:
self._log.debug('saving changes to {}', ob)
ob.try_sync(ui.should_write())

View file

@ -3,6 +3,10 @@ Changelog
1.3.16 (in development)
-----------------------
* A new plugin edit helps you manually edit fields from items.
You search for items in the normal beets way.Then edit opens a texteditor
with the items and the fields of the items you want to edit. Afterwards you can
review your changes save them back into the items.
New:

36
docs/plugins/edit.rst Normal file
View file

@ -0,0 +1,36 @@
Edit Plugin
===========
The ``edit`` plugin lets you modify music metadata using your favorite text
editor.
Enable the ``edit`` plugin in your configuration (see :ref:`using-plugins`) and
then type::
beet edit QUERY
Your text editor (i.e., the command in your ``$EDITOR`` environment variable)
will open with a list of tracks to edit. Make your changes and exit your text
editor to apply them to your music.
Command-Line Options
--------------------
The ``edit`` command has these command-line options:
- ``-a`` or ``--album``: Edit albums instead of individual items.
- ``-f FIELD`` or ``--field FIELD``: Specify an additional field to edit
(in addition to the defaults set in the configuration).
- ``--all``: Edit *all* available fields.
Configuration
-------------
To configure the plugin, make an ``edit:`` section in your configuration
file. The available options are:
- **itemfields**: A space-separated list of item fields to include in the
editor by default.
Default: ``track title artist album``
- **albumfields**: The same when editing albums (with the ``-a`` option).
Default: ``album albumartist``

View file

@ -40,6 +40,7 @@ Each plugin has its own set of options that can be defined in a section bearing
discogs
duplicates
echonest
edit
embedart
embyupdate
fetchart
@ -96,6 +97,7 @@ Metadata
* :doc:`bpm`: Measure tempo using keystrokes.
* :doc:`echonest`: Automatically fetch `acoustic attributes`_ from
`the Echo Nest`_ (tempo, energy, danceability, ...).
* :doc:`edit`: Edit metadata from a texteditor.
* :doc:`embedart`: Embed album art images into files' metadata.
* :doc:`fetchart`: Fetch album cover art from various sources.
* :doc:`ftintitle`: Move "featured" artists from the artist field to the title

260
test/test_edit.py Normal file
View file

@ -0,0 +1,260 @@
# This file is part of beets.
# Copyright 2015, Adrian Sampson and Diego Moreda.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import (division, absolute_import, print_function,
unicode_literals)
import codecs
from mock import patch
from test._common import unittest
from test.helper import TestHelper, control_stdin
class ModifyFileMocker(object):
"""Helper for modifying a file, replacing or editing its contents. Used for
mocking the calls to the external editor during testing."""
def __init__(self, contents=None, replacements=None):
""" `self.contents` and `self.replacements` are initalized here, in
order to keep the rest of the functions of this class with the same
signature as `EditPlugin.get_editor()`, making mocking easier.
- `contents`: string with the contents of the file to be used for
`overwrite_contents()`
- `replacement`: dict with the in-place replacements to be used for
`replace_contents()`, in the form {'previous string': 'new string'}
TODO: check if it can be solved more elegantly with a decorator
"""
self.contents = contents
self.replacements = replacements
self.action = self.overwrite_contents
if replacements:
self.action = self.replace_contents
def overwrite_contents(self, filename):
"""Modify `filename`, replacing its contents with `self.contents`. If
`self.contents` is empty, the file remains unchanged.
"""
if self.contents:
with codecs.open(filename, 'w', encoding='utf8') as f:
f.write(self.contents)
def replace_contents(self, filename):
"""Modify `filename`, reading its contents and replacing the strings
specified in `self.replacements`.
"""
with codecs.open(filename, 'r', encoding='utf8') as f:
contents = f.read()
for old, new_ in self.replacements.iteritems():
contents = contents.replace(old, new_)
with codecs.open(filename, 'w', encoding='utf8') as f:
f.write(contents)
class EditCommandTest(unittest.TestCase, TestHelper):
""" Black box tests for `beetsplug.edit`. Command line interaction is
simulated using `test.helper.control_stdin()`, and yaml editing via an
external editor is simulated using `ModifyFileMocker`.
"""
ALBUM_COUNT = 1
TRACK_COUNT = 10
def setUp(self):
self.setup_beets()
self.load_plugins('edit')
# make sure that we avoid invoking the editor except for making changes
self.config['edit']['diff_method'] = ''
# add an album, storing the original fields for comparison
self.album = self.add_album_fixture(track_count=self.TRACK_COUNT)
self.album_orig = {f: self.album[f] for f in self.album._fields}
self.items_orig = [{f: item[f] for f in item._fields} for
item in self.album.items()]
# keep track of write()s
self.write_patcher = patch('beets.library.Item.write')
self.mock_write = self.write_patcher.start()
def tearDown(self):
self.write_patcher.stop()
self.teardown_beets()
self.unload_plugins()
def run_mocked_command(self, modify_file_args={}, stdin=[], args=[]):
"""Run the edit command, with mocked stdin and yaml writing, and
passing `args` to `run_command`."""
m = ModifyFileMocker(**modify_file_args)
with patch('beetsplug.edit.edit', side_effect=m.action):
with control_stdin('\n'.join(stdin)):
self.run_command('edit', *args)
def assertCounts(self, album_count=ALBUM_COUNT, track_count=TRACK_COUNT,
write_call_count=TRACK_COUNT, title_starts_with=''):
"""Several common assertions on Album, Track and call counts."""
self.assertEqual(len(self.lib.albums()), album_count)
self.assertEqual(len(self.lib.items()), track_count)
self.assertEqual(self.mock_write.call_count, write_call_count)
self.assertTrue(all(i.title.startswith(title_starts_with)
for i in self.lib.items()))
def assertItemFieldsModified(self, library_items, items, fields=[]):
"""Assert that items in the library (`lib_items`) have different values
on the specified `fields` (and *only* on those fields), compared to
`items`.
An empty `fields` list results in asserting that no modifications have
been performed.
"""
changed_fields = []
for lib_item, item in zip(library_items, items):
changed_fields.append([field for field in lib_item._fields
if lib_item[field] != item[field]])
self.assertTrue(all(diff_fields == fields for diff_fields in
changed_fields))
def test_title_edit_discard(self):
"""Edit title for all items in the library, then discard changes-"""
# edit titles
self.run_mocked_command({'replacements': {u't\u00eftle':
u'modified t\u00eftle'}},
# Cancel.
['c'])
self.assertCounts(write_call_count=0,
title_starts_with=u't\u00eftle')
self.assertItemFieldsModified(self.album.items(), self.items_orig, [])
def test_title_edit_apply(self):
"""Edit title for all items in the library, then apply changes."""
# edit titles
self.run_mocked_command({'replacements': {u't\u00eftle':
u'modified t\u00eftle'}},
# Apply changes.
['a'])
self.assertCounts(write_call_count=self.TRACK_COUNT,
title_starts_with=u'modified t\u00eftle')
self.assertItemFieldsModified(self.album.items(), self.items_orig,
['title'])
def test_single_title_edit_apply(self):
"""Edit title for one item in the library, then apply changes."""
# edit title
self.run_mocked_command({'replacements': {u't\u00eftle 9':
u'modified t\u00eftle 9'}},
# Apply changes.
['a'])
self.assertCounts(write_call_count=1,)
# no changes except on last item
self.assertItemFieldsModified(list(self.album.items())[:-1],
self.items_orig[:-1], [])
self.assertEqual(list(self.album.items())[-1].title,
u'modified t\u00eftle 9')
def test_noedit(self):
"""Do not edit anything."""
# do not edit anything
self.run_mocked_command({'contents': None},
# no stdin
[])
self.assertCounts(write_call_count=0,
title_starts_with=u't\u00eftle')
self.assertItemFieldsModified(self.album.items(), self.items_orig, [])
def test_album_edit_apply(self):
"""Edit the album field for all items in the library, apply changes.
By design, the album should not be updated.""
"""
# edit album
self.run_mocked_command({'replacements': {u'\u00e4lbum':
u'modified \u00e4lbum'}},
# Apply changes.
['a'])
self.assertCounts(write_call_count=self.TRACK_COUNT)
self.assertItemFieldsModified(self.album.items(), self.items_orig,
['album'])
# ensure album is *not* modified
self.album.load()
self.assertEqual(self.album.album, u'\u00e4lbum')
def test_single_edit_add_field(self):
"""Edit the yaml file appending an extra field to the first item, then
apply changes."""
# append "foo: bar" to item with id == 1
self.run_mocked_command({'replacements': {u"id: 1":
u"id: 1\nfoo: bar"}},
# Apply changes.
['a'])
self.assertEqual(self.lib.items('id:1')[0].foo, 'bar')
self.assertCounts(write_call_count=1,
title_starts_with=u't\u00eftle')
def test_a_album_edit_apply(self):
"""Album query (-a), edit album field, apply changes."""
self.run_mocked_command({'replacements': {u'\u00e4lbum':
u'modified \u00e4lbum'}},
# Apply changes.
['a'],
args=['-a'])
self.album.load()
self.assertCounts(write_call_count=self.TRACK_COUNT)
self.assertEqual(self.album.album, u'modified \u00e4lbum')
self.assertItemFieldsModified(self.album.items(), self.items_orig,
['album'])
def test_a_albumartist_edit_apply(self):
"""Album query (-a), edit albumartist field, apply changes."""
self.run_mocked_command({'replacements': {u'album artist':
u'modified album artist'}},
# Apply changes.
['a'],
args=['-a'])
self.album.load()
self.assertCounts(write_call_count=self.TRACK_COUNT)
self.assertEqual(self.album.albumartist, u'the modified album artist')
self.assertItemFieldsModified(self.album.items(), self.items_orig,
['albumartist'])
def test_malformed_yaml(self):
"""Edit the yaml file incorrectly (resulting in a malformed yaml
document)."""
# edit the yaml file to an invalid file
self.run_mocked_command({'contents': '!MALFORMED'},
# Edit again to fix? No.
['n'])
self.assertCounts(write_call_count=0,
title_starts_with=u't\u00eftle')
def test_invalid_yaml(self):
"""Edit the yaml file incorrectly (resulting in a well-formed but
invalid yaml document)."""
# edit the yaml file to an invalid file
self.run_mocked_command({'contents': 'wellformed: yes, but invalid'},
# no stdin
[])
self.assertCounts(write_call_count=0,
title_starts_with=u't\u00eftle')
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == b'__main__':
unittest.main(defaultTest='suite')