mirror of
https://github.com/beetbox/beets.git
synced 2026-02-24 16:23:04 +01:00
commit
6f6794f560
6 changed files with 633 additions and 0 deletions
|
|
@ -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
326
beetsplug/edit.py
Normal 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())
|
||||
|
|
@ -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
36
docs/plugins/edit.rst
Normal 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``
|
||||
|
|
@ -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
260
test/test_edit.py
Normal 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')
|
||||
Loading…
Reference in a new issue