diff --git a/picard/coverart.py b/picard/coverart.py index dc2d3d734..f8636ca5b 100644 --- a/picard/coverart.py +++ b/picard/coverart.py @@ -26,7 +26,7 @@ import re import traceback from functools import partial from picard import config, log -from picard.metadata import is_front_image +from picard.metadata import Image, is_front_image from picard.util import mimetype, parse_amazon_url from picard.const import CAA_HOST, CAA_PORT from PyQt4.QtCore import QUrl, QObject @@ -84,12 +84,21 @@ def _coverart_downloaded(album, metadata, release, try_list, coverinfos, data, h QObject.tagger.window.set_statusbar_message(N_("Coverart %s downloaded"), http.url().toString()) mime = mimetype.get_from_data(data, default="image/jpeg") - filename = None - if not is_front_image(coverinfos) and config.setting["caa_image_type_as_filename"]: - filename = coverinfos['type'] - metadata.add_image(mime, data, filename, coverinfos) - for track in album._new_tracks: - track.metadata.add_image(mime, data, filename, coverinfos) + + try: + metadata.make_and_add_image(mime, data, + imagetype=coverinfos['type'], + comment=coverinfos['desc']) + for track in album._new_tracks: + track.metadata.make_and_add_image(mime, data, + imagetype=coverinfos['type'], + comment=coverinfos['desc']) + except (IOError, OSError), e: + album.error_append(e.message) + album._finalize_loading(error=True) + # It doesn't make sense to store/download more images if we can't + # save them in the temporary folder, abort. + return # If the image already was a front image, there might still be some # other front images in the try_list - remove them. diff --git a/picard/file.py b/picard/file.py index 6ae3f2f58..8c5727914 100644 --- a/picard/file.py +++ b/picard/file.py @@ -310,60 +310,13 @@ class File(QtCore.QObject, Item): shutil.move(encode_filename(old_filename), encode_filename(new_filename)) return new_filename - def _make_image_filename(self, image_filename, dirname, metadata): - image_filename = self._script_to_filename(image_filename, metadata) - if not image_filename: - image_filename = "cover" - if os.path.isabs(image_filename): - filename = image_filename - else: - filename = os.path.join(dirname, image_filename) - if config.setting['windows_compatibility'] or sys.platform == 'win32': - filename = filename.replace('./', '_/').replace('.\\', '_\\') - return encode_filename(filename) - def _save_images(self, dirname, metadata): """Save the cover images to disk.""" if not metadata.images: return - default_filename = self._make_image_filename( - config.setting["cover_image_filename"], dirname, metadata) - overwrite = config.setting["save_images_overwrite"] counters = defaultdict(lambda: 0) for image in metadata.images: - filename = image["filename"] - data = image["data"] - mime = image["mime"] - if filename is None: - filename = default_filename - else: - filename = self._make_image_filename(filename, dirname, metadata) - image_filename = filename - ext = mimetype.get_extension(mime, ".jpg") - if counters[filename] > 0: - image_filename = "%s (%d)" % (filename, counters[filename]) - counters[filename] = counters[filename] + 1 - while os.path.exists(image_filename + ext) and not overwrite: - if os.path.getsize(image_filename + ext) == len(data): - log.debug("Identical file size, not saving %r", image_filename) - break - image_filename = "%s (%d)" % (filename, counters[filename]) - counters[filename] = counters[filename] + 1 - else: - new_filename = image_filename + ext - # Even if overwrite is enabled we don't need to write the same - # image multiple times - if (os.path.exists(new_filename) and - os.path.getsize(new_filename) == len(data)): - log.debug("Identical file size, not saving %r", image_filename) - continue - log.debug("Saving cover images to %r", image_filename) - new_dirname = os.path.dirname(image_filename) - if not os.path.isdir(new_dirname): - os.makedirs(new_dirname) - f = open(image_filename + ext, "wb") - f.write(data) - f.close() + image.save(dirname, metadata, counters) def _move_additional_files(self, old_filename, new_filename): """Move extra files, like playlists...""" diff --git a/picard/formats/apev2.py b/picard/formats/apev2.py index eadcce5de..e358e5a1c 100644 --- a/picard/formats/apev2.py +++ b/picard/formats/apev2.py @@ -63,7 +63,7 @@ class APEv2File(File): if '\0' in values.value: descr, data = values.value.split('\0', 1) mime = mimetype.get_from_data(data, descr, 'image/jpeg') - metadata.add_image(mime, data) + metadata.make_and_add_image(mime, data) # skip EXTERNAL and BINARY values if values.kind != mutagen.apev2.TEXT: continue @@ -150,8 +150,8 @@ class APEv2File(File): if not save_this_image_to_tags(image): continue cover_filename = 'Cover Art (Front)' - cover_filename += mimetype.get_extension(image["mime"], '.jpg') - tags['Cover Art (Front)'] = mutagen.apev2.APEValue(cover_filename + '\0' + image["data"], mutagen.apev2.BINARY) + cover_filename += mimetype.get_extension(image.mimetype, '.jpg') + tags['Cover Art (Front)'] = mutagen.apev2.APEValue(cover_filename + '\0' + image.data, mutagen.apev2.BINARY) break # can't save more than one item with the same name # (mp3tags does this, but it's against the specs) tags.save(encode_filename(filename)) diff --git a/picard/formats/asf.py b/picard/formats/asf.py index 754107e2f..c4a3d6505 100644 --- a/picard/formats/asf.py +++ b/picard/formats/asf.py @@ -141,11 +141,8 @@ class ASFFile(File): if name == 'WM/Picture': for image in values: (mime, data, type, description) = unpack_image(image.value) - extras = { - 'desc': description, - 'type': image_type_from_id3_num(type) - } - metadata.add_image(mime, data, extras=extras) + metadata.make_and_add_image(mime, data, comment=description, + imagetype=image_type_from_id3_num(type)) continue elif name not in self.__RTRANS: continue @@ -170,9 +167,9 @@ class ASFFile(File): for image in metadata.images: if not save_this_image_to_tags(image): continue - tag_data = pack_image(image["mime"], image["data"], - image_type_as_id3_num(image['type']), - image['desc']) + tag_data = pack_image(image.mimetype, image.data, + image_type_as_id3_num(image.imagetype), + image.description) cover.append(ASFByteArrayAttribute(tag_data)) if cover: file.tags['WM/Picture'] = cover diff --git a/picard/formats/id3.py b/picard/formats/id3.py index 60f3113db..d5ff2e5c9 100644 --- a/picard/formats/id3.py +++ b/picard/formats/id3.py @@ -259,11 +259,8 @@ class ID3File(File): else: log.error("Invalid %s value '%s' dropped in %r", frameid, frame.text[0], filename) elif frameid == 'APIC': - extras = { - 'desc': frame.desc, - 'type': image_type_from_id3_num(frame.type) - } - metadata.add_image(frame.mime, frame.data, extras=extras) + metadata.make_and_add_image(frame.mime, frame.data, comment=frame.desc, + imagetype=image_type_from_id3_num(frame.type)) elif frameid == 'POPM': # Rating in ID3 ranges from 0 to 255, normalize this to the range 0 to 5 if frame.email == config.setting['rating_user_email']: @@ -318,7 +315,7 @@ class ID3File(File): # any description. counters = defaultdict(lambda: 0) for image in metadata.images: - desc = desctag = image['desc'] + desc = desctag = image.description if not save_this_image_to_tags(image): continue if counters[desc] > 0: @@ -328,10 +325,10 @@ class ID3File(File): desctag = "(%i)" % counters[desc] counters[desc] += 1 tags.add(id3.APIC(encoding=0, - mime=image["mime"], - type=image_type_as_id3_num(image['type']), + mime=image.mimetype, + type=image_type_as_id3_num(image.imagetype), desc=desctag, - data=image["data"])) + data=image.data)) tmcl = mutagen.id3.TMCL(encoding=encoding, people=[]) tipl = mutagen.id3.TIPL(encoding=encoding, people=[]) diff --git a/picard/formats/mp4.py b/picard/formats/mp4.py index 91df4fed4..576f92cf4 100644 --- a/picard/formats/mp4.py +++ b/picard/formats/mp4.py @@ -141,9 +141,9 @@ class MP4File(File): elif name == "covr": for value in values: if value.imageformat == value.FORMAT_JPEG: - metadata.add_image("image/jpeg", value) + metadata.make_and_add_image("image/jpeg", value) elif value.imageformat == value.FORMAT_PNG: - metadata.add_image("image/png", value) + metadata.make_and_add_image("image/png", value) self._info(metadata, file) return metadata @@ -194,11 +194,11 @@ class MP4File(File): for image in metadata.images: if not save_this_image_to_tags(image): continue - mime = image["mime"] + mime = image.mimetype if mime == "image/jpeg": - covr.append(MP4Cover(image["data"], MP4Cover.FORMAT_JPEG)) + covr.append(MP4Cover(image.data, MP4Cover.FORMAT_JPEG)) elif mime == "image/png": - covr.append(MP4Cover(image["data"], MP4Cover.FORMAT_PNG)) + covr.append(MP4Cover(image.data, MP4Cover.FORMAT_PNG)) if covr: file.tags["covr"] = covr diff --git a/picard/formats/vorbis.py b/picard/formats/vorbis.py index 538005ec3..7635997ec 100644 --- a/picard/formats/vorbis.py +++ b/picard/formats/vorbis.py @@ -96,27 +96,22 @@ class VCommentFile(File): name = "totaldiscs" elif name == "metadata_block_picture": image = mutagen.flac.Picture(base64.standard_b64decode(value)) - extras = { - 'desc': image.desc, - 'type': image_type_from_id3_num(image.type) - } - metadata.add_image(image.mime, image.data, extras=extras) + metadata.make_and_add_image(image.mime, image.data, + comment=image.desc, + imagetype=image_type_from_id3_num(image.type)) continue elif name in self.__translate: name = self.__translate[name] metadata.add(name, value) if self._File == mutagen.flac.FLAC: for image in file.pictures: - extras = { - 'desc': image.desc, - 'type': image_type_from_id3_num(image.type) - } - metadata.add_image(image.mime, image.data, extras=extras) + metadata.make_and_add_image(image.mime, image.data, comment=image.desc, + imagetype=image_type_from_id3_num(image.type)) # Read the unofficial COVERART tags, for backward compatibillity only if not "metadata_block_picture" in file.tags: try: for index, data in enumerate(file["COVERART"]): - metadata.add_image(file["COVERARTMIME"][index], + metadata.make_and_add_image(file["COVERARTMIME"][index], base64.standard_b64decode(data) ) except KeyError: @@ -175,10 +170,10 @@ class VCommentFile(File): if not save_this_image_to_tags(image): continue picture = mutagen.flac.Picture() - picture.data = image["data"] - picture.mime = image["mime"] - picture.desc = image['desc'] - picture.type = image_type_as_id3_num(image['type']) + picture.data = image.data + picture.mime = image.mimetype + picture.desc = image.description + picture.type = image_type_as_id3_num(image.imagetype) if self._File == mutagen.flac.FLAC: file.add_picture(picture) else: diff --git a/picard/metadata.py b/picard/metadata.py index a70b79b49..e7af4d78f 100644 --- a/picard/metadata.py +++ b/picard/metadata.py @@ -15,13 +15,29 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. +import os.path +import shutil +import sys +import tempfile +import traceback + +from hashlib import md5 +from os import fdopen, unlink from PyQt4.QtCore import QObject -from picard import config +from picard import config, log from picard.plugin import ExtensionPoint from picard.similarity import similarity2 -from picard.util import load_release_type_scores +from picard.util import ( + encode_filename, + load_release_type_scores, + mimetype as mime, + replace_non_ascii, + replace_win32_incompat, + unaccent, +) from picard.mbxml import artist_credit_from_node MULTI_VALUED_JOINER = '; ' @@ -39,9 +55,105 @@ def is_front_image(image): def save_this_image_to_tags(image): if not config.setting["save_only_front_images_to_tags"]: return True - if is_front_image(image): - return True - return False + return image.is_front_image + + +class Image(object): + """Wrapper around images. Instantiating an object of this class can raise + an IOError or OSError due to the usage of tempfiles underneath. + """ + + def __init__(self, data, mimetype="image/jpeg", imagetype="front", + comment="", filename=None, datahash=""): + self.description = comment + (fd, self._tempfile_filename) = tempfile.mkstemp(prefix="picard") + with fdopen(fd, "wb") as imagefile: + imagefile.write(data) + log.debug("Saving image (hash=%s) to %r" % (datahash, + self._tempfile_filename)) + self.datalength = len(data) + self.extension = mime.get_extension(mime, ".jpg") + self.filename = filename + self.imagetype = imagetype + self.is_front_image = imagetype == "front" + self.mimetype = mimetype + + def _make_image_filename(self, filename, dirname, metadata): + if config.setting["ascii_filenames"]: + if isinstance(filename, unicode): + filename = unaccent(filename) + filename = replace_non_ascii(filename) + if not filename: + filename = "cover" + if not os.path.isabs(filename): + filename = os.path.join(dirname, filename) + # replace incompatible characters + if config.setting["windows_compatibility"] or sys.platform == "win32": + filename = replace_win32_incompat(filename) + # remove null characters + filename = filename.replace("\x00", "") + return encode_filename(filename) + + def save(self, dirname, metadata, counters): + """Saves this image. + + :dirname: The name of the directory that contains the audio file + :metadata: A metadata object + :counters: A dictionary mapping filenames to the amount of how many + images with that filename were already saved in `dirname`. + """ + if self.filename is not None: + log.debug("Using the custom file name %s", self.filename) + filename = self.filename + elif config.setting["caa_image_type_as_filename"]: + log.debug("Using image type %s", self.imagetype) + filename = self.imagetype + else: + log.debug("Using default file name %s", + config.setting["cover_image_filename"]) + filename = config.setting["cover_image_filename"] + filename = self._make_image_filename(filename, dirname, metadata) + + overwrite = config.setting["save_images_overwrite"] + ext = self.extension + image_filename = filename + if counters[filename] > 0: + image_filename = "%s (%d)" % (filename, counters[filename]) + counters[filename] = counters[filename] + 1 + while os.path.exists(image_filename + ext) and not overwrite: + if os.path.getsize(image_filename + ext) == self.datalength: + log.debug("Identical file size, not saving %r", image_filename) + break + image_filename = "%s (%d)" % (filename, counters[filename]) + counters[filename] = counters[filename] + 1 + else: + new_filename = image_filename + ext + # Even if overwrite is enabled we don't need to write the same + # image multiple times + if (os.path.exists(new_filename) and + os.path.getsize(new_filename) == self.datalength): + log.debug("Identical file size, not saving %r", image_filename) + return + log.debug("Saving cover images to %r", image_filename) + new_dirname = os.path.dirname(image_filename) + if not os.path.isdir(new_dirname): + os.makedirs(new_dirname) + shutil.copyfile(self._tempfile_filename, new_filename) + + @property + def data(self): + """Reads the data from the temporary file created for this image. May + raise IOErrors or OSErrors. + """ + with open(self._tempfile_filename, "rb") as imagefile: + return imagefile.read() + + def _delete(self): + log.debug("Unlinking %s", self._tempfile_filename) + try: + unlink(self._tempfile_filename) + except OSError, e: + log.error(traceback.format_exc()) class Metadata(dict): @@ -61,26 +173,30 @@ class Metadata(dict): self.images = [] self.length = 0 - def add_image(self, mime, data, filename=None, extras=None): - """Adds the image ``data`` to this Metadata object. + def make_and_add_image(self, mime, data, filename=None, comment="", + imagetype="front"): + """Build a new image object from ``data`` and adds it to this Metadata + object. If an image with the same MD5 hash has already been added to + any Metadata object, that file will be reused. Arguments: mime -- The mimetype of the image data -- The image data filename -- The image filename, without an extension - extras -- extra informations about image as dict - 'desc' : image description or comment, default to '' - 'type' : main type as a string, default to 'front' - 'front': if set, CAA front flag is true for this image + comment -- image description or comment, default to '' + imagetype -- main type as a string, default to 'front' """ - imagedict = {'mime': mime, - 'data': data, - 'filename': filename, - 'type': 'front', - 'desc': ''} - if extras is not None: - imagedict.update(extras) - self.images.append(imagedict) + m = md5() + m.update(data) + datahash = m.hexdigest() + QObject.tagger.images.lock() + image = QObject.tagger.images[datahash] + if image is None: + image = Image(data, mime, imagetype, comment, filename, + datahash=datahash) + QObject.tagger.images[datahash] = image + QObject.tagger.images.unlock() + self.images.append(image) def remove_image(self, index): self.images.pop(index) diff --git a/picard/tagger.py b/picard/tagger.py index 1180ce853..fb568d317 100644 --- a/picard/tagger.py +++ b/picard/tagger.py @@ -30,7 +30,9 @@ import os.path import re import shutil import signal +import socket import sys +from collections import defaultdict from functools import partial from itertools import chain @@ -76,6 +78,7 @@ from picard.util import ( check_io_encoding, uniqify, is_hidden_path, + LockableDefaultDict ) from picard.webservice import XmlWebService @@ -107,6 +110,25 @@ class Tagger(QtGui.QApplication): self.save_thread_pool = QtCore.QThreadPool(self) self.save_thread_pool.setMaxThreadCount(1) + if not sys.platform == "win32": + # Set up signal handling + # It's not possible to call all available functions from signal + # handlers, therefore we need to set up a QSocketNotifier to listen + # on a socket. Sending data through a socket can be done in a + # signal handler, so we use the socket to notify the application of + # the signal. + # This code is adopted from + # https://qt-project.org/doc/qt-4.8/unix-signals.html + self.signalfd = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) + + self.signalnotifier = QtCore.QSocketNotifier(self.signalfd[1].fileno(), + QtCore.QSocketNotifier.Read, self) + self.signalnotifier.activated.connect(self.sighandler) + + signal.signal(signal.SIGHUP, self.signal) + signal.signal(signal.SIGINT, self.signal) + signal.signal(signal.SIGTERM, self.signal) + # Setup logging if debug or "PICARD_DEBUG" in os.environ: log.log_levels = log.log_levels | log.LOG_DEBUG @@ -165,6 +187,7 @@ class Tagger(QtGui.QApplication): self.albums = {} self.release_groups = {} self.mbid_redirects = {} + self.images = LockableDefaultDict(lambda: None) self.unmatched_files = UnmatchedFiles() self.nats = None self.window = MainWindow() @@ -205,6 +228,8 @@ class Tagger(QtGui.QApplication): self.nats.update() def exit(self): + log.debug("exit") + map(lambda i: i._delete(), self.images.itervalues()) self.stopping = True self._acoustid.done() self.thread_pool.waitForDone() @@ -543,6 +568,18 @@ class Tagger(QtGui.QApplication): def instance(cls): return cls.__instance + def signal(self, signum, frame): + log.debug("signal %i received", signum) + # Send a notification about a received signal from the signal handler + # to Qt. + self.signalfd[0].sendall("a") + + def sighandler(self): + self.signalnotifier.setEnabled(False) + self.exit() + self.quit() + self.signalnotifier.setEnabled(True) + def help(): print """Usage: %s [OPTIONS] [FILE] [FILE] ... @@ -577,4 +614,5 @@ def main(localedir=None, autoupdate=True): elif opt in ("-d", "--debug"): kwargs["debug"] = True tagger = Tagger(args, localedir, autoupdate, **kwargs) + tagger.startTimer(1000) sys.exit(tagger.run()) diff --git a/picard/ui/coverartbox.py b/picard/ui/coverartbox.py index c8a3ad344..c5141ce6c 100644 --- a/picard/ui/coverartbox.py +++ b/picard/ui/coverartbox.py @@ -23,7 +23,6 @@ from picard import config, log from picard.album import Album from picard.track import Track from picard.file import File -from picard.metadata import is_front_image from picard.util import webbrowser2, encode_filename @@ -104,7 +103,7 @@ class CoverArtBox(QtGui.QGroupBox): if self.data: if pixmap is None: pixmap = QtGui.QPixmap() - pixmap.loadFromData(self.data["data"]) + pixmap.loadFromData(self.data.data) if not pixmap.isNull(): offx, offy, w, h = (1, 1, 121, 121) cover = QtGui.QPixmap(self.shadow) @@ -123,7 +122,7 @@ class CoverArtBox(QtGui.QGroupBox): data = None if metadata and metadata.images: for image in metadata.images: - if is_front_image(image): + if image.is_front_image: data = image break else: @@ -189,16 +188,16 @@ class CoverArtBox(QtGui.QGroupBox): self.__set_data([mime, data], pixmap=pixmap) if isinstance(self.item, Album): album = self.item - album.metadata.add_image(mime, data) + album.metadata.make_and_add_image(mime, data) for track in album.tracks: - track.metadata.add_image(mime, data) + track.metadata.make_and_add_image(mime, data) for file in album.iterfiles(): - file.metadata.add_image(mime, data) + file.metadata.make_and_add_image(mime, data) elif isinstance(self.item, Track): track = self.item - track.metadata.add_image(mime, data) + track.metadata.make_and_add_image(mime, data) for file in track.iterfiles(): - file.metadata.add_image(mime, data) + file.metadata.make_and_add_image(mime, data) elif isinstance(self.item, File): file = self.item - file.metadata.add_image(mime, data) + file.metadata.make_and_add_image(mime, data) diff --git a/picard/ui/infodialog.py b/picard/ui/infodialog.py index d2169db8e..feabb662a 100644 --- a/picard/ui/infodialog.py +++ b/picard/ui/infodialog.py @@ -49,7 +49,11 @@ class InfoDialog(PicardDialog): return for image in images: - data = image["data"] + try: + data = image.data + except (OSError, IOError), e: + log.error(traceback.format_exc()) + continue size = len(data) item = QtGui.QListWidgetItem() pixmap = QtGui.QPixmap() @@ -105,8 +109,8 @@ class FileInfoDialog(InfoDialog): ch = str(ch) info.append((_('Channels:'), ch)) text = '
'.join(map(lambda i: '%s
%s' % - (cgi.escape(i[0]), - cgi.escape(i[1])), info)) + (QtCore.Qt.escape(i[0]), + QtCore.Qt.escape(i[1])), info)) self.ui.info.setText(text) @@ -124,7 +128,7 @@ class AlbumInfoDialog(InfoDialog): if album.errors: tabWidget.setTabText(tab_index, _("&Errors")) text = '
'.join(map(lambda s: '%s' % - '
'.join(unicode(cgi.escape(s)) + '
'.join(unicode(QtCore.Qt.escape(s)) .replace('\t', ' ') .replace(' ', ' ') .splitlines() diff --git a/picard/util/__init__.py b/picard/util/__init__.py index a1b033d92..5213f7a4a 100644 --- a/picard/util/__init__.py +++ b/picard/util/__init__.py @@ -27,6 +27,19 @@ from PyQt4 import QtCore from encodings import rot_13 from string import Template from functools import partial +from collections import defaultdict + + +class LockableDefaultDict(defaultdict): + def __init__(self, default): + defaultdict.__init__(self, default) + self.__lock = QtCore.QReadWriteLock() + + def lock(self): + self.__lock.lockForWrite() + + def unlock(self): + self.__lock.unlock() def asciipunct(s): diff --git a/test/test_formats.py b/test/test_formats.py index 8c7e5919d..e880f81d9 100644 --- a/test/test_formats.py +++ b/test/test_formats.py @@ -1,11 +1,14 @@ import os.path +import picard.formats import unittest import shutil -from tempfile import mkstemp + + +from PyQt4 import QtCore +from picard.util import LockableDefaultDict from picard import config, log from picard.metadata import Metadata -import picard.formats -from PyQt4 import QtCore +from tempfile import mkstemp settings = { @@ -33,6 +36,7 @@ class FakeTagger(QtCore.QObject): QtCore.QObject.config = config QtCore.QObject.log = log self.tagger_stats_changed.connect(self.emit) + self.images = LockableDefaultDict(lambda: None) def emit(self, *args): pass @@ -504,6 +508,7 @@ class TestCoverArt(unittest.TestCase): QtCore.QObject.tagger = FakeTagger() def _tear_down(self): + map(lambda i: i._delete(), QtCore.QObject.tagger.images.itervalues()) os.unlink(self.filename) def test_asf(self): @@ -544,13 +549,13 @@ class TestCoverArt(unittest.TestCase): f = picard.formats.open(self.filename) metadata = Metadata() imgdata = tests[t]['head'] + dummyload - metadata.add_image(tests[t]['mime'], imgdata) + metadata.make_and_add_image(tests[t]['mime'], imgdata) f._save(self.filename, metadata) f = picard.formats.open(self.filename) loaded_metadata = f._load(self.filename) image = loaded_metadata.images[0] - self.assertEqual(image["mime"], tests[t]['mime']) - self.assertEqual(image["data"], imgdata) + self.assertEqual(image.mimetype, tests[t]['mime']) + self.assertEqual(image.data, imgdata) finally: self._tear_down()