diff --git a/sigal/gallery.py b/sigal/gallery.py index 536366d..4d90965 100644 --- a/sigal/gallery.py +++ b/sigal/gallery.py @@ -40,10 +40,10 @@ from PIL import Image as PILImage from . import image, video, signals from .compat import UnicodeMixin, strxfrm, url_quote -from .image import process_image, get_exif_tags +from .image import process_image, get_exif_tags, get_exif_data from .settings import get_thumb from .utils import (Devnull, copy, check_or_create_dir, url_from_path, - read_markdown) + read_markdown, cached_property) from .video import process_video from .writer import Writer @@ -70,6 +70,7 @@ class Media(UnicodeMixin): self.src_filename = self.filename = self.url = filename self.path = path self.settings = settings + self.ext = os.path.splitext(filename)[1].lower() self.src_path = join(settings['source'], path, filename) self.dst_path = join(settings['destination'], path, filename) @@ -78,9 +79,6 @@ class Media(UnicodeMixin): self.thumb_path = join(settings['destination'], path, self.thumb_name) self.logger = logging.getLogger(__name__) - self.raw_exif = None - self.exif = None - self.date = None self._get_metadata() signals.media_initialized.send(self) @@ -145,41 +143,24 @@ class Image(Media): type = 'image' extensions = ('.jpg', '.jpeg', '.png') - def __init__(self, filename, path, settings): - super(Image, self).__init__(filename, path, settings) - self._raw_exif = None - self._exif = None - - @property + @cached_property() def date(self): - if self.exif is not None and 'dateobj' in self.exif: - return self.exif['dateobj'] - else: - return self._date - - @date.setter - def date(self, value): - self._date = value + return self.exif and self.exif.get('dateobj', None) or None - @property + @cached_property() def exif(self): - if not self._exif: - self._raw_exif, self._exif = get_exif_tags(self.src_path) - return self._exif + return (get_exif_tags(self.raw_exif) + if self.raw_exif and self.ext in ('.jpg', '.jpeg') else None) - @exif.setter - def exif(self, value): - self._exif = value - - @property + @cached_property() def raw_exif(self): - if not self._raw_exif: - self._raw_exif, self._exif = get_exif_tags(self.src_path) - return self._raw_exif - - @raw_exif.setter - def raw_exif(self, value): - self._raw_exif = value + try: + return (get_exif_data(self.src_path) + if self.ext in ('.jpg', '.jpeg') else None) + except (IOError, IndexError, TypeError, AttributeError): + self.logger.warning(u'Could not read EXIF data from %s', + self.src_path) + return None class Video(Media): @@ -191,6 +172,7 @@ class Video(Media): def __init__(self, filename, path, settings): super(Video, self).__init__(filename, path, settings) base = splitext(filename)[0] + self.date = None self.src_filename = filename self.filename = self.url = base + '.webm' self.dst_path = join(settings['destination'], path, base + '.webm') diff --git a/sigal/image.py b/sigal/image.py index cdf2ee4..9ef65e0 100644 --- a/sigal/image.py +++ b/sigal/image.py @@ -153,8 +153,8 @@ def process_image(filepath, outpath, settings): return Status.SUCCESS -def _get_exif_data(filename): - """Return a dict with EXIF data.""" +def get_exif_data(filename): + """Return a dict with the raw EXIF data.""" img = PILImage.open(filename) exif = img._getexif() or {} @@ -175,23 +175,10 @@ def dms_to_degrees(v): return d + (m / 60.0) + (s / 3600.0) -def get_exif_tags(source): - """Read EXIF tags from file @source and return a tuple of two dictionaries, - the first one containing the raw EXIF data, the second one a simplified - version with common tags. - """ +def get_exif_tags(data): + """Make a simplified version with common tags from raw EXIF data.""" logger = logging.getLogger(__name__) - - if os.path.splitext(source)[1].lower() not in ('.jpg', '.jpeg'): - return (None, None) - - try: - data = _get_exif_data(source) - except (IOError, IndexError, TypeError, AttributeError): - logger.warning(u'Could not read EXIF data from %s', source) - return (None, None) - simple = {} # Provide more accessible tags in the 'simple' key @@ -209,8 +196,8 @@ def get_exif_tags(source): elif isinstance(data['ExposureTime'], int): simple['exposure'] = str(data['ExposureTime']) else: - logger.warning('Unknown format for ExposureTime: %r (%s)', - data['ExposureTime'], source) + logger.warning('Unknown format for ExposureTime: %r', + data['ExposureTime']) if 'ISOSpeedRatings' in data: simple['iso'] = data['ISOSpeedRatings'] @@ -227,8 +214,7 @@ def get_exif_tags(source): else: simple['datetime'] = dt except (ValueError, TypeError) as e: - logger.warning(u'Could not parse DateTimeOriginal of %s: %s', - source, e) + logger.warning(u'Could not parse DateTimeOriginal: %s', e) if 'GPSInfo' in data: info = data['GPSInfo'] @@ -242,7 +228,7 @@ def get_exif_tags(source): lat = dms_to_degrees(lat_info) lon = dms_to_degrees(lon_info) except (ZeroDivisionError, ValueError): - logger.warning('Failed to read GPS info for %s', source) + logger.warning('Failed to read GPS info') lat = lon = None if lat and lon: @@ -251,4 +237,4 @@ def get_exif_tags(source): 'lon': - lon if lon_ref_info != 'E' else lon, } - return (data, simple) + return simple diff --git a/sigal/utils.py b/sigal/utils.py index fb9418d..a751fbc 100644 --- a/sigal/utils.py +++ b/sigal/utils.py @@ -87,3 +87,39 @@ def call_subprocess(cmd): stderr = stderr.decode('utf8') stdout = stdout.decode('utf8') return p.returncode, stdout, stderr + + +class cached_property(object): + '''Decorator for read-only properties evaluated only once. + + © 2011 Christopher Arndt, MIT License + https://wiki.python.org/moin/PythonDecoratorLibrary#Cached_Properties + + The value is cached in the '_cache' attribute of the object instance that + has the property getter method wrapped by this decorator. The '_cache' + attribute value is a dictionary which has a key for every property of the + object which is wrapped by this decorator. Each entry in the cache is + created only when the property is accessed for the first time and is a + two-element tuple with the last computed property value and the last time + it was updated in seconds since the epoch. + + ''' + + def __call__(self, fget, doc=None): + self.fget = fget + self.__doc__ = doc or fget.__doc__ + self.__name__ = fget.__name__ + self.__module__ = fget.__module__ + return self + + def __get__(self, inst, owner): + try: + value = inst._cache[self.__name__] + except (KeyError, AttributeError): + value = self.fget(inst) + try: + cache = inst._cache + except AttributeError: + cache = inst._cache = {} + cache[self.__name__] = value + return value diff --git a/tests/test_image.py b/tests/test_image.py index d50a1bd..b3aa079 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -5,7 +5,8 @@ import pytest from PIL import Image from sigal import init_logging -from sigal.image import generate_image, generate_thumbnail, get_exif_tags +from sigal.image import (generate_image, generate_thumbnail, get_exif_tags, + get_exif_data) from sigal.settings import create_settings CURRENT_DIR = os.path.dirname(__file__) @@ -62,13 +63,12 @@ def test_exif_copy(tmpdir): settings = create_settings(img_size=(300, 400), copy_exif_data=True) generate_image(src_file, dst_file, settings) - raw, simple = get_exif_tags(dst_file) + simple = get_exif_tags(get_exif_data(dst_file)) assert simple['iso'] == 50 settings['copy_exif_data'] = False generate_image(src_file, dst_file, settings) - raw, simple = get_exif_tags(dst_file) - assert not raw + simple = get_exif_tags(get_exif_data(dst_file)) assert not simple @@ -82,7 +82,7 @@ def test_exif_gps(tmpdir): settings = create_settings(img_size=(400, 300), copy_exif_data=True) generate_image(src_file, dst_file, settings) - raw, simple = get_exif_tags(dst_file) + simple = get_exif_tags(get_exif_data(dst_file)) assert 'gps' in simple lat = 34.029167