aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorOlivier Tilloy <olivier@tilloy.net>2009-10-20 09:59:06 +0200
committerOlivier Tilloy <olivier@fluendo.com>2009-10-20 09:59:06 +0200
commitbd14365ded7630441a1f08d303114398df463b9c (patch)
tree3c66400f11604116c8c8daeebf6f12e4673f1a6a
parent4a7092691ef7ab3f11c8edcf4f28c8b42bf5342f (diff)
parentb6182ecb49d515f34b55666ec7e85f4ae4bc431a (diff)
downloadpyexiv2-bd14365ded7630441a1f08d303114398df463b9c.tar.gz
Code split into various python modules.
-rw-r--r--src/SConscript13
-rw-r--r--src/pyexiv2.py1574
-rw-r--r--src/pyexiv2/__init__.py73
-rw-r--r--src/pyexiv2/exif.py317
-rw-r--r--src/pyexiv2/iptc.py254
-rwxr-xr-xsrc/pyexiv2/main.py53
-rw-r--r--src/pyexiv2/metadata.py287
-rw-r--r--src/pyexiv2/tag.py77
-rw-r--r--src/pyexiv2/utils.py361
-rw-r--r--src/pyexiv2/xmp.py371
-rw-r--r--test/exif.py5
-rw-r--r--test/iptc.py5
-rw-r--r--test/metadata.py692
-rw-r--r--test/notifying_list.py338
-rw-r--r--test/rational.py80
-rw-r--r--test/xmp.py370
16 files changed, 3288 insertions, 1582 deletions
diff --git a/src/SConscript b/src/SConscript
index 60e13b6..89fe502 100644
--- a/src/SConscript
+++ b/src/SConscript
@@ -1,14 +1,14 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
-import sys
import os.path
+from glob import glob
+from distutils.sysconfig import get_python_inc, get_python_lib
env = Environment()
# Include directories to look for 'Python.h' in
-python_inc_path = os.path.join(sys.prefix, 'include', 'python' + sys.version[:3])
-env.Append(CPPPATH=[python_inc_path])
+env.Append(CPPPATH=[get_python_inc(plat_specific=True)])
# Libraries to link against
libs = ['boost_python', 'exiv2']
@@ -18,15 +18,16 @@ env.Append(LIBS=libs)
cpp_sources = ['exiv2wrapper.cpp', 'exiv2wrapper_python.cpp']
libpyexiv2 = env.SharedLibrary('exiv2python', cpp_sources)
-# Install the shared library and the Python module, invoked using
+# Install the shared library and the Python modules, invoked using
# 'scons install'. If DESTDIR is specified on the command line when invoking
# scons, it will be prepended to each installed target file. See
# http://www.gnu.org/prep/standards/html_node/DESTDIR.html for reference.
-python_lib_path = os.path.join(sys.prefix, 'lib', 'python' + sys.version[:3], 'site-packages')
+python_lib_path = get_python_lib(plat_specific=True)
dest_dir = ARGUMENTS.get('DESTDIR')
if (dest_dir is None) or (not os.path.isabs(dest_dir)):
install_dir = python_lib_path
else:
install_dir = os.path.join(dest_dir, python_lib_path[1:])
-env.Install(install_dir, [libpyexiv2, 'pyexiv2.py'])
+env.Install(install_dir, [libpyexiv2])
+env.Install(os.path.join(install_dir, 'pyexiv2'), glob('pyexiv2/*.py'))
env.Alias('install', install_dir)
diff --git a/src/pyexiv2.py b/src/pyexiv2.py
deleted file mode 100644
index 0e914f7..0000000
--- a/src/pyexiv2.py
+++ /dev/null
@@ -1,1574 +0,0 @@
-#!/usr/bin/python
-# -*- coding: utf-8 -*-
-
-# ******************************************************************************
-#
-# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net>
-#
-# This file is part of the pyexiv2 distribution.
-#
-# pyexiv2 is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# pyexiv2 is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with pyexiv2; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
-#
-# Author: Olivier Tilloy <olivier@tilloy.net>
-#
-# ******************************************************************************
-
-"""
-Manipulation of EXIF, IPTC and XMP metadata and thumbnails embedded in images.
-
-The L{ImageMetadata} class provides read/write access to all the metadata and
-the various thumbnails embedded in an image file such as JPEG and TIFF files.
-
-Metadata is accessed through subclasses of L{MetadataTag} and the tag values are
-conveniently wrapped in python objects.
-A tag containing a date/time information for the image
-(e.g. C{Exif.Photo.DateTimeOriginal}) will be represented by a python
-C{datetime.datetime} object.
-
-This module is a python layer on top of the low-level python binding of the
-C++ library Exiv2, libpyexiv2.
-
-A typical use of this binding would be:
-
->>> import pyexiv2
->>> metadata = pyexiv2.ImageMetadata('test/smiley.jpg')
->>> metadata.read()
->>> print metadata.exif_keys
-['Exif.Image.ImageDescription', 'Exif.Image.XResolution',
- 'Exif.Image.YResolution', 'Exif.Image.ResolutionUnit', 'Exif.Image.Software',
- 'Exif.Image.DateTime', 'Exif.Image.Artist', 'Exif.Image.Copyright',
- 'Exif.Image.ExifTag', 'Exif.Photo.Flash', 'Exif.Photo.PixelXDimension',
- 'Exif.Photo.PixelYDimension']
->>> print metadata['Exif.Image.DateTime'].value
-2004-07-13 21:23:44
->>> import datetime
->>> metadata['Exif.Image.DateTime'].value = datetime.datetime.today()
->>> metadata.write()
-"""
-
-import libexiv2python
-
-import os
-import time
-import datetime
-import re
-
-
-__version__ = (0, 2, 1)
-
-__exiv2_version__ = libexiv2python.__exiv2_version__
-
-
-class FixedOffset(datetime.tzinfo):
-
- """
- Fixed positive or negative offset from a local time east from UTC.
-
- @ivar sign: the sign of the offset ('+' or '-')
- @type sign: C{str}
- @ivar hours: the absolute number of hours of the offset
- @type hours: C{int}
- @ivar minutes: the absolute number of minutes of the offset
- @type minutes: C{int}
-
- """
-
- def __init__(self, sign='+', hours=0, minutes=0):
- """
- Initialize an offset from a sign ('+' or '-') and an absolute value
- expressed in hours and minutes.
- No check on the validity of those values is performed, it is the
- responsibility of the caller to pass valid values.
-
- @param sign: the sign of the offset ('+' or '-')
- @type sign: C{str}
- @param hours: an absolute number of hours
- @type hours: C{int}
- @param minutes: an absolute number of minutes
- @type minutes: C{int}
- """
- self.sign = sign
- self.hours = hours
- self.minutes = minutes
-
- def utcoffset(self, dt):
- """
- Return offset of local time from UTC, in minutes east of UTC.
- If local time is west of UTC, this value will be negative.
-
- @param dt: the local time
- @type dt: C{datetime.time}
-
- @return: a whole number of minutes in the range -1439 to 1439 inclusive
- @rtype: C{datetime.timedelta}
- """
- total = self.hours * 60 + self.minutes
- if self.sign == '-':
- total = -total
- return datetime.timedelta(minutes = total)
-
- def dst(self, dt):
- """
- Return the daylight saving time (DST) adjustment.
- In this implementation, it is always nil.
-
- @param dt: the local time
- @type dt: C{datetime.time}
-
- @return: the DST adjustment (always nil)
- @rtype: C{datetime.timedelta}
- """
- return datetime.timedelta(0)
-
- def tzname(self, dt):
- """
- Return a string representation of the offset in the format '±%H:%M'.
- If the offset is nil, the representation is, by convention, 'Z'.
-
- @param dt: the local time
- @type dt: C{datetime.time}
-
- @return: a human-readable representation of the offset
- @rtype: C{str}
- """
- if self.hours == 0 and self.minutes == 0:
- return 'Z'
- else:
- return '%s%02d:%02d' % (self.sign, self.hours, self.minutes)
-
- def __equal__(self, other):
- """
- Test equality between this offset and another offset.
-
- @param other: another offset
- @type other: L{FixedOffset}
-
- @return: C{True} if the offset are equal, C{False} otherwise
- @rtype: C{bool}
- """
- return (self.sign == other.sign) and (self.hours == other.hours) and \
- (self.minutes == other.minutes)
-
-
-def UndefinedToString(undefined):
- """
- Convert an undefined string into its corresponding sequence of bytes.
- The undefined string must contain the ascii codes of a sequence of bytes,
- each followed by a blank space (e.g. "48 50 50 49 " will be converted into
- "0221").
- The Undefined type is part of the EXIF specification.
-
- @param undefined: an undefined string
- @type undefined: C{str}
-
- @return: the corresponding decoded string
- @rtype: C{str}
- """
- return ''.join(map(lambda x: chr(int(x)), undefined.rstrip().split(' ')))
-
-
-def StringToUndefined(sequence):
- """
- Convert a string into its undefined form.
- The undefined form contains a sequence of ascii codes, each followed by a
- blank space (e.g. "0221" will be converted into "48 50 50 49 ").
- The Undefined type is part of the EXIF specification.
-
- @param sequence: a sequence of bytes
- @type sequence: C{str}
-
- @return: the corresponding undefined string
- @rtype: C{str}
- """
- return ''.join(map(lambda x: '%d ' % ord(x), sequence))
-
-
-class Rational(object):
-
- """
- A class representing a rational number.
-
- Its numerator and denominator are read-only properties.
- """
-
- _format_re = re.compile(r'(?P<numerator>-?\d+)/(?P<denominator>\d+)')
-
- def __init__(self, numerator, denominator):
- """
- Constructor.
-
- @param numerator: the numerator
- @type numerator: C{long}
- @param denominator: the denominator
- @type denominator: C{long}
-
- @raise ZeroDivisionError: if the denominator equals zero
- """
- if denominator == 0:
- msg = 'Denominator of a rational number cannot be zero.'
- raise ZeroDivisionError(msg)
- self._numerator = long(numerator)
- self._denominator = long(denominator)
-
- @property
- def numerator(self):
- return self._numerator
-
- @property
- def denominator(self):
- return self._denominator
-
- @staticmethod
- def from_string(string):
- """
- Instantiate a Rational from a string formatted as
- C{[-]numerator/denominator}.
-
- @param string: a string representation of a rational number
- @type string: C{str}
-
- @return: the rational number parsed
- @rtype: L{Rational}
-
- @raise ValueError: if the format of the string is invalid
- """
- match = Rational._format_re.match(string)
- if match is None:
- raise ValueError('Invalid format for a rational: %s' % string)
- gd = match.groupdict()
- return Rational(long(gd['numerator']), long(gd['denominator']))
-
- def to_float(self):
- """
- @return: a floating point number approximation of the value
- @rtype: C{float}
- """
- return float(self._numerator) / self._denominator
-
- def __eq__(self, other):
- """
- Compare two rational numbers for equality.
-
- Two rational numbers are equal if their reduced forms are equal.
-
- @param other: the rational number to compare to self for equality
- @type other: L{Rational}
-
- @return: C{True} if equal, C{False} otherwise
- @rtype: C{bool}
- """
- return (self._numerator * other._denominator) == \
- (other._numerator * self._denominator)
-
- def __str__(self):
- """
- Return a string representation of the rational number.
- """
- return '%d/%d' % (self._numerator, self._denominator)
-
-
-class ListenerInterface(object):
-
- """
- Interface that an object that wants to listen to changes on another object
- should implement.
- """
-
- def contents_changed(self):
- """
- React on changes on the object observed.
- Override to implement specific behaviours.
- """
- raise NotImplementedError()
-
-
-class NotifyingList(list):
-
- """
- A simplistic implementation of a notifying list.
- Any changes to the list are notified in a synchronous way to all previously
- registered listeners. A listener must implement the L{ListenerInterface}.
- """
-
- # Useful documentation:
- # file:///usr/share/doc/python2.5/html/lib/typesseq-mutable.html
- # http://docs.python.org/reference/datamodel.html#additional-methods-for-emulation-of-sequence-types
-
- def __init__(self, items=[]):
- super(NotifyingList, self).__init__(items)
- self._listeners = set()
-
- def register_listener(self, listener):
- """
- Register a new listener to be notified of changes.
-
- @param listener: any object that listens for changes
- @type listener: any class that implements the L{ListenerInterface}
- """
- self._listeners.add(listener)
-
- def unregister_listener(self, listener):
- """
- Unregister a previously registered listener.
-
- @param listener: a previously registered listener
- @type listener: any class that implements the L{ListenerInterface}
-
- @raise KeyError: if the listener was not previously registered
- """
- self._listeners.remove(listener)
-
- def _notify_listeners(self, *args):
- for listener in self._listeners:
- listener.contents_changed(*args)
-
- def __setitem__(self, index, item):
- # FIXME: support slice arguments for extended slicing
- super(NotifyingList, self).__setitem__(index, item)
- self._notify_listeners()
-
- def __delitem__(self, index):
- # FIXME: support slice arguments for extended slicing
- super(NotifyingList, self).__delitem__(index)
- self._notify_listeners()
-
- def append(self, item):
- super(NotifyingList, self).append(item)
- self._notify_listeners()
-
- def extend(self, items):
- super(NotifyingList, self).extend(items)
- self._notify_listeners()
-
- def insert(self, index, item):
- super(NotifyingList, self).insert(index, item)
- self._notify_listeners()
-
- def pop(self, index=None):
- if index is None:
- item = super(NotifyingList, self).pop()
- else:
- item = super(NotifyingList, self).pop(index)
- self._notify_listeners()
- return item
-
- def remove(self, item):
- super(NotifyingList, self).remove(item)
- self._notify_listeners()
-
- def reverse(self):
- super(NotifyingList, self).reverse()
- self._notify_listeners()
-
- def sort(self, cmp=None, key=None, reverse=False):
- super(NotifyingList, self).sort(cmp, key, reverse)
- self._notify_listeners()
-
- def __iadd__(self, other):
- self = super(NotifyingList, self).__iadd__(other)
- self._notify_listeners()
- return self
-
- def __imul__(self, coefficient):
- self = super(NotifyingList, self).__imul__(coefficient)
- self._notify_listeners()
- return self
-
- def __setslice__(self, i, j, items):
- # __setslice__ is deprecated but needs to be overridden for completeness
- super(NotifyingList, self).__setslice__(i, j, items)
- self._notify_listeners()
-
- def __delslice__(self, i, j):
- # __delslice__ is deprecated but needs to be overridden for completeness
- deleted = self[i:j]
- super(NotifyingList, self).__delslice__(i, j)
- if deleted:
- self._notify_listeners()
-
-
-class MetadataTag(object):
-
- """
- A generic metadata tag.
- It is meant to be subclassed to implement specific tag types behaviours.
-
- @ivar key: a unique key that identifies the tag
- @type key: C{str}
- @ivar name: the short internal name that identifies the tag within
- its scope
- @type name: C{str}
- @ivar label: a human readable label for the tag
- @type label: C{str}
- @ivar description: a description of the function of the tag
- @type description: C{str}
- @ivar type: the data type name
- @type type: C{str}
- @ivar raw_value: the raw value of the tag as provided by exiv2
- @type raw_value: C{str}
- @ivar metadata: reference to the containing metadata if any
- @type metadata: L{pyexiv2.ImageMetadata}
- """
-
- def __init__(self, key, name, label, description, type, value):
- self.key = key
- self.name = name
- # FIXME: all attributes that may contain a localized string should be
- # unicode.
- self.label = label
- self.description = description
- self.type = type
- self.raw_value = value
- self.metadata = None
-
- def __str__(self):
- """
- Return a string representation of the value of the tag suitable to pass
- to libexiv2 to set it.
-
- @rtype: C{str}
- """
- return self.raw_value
-
- def __repr__(self):
- """
- Return a string representation of the tag for debugging purposes.
-
- @rtype: C{str}
- """
- return '<%s [%s] = %s>' % (self.key, self.type, self.raw_value)
-
-
-class ExifValueError(ValueError):
-
- """
- Exception raised when failing to parse the value of an EXIF tag.
-
- @ivar value: the value that fails to be parsed
- @type value: C{str}
- @ivar type: the EXIF type of the tag
- @type type: C{str}
- """
-
- def __init__(self, value, type):
- self.value = value
- self.type = type
-
- def __str__(self):
- return 'Invalid value for EXIF type [%s]: [%s]' % \
- (self.type, self.value)
-
-
-class ExifTag(MetadataTag, ListenerInterface):
-
- """
- An EXIF metadata tag.
- This tag has an additional field that contains the value of the tag
- formatted as a human readable string.
-
- @ivar fvalue: the value of the tag formatted as a human readable string
- @type fvalue: C{str}
- """
-
- # According to the EXIF specification, the only accepted format for an Ascii
- # value representing a datetime is '%Y:%m:%d %H:%M:%S', but it seems that
- # others formats can be found in the wild.
- _datetime_formats = ('%Y:%m:%d %H:%M:%S',
- '%Y-%m-%d %H:%M:%S',
- '%Y-%m-%dT%H:%M:%SZ')
-
- _date_formats = ('%Y:%m:%d',)
-
- def __init__(self, key, name, label, description, type, value, fvalue):
- super(ExifTag, self).__init__(key, name, label,
- description, type, value)
- self.fvalue = fvalue
- self._init_values()
-
- def _init_values(self):
- # Initial conversion of the raw values to their corresponding python
- # types.
- if self.type in ('Short', 'Long', 'SLong', 'Rational', 'SRational'):
- # May contain multiple values
- values = self.raw_value.split()
- if len(values) > 1:
- # Make values a notifying list
- values = map(self._convert_to_python, values)
- self._value = NotifyingList(values)
- self._value.register_listener(self)
- return
- self._value = self._convert_to_python(self.raw_value)
-
- def _get_value(self):
- return self._value
-
- def _set_value(self, new_value):
- if self.metadata is not None:
- if isinstance(new_value, (list, tuple)):
- raw_values = map(self._convert_to_string, new_value)
- raw_value = ' '.join(raw_values)
- else:
- raw_value = self._convert_to_string(new_value)
- self.metadata._set_exif_tag_value(self.key, raw_value)
-
- if isinstance(self._value, NotifyingList):
- self._value.unregister_listener(self)
-
- if isinstance(new_value, NotifyingList):
- # Already a notifying list
- self._value = new_value
- self._value.register_listener(self)
- elif isinstance(new_value, (list, tuple)):
- # Make the values a notifying list
- self._value = NotifyingList(new_value)
- self._value.register_listener(self)
- else:
- # Single value
- self._value = new_value
-
- def _del_value(self):
- if self.metadata is not None:
- self.metadata._delete_exif_tag(self.key)
-
- if isinstance(self._value, NotifyingList):
- self._value.unregister_listener(self)
-
- del self._value
-
- """the value of the tag converted to its corresponding python type"""
- value = property(fget=_get_value, fset=_set_value, fdel=_del_value,
- doc=None)
-
- def contents_changed(self):
- """
- Implementation of the L{ListenerInterface}.
- React on changes to the list of values of the tag.
- """
- # self._value is a list of value and its contents changed.
- self._set_value(self._value)
-
- def _convert_to_python(self, value):
- """
- Convert one raw value to its corresponding python type.
-
- @param value: the raw value to be converted
- @type value: C{str}
-
- @return: the value converted to its corresponding python type
- @rtype: depends on C{self.type} (DOCME)
-
- @raise ExifValueError: if the conversion fails
- """
- if self.type == 'Ascii':
- # The value may contain a Datetime
- for format in self._datetime_formats:
- try:
- t = time.strptime(value, format)
- except ValueError:
- continue
- else:
- return datetime.datetime(*t[:6])
- # Or a Date (e.g. Exif.GPSInfo.GPSDateStamp)
- for format in self._date_formats:
- try:
- t = time.strptime(value, format)
- except ValueError:
- continue
- else:
- return datetime.date(*t[:3])
- # Default to string.
- # There is currently no charset conversion.
- # TODO: guess the encoding and decode accordingly into unicode
- # where relevant.
- return value
-
- elif self.type == 'Byte':
- return value
-
- elif self.type == 'Short':
- try:
- return int(value)
- except ValueError:
- raise ExifValueError(value, self.type)
-
- elif self.type in ('Long', 'SLong'):
- try:
- return long(value)
- except ValueError:
- raise ExifValueError(value, self.type)
-
- elif self.type in ('Rational', 'SRational'):
- try:
- r = Rational.from_string(value)
- except (ValueError, ZeroDivisionError):
- raise ExifValueError(value, self.type)
- else:
- if self.type == 'Rational' and r.numerator < 0:
- raise ExifValueError(value, self.type)
- return r
-
- elif self.type == 'Undefined':
- # There is currently no charset conversion.
- # TODO: guess the encoding and decode accordingly into unicode
- # where relevant.
- return self.fvalue
-
- raise ExifValueError(value, self.type)
-
- def _convert_to_string(self, value):
- """
- Convert one value to its corresponding string representation, suitable
- to pass to libexiv2.
-
- @param value: the value to be converted
- @type value: depends on C{self.type} (DOCME)
-
- @return: the value converted to its corresponding string representation
- @rtype: C{str}
-
- @raise ExifValueError: if the conversion fails
- """
- if self.type == 'Ascii':
- if type(value) is datetime.datetime:
- return value.strftime(self._datetime_formats[0])
- elif type(value) is datetime.date:
- if self.key == 'Exif.GPSInfo.GPSDateStamp':
- # Special case
- return value.strftime(self._date_formats[0])
- else:
- return value.strftime('%s 00:00:00' % self._date_formats[0])
- elif type(value) is unicode:
- try:
- return value.encode('utf-8')
- except UnicodeEncodeError:
- raise ExifValueError(value, self.type)
- elif type(value) is str:
- return value
- else:
- raise ExifValueError(value, self.type)
-
- elif self.type == 'Byte':
- if type(value) is unicode:
- try:
- return value.encode('utf-8')
- except UnicodeEncodeError:
- raise ExifValueError(value, self.type)
- elif type(value) is str:
- return value
- else:
- raise ExifValueError(value, self.type)
-
- elif self.type == 'Short':
- if type(value) is int and value >= 0:
- return str(value)
- else:
- raise ExifValueError(value, self.type)
-
- elif self.type == 'Long':
- if type(value) in (int, long) and value >= 0:
- return str(value)
- else:
- raise ExifValueError(value, self.type)
-
- elif self.type == 'SLong':
- if type(value) in (int, long):
- return str(value)
- else:
- raise ExifValueError(value, self.type)
-
- elif self.type == 'Rational':
- if type(value) is Rational and value.numerator >= 0:
- return str(value)
- else:
- raise ExifValueError(value, self.type)
-
- elif self.type == 'SRational':
- if type(value) is Rational:
- return str(value)
- else:
- raise ExifValueError(value, self.type)
-
- elif self.type == 'Undefined':
- if type(value) is unicode:
- try:
- return value.encode('utf-8')
- except UnicodeEncodeError:
- raise ExifValueError(value, self.type)
- elif type(value) is str:
- return value
- else:
- raise ExifValueError(value, self.type)
-
- raise ExifValueError(value, self.type)
-
- def __str__(self):
- """
- Return a string representation of the value of the EXIF tag suitable to
- pass to libexiv2 to set it.
-
- @rtype: C{str}
- """
- return self._convert_to_string(self.value)
-
- def __repr__(self):
- """
- Return a string representation of the EXIF tag for debugging purposes.
-
- @rtype: C{str}
- """
- left = '%s [%s]' % (self.key, self.type)
- if self.type == 'Undefined' and len(self._value) > 100:
- right = '(Binary value suppressed)'
- else:
- right = self.fvalue
- return '<%s = %s>' % (left, right)
-
-
-class IptcValueError(ValueError):
-
- """
- Exception raised when failing to parse the value of an IPTC tag.
-
- @ivar value: the value that fails to be parsed
- @type value: C{str}
- @ivar type: the IPTC type of the tag
- @type type: C{str}
- """
-
- def __init__(self, value, type):
- self.value = value
- self.type = type
-
- def __str__(self):
- return 'Invalid value for IPTC type [%s]: [%s]' % \
- (self.type, self.value)
-
-
-class IptcTag(MetadataTag):
-
- """
- An IPTC metadata tag.
- This tag can have several values (tags that have the repeatable property).
- """
-
- # strptime is not flexible enough to handle all valid Time formats, we use a
- # custom regular expression
- _time_zone_re = r'(?P<sign>\+|-)(?P<ohours>\d{2}):(?P<ominutes>\d{2})'
- _time_re = re.compile(r'(?P<hours>\d{2}):(?P<minutes>\d{2}):(?P<seconds>\d{2})(?P<tzd>%s)' % _time_zone_re)
-
- def __init__(self, key, name, label, description, type, values):
- super(IptcTag, self).__init__(key, name, label,
- description, type, values)
- self._init_values()
-
- def _init_values(self):
- # Initial conversion of the raw values to their corresponding python
- # types.
- values = map(self._convert_to_python, self.raw_value)
- # Make values a notifying list
- self._values = NotifyingList(values)
- self._values.register_listener(self)
-
- def _get_values(self):
- return self._values
-
- def _set_values(self, new_values):
- if self.metadata is not None:
- raw_values = map(self._convert_to_string, new_values)
- self.metadata._set_iptc_tag_values(self.key, raw_values)
- # Make values a notifying list if needed
- if isinstance(new_values, NotifyingList):
- self._values = new_values
- else:
- self._values = NotifyingList(new_values)
-
- def _del_values(self):
- if self.metadata is not None:
- self.metadata._delete_iptc_tag(self.key)
- del self._values
-
- """the list of values of the tag converted to their corresponding python
- type"""
- values = property(fget=_get_values, fset=_set_values, fdel=_del_values,
- doc=None)
-
- def contents_changed(self):
- """
- Implementation of the L{ListenerInterface}.
- React on changes to the list of values of the tag.
- """
- # The contents of self._values was changed.
- # The following is a quick, non optimal solution.
- self._set_values(self._values)
-
- def _convert_to_python(self, value):
- """
- Convert one raw value to its corresponding python type.
-
- @param value: the raw value to be converted
- @type value: C{str}
-
- @return: the value converted to its corresponding python type
- @rtype: depends on C{self.type} (DOCME)
-
- @raise IptcValueError: if the conversion fails
- """
- if self.type == 'Short':
- try:
- return int(value)
- except ValueError:
- raise IptcValueError(value, self.type)
-
- elif self.type == 'String':
- # There is currently no charset conversion.
- # TODO: guess the encoding and decode accordingly into unicode
- # where relevant.
- return value
-
- elif self.type == 'Date':
- # According to the IPTC specification, the format for a string field
- # representing a date is '%Y%m%d'. However, the string returned by
- # exiv2 using method DateValue::toString() is formatted using
- # pattern '%Y-%m-%d'.
- format = '%Y-%m-%d'
- try:
- t = time.strptime(value, format)
- return datetime.date(*t[:3])
- except ValueError:
- raise IptcValueError(value, self.type)
-
- elif self.type == 'Time':
- # According to the IPTC specification, the format for a string field
- # representing a time is '%H%M%S±%H%M'. However, the string returned
- # by exiv2 using method TimeValue::toString() is formatted using
- # pattern '%H:%M:%S±%H:%M'.
- match = IptcTag._time_re.match(value)
- if match is None:
- raise IptcValueError(value, self.type)
- gd = match.groupdict()
- try:
- tzinfo = FixedOffset(gd['sign'], int(gd['ohours']),
- int(gd['ominutes']))
- except TypeError:
- raise IptcValueError(value, self.type)
- try:
- return datetime.time(int(gd['hours']), int(gd['minutes']),
- int(gd['seconds']), tzinfo=tzinfo)
- except (TypeError, ValueError):
- raise IptcValueError(value, self.type)
-
- elif self.type == 'Undefined':
- # Binary data, return it unmodified
- return value
-
- raise IptcValueError(value, self.type)
-
- def _convert_to_string(self, value):
- """
- Convert one value to its corresponding string representation, suitable
- to pass to libexiv2.
-
- @param value: the value to be converted
- @type value: depends on C{self.type} (DOCME)
-
- @return: the value converted to its corresponding string representation
- @rtype: C{str}
-
- @raise IptcValueError: if the conversion fails
- """
- if self.type == 'Short':
- if type(value) is int:
- return str(value)
- else:
- raise IptcValueError(value, self.type)
-
- elif self.type == 'String':
- if type(value) is unicode:
- try:
- return value.encode('utf-8')
- except UnicodeEncodeError:
- raise IptcValueError(value, self.type)
- elif type(value) is str:
- return value
- else:
- raise IptcValueError(value, self.type)
-
- elif self.type == 'Date':
- if type(value) in (datetime.date, datetime.datetime):
- # ISO 8601 date format
- return value.strftime('%Y%m%d')
- else:
- raise IptcValueError(value, self.type)
-
- elif self.type == 'Time':
- if type(value) in (datetime.time, datetime.datetime):
- r = value.strftime('%H%M%S')
- if value.tzinfo is not None:
- r += value.strftime('%z')
- else:
- r += '+0000'
- return r
- else:
- raise IptcValueError(value, self.type)
-
- elif self.type == 'Undefined':
- if type(value) is str:
- return value
- else:
- raise IptcValueError(value, self.type)
-
- raise IptcValueError(value, self.type)
-
- def to_string(self):
- """
- Return a list of string representations of the values of the IPTC tag
- suitable to pass to libexiv2 to set it.
-
- @rtype: C{list} of C{str}
- """
- return map(self._convert_to_string, self.values)
-
- def __str__(self):
- """
- Return a string representation of the list of values of the IPTC tag.
-
- @rtype: C{str}
- """
- return ', '.join(self.to_string())
-
- def __repr__(self):
- """
- Return a string representation of the IPTC tag for debugging purposes.
-
- @rtype: C{str}
- """
- return '<%s [%s] = %s>' % (self.key, self.type, str(self))
-
-
-class XmpValueError(ValueError):
-
- """
- Exception raised when failing to parse the value of an XMP tag.
-
- @ivar value: the value that fails to be parsed
- @type value: C{str}
- @ivar type: the XMP type of the tag
- @type type: C{str}
- """
- def __init__(self, value, type):
- self.value = value
- self.type = type
-
- def __str__(self):
- return 'Invalid value for XMP type [%s]: [%s]' % \
- (self.type, self.value)
-
-
-class XmpTag(MetadataTag):
-
- """
- An XMP metadata tag.
- """
-
- # strptime is not flexible enough to handle all valid Date formats, we use a
- # custom regular expression
- _time_zone_re = r'Z|((?P<sign>\+|-)(?P<ohours>\d{2}):(?P<ominutes>\d{2}))'
- _time_re = r'(?P<hours>\d{2})(:(?P<minutes>\d{2})(:(?P<seconds>\d{2})(.(?P<decimal>\d+))?)?(?P<tzd>%s))?' % _time_zone_re
- _date_re = re.compile(r'(?P<year>\d{4})(-(?P<month>\d{2})(-(?P<day>\d{2})(T(?P<time>%s))?)?)?' % _time_re)
-
- def __init__(self, key, name, label, description, type, value):
- super(XmpTag, self).__init__(key, name, label, description, type, value)
- self._value = XmpTag._convert_to_python(value, type)
-
- def _get_value(self):
- return self._value
-
- def _set_value(self, new_value):
- if self.metadata is not None:
- raw_value = XmpTag._convert_to_string(new_value, self.type)
- self.metadata._set_xmp_tag_value(self.key, raw_value)
- self._value = new_value
-
- def _del_value(self):
- if self.metadata is not None:
- self.metadata._delete_xmp_tag(self.key)
- del self._value
-
- """the value of the tag converted to its corresponding python type"""
- value = property(fget=_get_value, fset=_set_value, fdel=_del_value,
- doc=None)
-
- @staticmethod
- def _convert_to_python(value, xtype):
- """
- Convert a raw value to its corresponding python type.
-
- @param value: the raw value to be converted
- @type value: C{str}
- @param xtype: the XMP type of the value
- @type xtype: C{str}
-
- @return: the value converted to its corresponding python type
- @rtype: depends on xtype (DOCME)
-
- @raise XmpValueError: if the conversion fails
- """
- if xtype.startswith('bag '):
- # FIXME: make the value a notifying list.
- if value == '':
- return []
- values = value.split(', ')
- return map(lambda x: XmpTag._convert_to_python(x, xtype[4:]), values)
-
- elif xtype == 'Boolean':
- if value == 'True':
- return True
- elif value == 'False':
- return False
- else:
- raise XmpValueError(value, xtype)
-
- elif xtype == 'Choice':
- # TODO
- raise NotImplementedError('XMP conversion for type [%s]' % xtype)
-
- elif xtype == 'Colorant':
- # TODO
- raise NotImplementedError('XMP conversion for type [%s]' % xtype)
-
- elif xtype == 'Date':
- match = XmpTag._date_re.match(value)
- if match is None:
- raise XmpValueError(value, xtype)
- gd = match.groupdict()
- if gd['month'] is not None:
- month = int(gd['month'])
- else:
- month = 1
- if gd['day'] is not None:
- day = int(gd['day'])
- else:
- day = 1
- if gd['time'] is None:
- try:
- return datetime.date(int(gd['year']), month, day)
- except ValueError:
- raise XmpValueError(value, xtype)
- else:
- if gd['minutes'] is None:
- # Malformed time
- raise XmpValueError(value, xtype)
- if gd['seconds'] is not None:
- seconds = int(gd['seconds'])
- else:
- seconds = 0
- if gd['decimal'] is not None:
- microseconds = int(float('0.%s' % gd['decimal']) * 1E6)
- else:
- microseconds = 0
- if gd['tzd'] == 'Z':
- tzinfo = FixedOffset()
- else:
- tzinfo = FixedOffset(gd['sign'], int(gd['ohours']),
- int(gd['ominutes']))
- try:
- return datetime.datetime(int(gd['year']), month, day,
- int(gd['hours']), int(gd['minutes']),
- seconds, microseconds, tzinfo)
- except ValueError:
- raise XmpValueError(value, xtype)
-
- elif xtype == 'Dimensions':
- # TODO
- raise NotImplementedError('XMP conversion for type [%s]' % xtype)
-
- elif xtype == 'Font':
- # TODO
- raise NotImplementedError('XMP conversion for type [%s]' % xtype)
-
- elif xtype == 'Integer':
- try:
- return int(value)
- except ValueError:
- raise XmpValueError(value, xtype)
-
- elif xtype == 'Lang Alt':
- matches = value.split('lang="')
- nb = len(matches)
- if nb < 2 or matches[0] != '':
- raise XmpValueError(value, xtype)
- result = {}
- for i, match in enumerate(matches[1:]):
- try:
- qualifier, text = match.split('" ', 1)
- except ValueError:
- raise XmpValueError(value, xtype)
- else:
- if not text.rstrip().endswith(','):
- if (i < nb - 2):
- # If not the last match, it should end with a comma
- raise XmpValueError(value, xtype)
- else:
- result[qualifier] = text
- try:
- result[qualifier] = unicode(text, 'utf-8')
- except TypeError:
- raise XmpValueError(value, xtype)
- else:
- try:
- result[qualifier] = unicode(text.rstrip()[:-1], 'utf-8')
- except TypeError:
- raise XmpValueError(value, xtype)
- return result
-
- elif xtype == 'Locale':
- # TODO
- # See RFC 3066
- raise NotImplementedError('XMP conversion for type [%s]' % xtype)
-
- elif xtype == 'MIMEType':
- try:
- mtype, msubtype = value.split('/', 1)
- except ValueError:
- raise XmpValueError(value, xtype)
- else:
- return {'type': mtype, 'subtype': msubtype}
-
- elif xtype == 'Real':
- # TODO
- raise NotImplementedError('XMP conversion for type [%s]' % xtype)
-
- elif xtype in ('ProperName', 'Text'):
- try:
- return unicode(value, 'utf-8')
- except TypeError:
- raise XmpValueError(value, xtype)
-
- elif xtype == 'Thumbnail':
- # TODO
- raise NotImplementedError('XMP conversion for type [%s]' % xtype)
-
- elif xtype in ('URI', 'URL'):
- return value
-
- elif xtype == 'XPath':
- # TODO
- raise NotImplementedError('XMP conversion for type [%s]' % xtype)
-
- raise NotImplementedError('XMP conversion for type [%s]' % xtype)
-
- @staticmethod
- def _convert_to_string(value, xtype):
- """
- Convert a value to its corresponding string representation, suitable to
- pass to libexiv2.
-
- @param value: the value to be converted
- @type value: depends on xtype (DOCME)
- @param xtype: the XMP type of the value
- @type xtype: C{str}
-
- @return: the value converted to its corresponding string representation
- @rtype: C{str}
-
- @raise XmpValueError: if the conversion fails
- """
- if xtype.startswith('bag '):
- if type(value) in (list, tuple):
- return ', '.join(map(lambda x: XmpTag._convert_to_string(x, xtype[4:]), value))
- else:
- raise XmpValueError(value, xtype)
-
- elif xtype == 'Boolean':
- if type(value) is bool:
- return str(value)
- else:
- raise XmpValueError(value, xtype)
-
- elif xtype == 'Date':
- if type(value) is datetime.date:
- return value.isoformat()
- elif type(value) is datetime.datetime:
- if value.hour == 0 and value.minute == 0 and \
- value.second == 0 and value.microsecond == 0 and \
- (value.tzinfo is None or value.tzinfo == FixedOffset()):
- return value.strftime('%Y-%m-%d')
- elif value.second == 0 and value.microsecond == 0:
- return value.strftime('%Y-%m-%dT%H:%M%Z')
- elif value.microsecond == 0:
- return value.strftime('%Y-%m-%dT%H:%M:%S%Z')
- else:
- r = value.strftime('%Y-%m-%dT%H:%M:%S.')
- r += str(int(value.microsecond) / 1E6)[2:]
- r += value.strftime('%Z')
- return r
- else:
- raise XmpValueError(value, xtype)
-
- elif xtype == 'Integer':
- if type(value) in (int, long):
- return str(value)
- else:
- raise XmpValueError(value, xtype)
-
- elif xtype == 'Lang Alt':
- if type(value) is dict and len(value) > 0:
- r = ''
- for key, avalue in value.iteritems():
- if type(key) is unicode:
- try:
- rkey = key.encode('utf-8')
- except UnicodeEncodeError:
- raise XmpValueError(value, xtype)
- elif type(key) is str:
- rkey = key
- else:
- raise XmpValueError(value, xtype)
- if type(avalue) is unicode:
- try:
- ravalue = avalue.encode('utf-8')
- except UnicodeEncodeError:
- raise XmpValueError(value, xtype)
- elif type(avalue) is str:
- ravalue = avalue
- else:
- raise XmpValueError(value, xtype)
- r += 'lang="%s" %s, ' % (rkey, ravalue)
- return r[:-2]
- else:
- raise XmpValueError(value, xtype)
-
- elif xtype == 'MIMEType':
- if type(value) is dict:
- try:
- return '%s/%s' % (value['type'], value['subtype'])
- except KeyError:
- raise XmpValueError(value, xtype)
- else:
- raise XmpValueError(value, xtype)
-
- elif xtype in ('ProperName', 'Text', 'URI', 'URL'):
- if type(value) is unicode:
- try:
- return value.encode('utf-8')
- except UnicodeEncodeError:
- raise XmpValueError(value, xtype)
- elif type(value) is str:
- return value
- else:
- raise XmpValueError(value, xtype)
-
- raise NotImplementedError('XMP conversion for type [%s]' % xtype)
-
- def to_string(self):
- """
- Return a string representation of the XMP tag suitable to pass to
- libexiv2 to set the value of the tag.
-
- @rtype: C{str}
- """
- return XmpTag._convert_to_string(self.value, self.type)
-
- def __str__(self):
- """
- Return a string representation of the XMP tag for debugging purposes.
-
- @rtype: C{str}
- """
- r = 'Key = ' + self.key + os.linesep + \
- 'Name = ' + self.name + os.linesep + \
- 'Label = ' + self.label + os.linesep + \
- 'Description = ' + self.description + os.linesep + \
- 'Type = ' + self.type + os.linesep + \
- 'Values = ' + str(self.values)
- return r
-
-
-class ImageMetadata(object):
-
- """
- A container for all the metadata attached to an image.
-
- It provides convenient methods for the manipulation of EXIF, IPTC and XMP
- metadata embedded in image files such as JPEG and TIFF files, using Python
- types.
- It also provides access to the thumbnails embedded in an image.
- """
-
- def __init__(self, filename):
- """
- @param filename: absolute path to an image file
- @type filename: C{str} or C{unicode}
- """
- self.filename = filename
- if type(filename) is unicode:
- self.filename = filename.encode('utf-8')
- self._image = None
- self._keys = {'exif': None, 'iptc': None, 'xmp': None}
- self._tags = {'exif': {}, 'iptc': {}, 'xmp': {}}
-
- def _instantiate_image(self, filename):
- # This method is meant to be overridden in unit tests to easily replace
- # the internal image reference by a mock.
- return libexiv2python.Image(filename)
-
- def read(self):
- """
- Read the metadata embedded in the associated image file.
- It is necessary to call this method once before attempting to access
- the metadata (an exception will be raised if trying to access metadata
- before calling this method).
- """
- if self._image is None:
- self._image = self._instantiate_image(self.filename)
- self._image.readMetadata()
-
- def write(self):
- """
- Write the metadata back to the associated image file.
- """
- self._image.writeMetadata()
-
- """List the keys of the available EXIF tags embedded in the image."""
- @property
- def exif_keys(self):
- if self._keys['exif'] is None:
- self._keys['exif'] = self._image.exifKeys()
- return self._keys['exif']
-
- """List the keys of the available IPTC tags embedded in the image."""
- @property
- def iptc_keys(self):
- if self._keys['iptc'] is None:
- self._keys['iptc'] = self._image.iptcKeys()
- return self._keys['iptc']
-
- """List the keys of the available XMP tags embedded in the image."""
- @property
- def xmp_keys(self):
- if self._keys['xmp'] is None:
- self._keys['xmp'] = self._image.xmpKeys()
- return self._keys['xmp']
-
- def _get_exif_tag(self, key):
- # Return the EXIF tag for the given key.
- # Throw a KeyError if the tag doesn't exist.
- try:
- return self._tags['exif'][key]
- except KeyError:
- tag = ExifTag(*self._image.getExifTag(key))
- tag.metadata = self
- self._tags['exif'][key] = tag
- return tag
-
- def _get_iptc_tag(self, key):
- # Return the IPTC tag for the given key.
- # Throw a KeyError if the tag doesn't exist.
- try:
- return self._tags['iptc'][key]
- except KeyError:
- tag = IptcTag(*self._image.getIptcTag(key))
- tag.metadata = self
- self._tags['iptc'][key] = tag
- return tag
-
- def _get_xmp_tag(self, key):
- # Return the XMP tag for the given key.
- # Throw a KeyError if the tag doesn't exist.
- try:
- return self._tags['xmp'][key]
- except KeyError:
- tag = XmpTag(*self._image.getXmpTag(key))
- tag.metadata = self
- self._tags['xmp'][key] = tag
- return tag
-
- def __getitem__(self, key):
- """
- Get a metadata tag for a given key.
-
- @param key: a metadata key in the dotted form C{family.group.tag} where
- family may be C{exif}, C{iptc} or C{xmp}.
- @type key: C{str}
-
- @return: the metadata tag corresponding to the key
- @rtype: a subclass of L{pyexiv2.MetadataTag}
-
- @raise KeyError: if the tag doesn't exist
- """
- family = key.split('.')[0].lower()
- try:
- return getattr(self, '_get_%s_tag' % family)(key)
- except AttributeError:
- raise KeyError(key)
-
- def _set_exif_tag(self, tag):
- # Set an EXIF tag. If the tag already exists, its value is overwritten.
- if type(tag) is not ExifTag:
- raise TypeError('Expecting an ExifTag')
- self._image.setExifTagValue(tag.key, str(tag))
- self._tags['exif'][tag.key] = tag
- tag.metadata = self
-
- def _set_exif_tag_value(self, key, value):
- # Overwrite the tag value for an already existing tag.
- # The tag is already in cache.
- # Warning: this is not meant to be called directly as it doesn't update
- # the internal cache (which would leave the object in an inconsistent
- # state).
- if key not in self.exif_keys:
- raise KeyError('Cannot set the value of an inexistent tag')
- if type(value) is not str:
- raise TypeError('Expecting a string')
- self._image.setExifTagValue(key, value)
-
- def _set_iptc_tag(self, tag):
- # Set an IPTC tag. If the tag already exists, its values are
- # overwritten.
- if type(tag) is not IptcTag:
- raise TypeError('Expecting an IptcTag')
- self._image.setIptcTagValues(tag.key, tag.to_string())
- self._tags['iptc'][tag.key] = tag
- tag.metadata = self
-
- def _set_iptc_tag_values(self, key, values):
- # Overwrite the tag values for an already existing tag.
- # The tag is already in cache.
- # Warning: this is not meant to be called directly as it doesn't update
- # the internal cache (which would leave the object in an inconsistent
- # state).
- # FIXME: this is sub-optimal as it sets all the values regardless of how
- # many of them really changed. Need to implement the same method with an
- # index/range parameter (here and in the C++ wrapper).
- if key not in self.iptc_keys:
- raise KeyError('Cannot set the value of an inexistent tag')
- if type(values) is not list or not \
- reduce(lambda x, y: x and type(y) is str, values, True):
- raise TypeError('Expecting a list of strings')
- self._image.setIptcTagValues(key, values)
-
- def _set_xmp_tag(self, tag):
- # Set an XMP tag. If the tag already exists, its value is overwritten.
- if type(tag) is not XmpTag:
- raise TypeError('Expecting an XmpTag')
- self._image.setXmpTagValue(tag.key, tag.to_string())
- self._tags['xmp'][tag.key] = tag
- tag.metadata = self
-
- def _set_xmp_tag_value(self, key, value):
- # Overwrite the tag value for an already existing tag.
- # The tag is already in cache.
- # Warning: this is not meant to be called directly as it doesn't update
- # the internal cache (which would leave the object in an inconsistent
- # state).
- if key not in self.xmp_keys:
- raise KeyError('Cannot set the value of an inexistent tag')
- if type(value) is not str:
- raise TypeError('Expecting a string')
- self._image.setXmpTagValue(key, value)
-
- def __setitem__(self, key, tag):
- """
- Set a metadata tag for a given key.
- If the tag was previously set, it is overwritten.
-
- @param key: a metadata key in the dotted form C{family.group.tag} where
- family may be C{exif}, C{iptc} or C{xmp}.
- @type key: C{str}
- @param tag: a metadata tag
- @type tag: a subclass of L{pyexiv2.MetadataTag}
-
- @raise KeyError: if the key is invalid
- """
- family = key.split('.')[0].lower()
- try:
- return getattr(self, '_set_%s_tag' % family)(tag)
- except AttributeError:
- raise KeyError(key)
-
- def _delete_exif_tag(self, key):
- # Delete an EXIF tag.
- # Throw a KeyError if the tag doesn't exist.
- if key not in self.exif_keys:
- raise KeyError('Cannot delete an inexistent tag')
- self._image.deleteExifTag(key)
- try:
- del self._tags['exif'][key]
- except KeyError:
- # The tag was not cached.
- pass
-
- def _delete_iptc_tag(self, key):
- # Delete an IPTC tag.
- # Throw a KeyError if the tag doesn't exist.
- if key not in self.iptc_keys:
- raise KeyError('Cannot delete an inexistent tag')
- self._image.deleteIptcTag(key)
- try:
- del self._tags['iptc'][key]
- except KeyError:
- # The tag was not cached.
- pass
-
- def _delete_xmp_tag(self, key):
- # Delete an XMP tag.
- # Throw a KeyError if the tag doesn't exist.
- if key not in self.xmp_keys:
- raise KeyError('Cannot delete an inexistent tag')
- self._image.deleteXmpTag(key)
- try:
- del self._tags['xmp'][key]
- except KeyError:
- # The tag was not cached.
- pass
-
- def __delitem__(self, key):
- """
- Delete a metadata tag for a given key.
-
- @param key: a metadata key in the dotted form C{family.group.tag} where
- family may be C{exif}, C{iptc} or C{xmp}.
- @type key: C{str}
-
- @raise KeyError: if the tag with the given key doesn't exist
- """
- family = key.split('.')[0].lower()
- try:
- return getattr(self, '_delete_%s_tag' % family)(key)
- except AttributeError:
- raise KeyError(key)
-
-
-if __name__ == '__main__':
- import sys
- args = sys.argv
-
- if len(args) != 2:
- print 'Usage: %s image_file' % args[0]
- sys.exit(-1)
-
- metadata = ImageMetadata(args[1])
- metadata.read()
-
- for key in metadata.exif_keys:
- tag = metadata[key]
- print '%-45s%-11s%s' % (key, tag.type, str(tag))
-
- for key in metadata.iptc_keys:
- tag = metadata[key]
- print '%-45s%-11s%s' % (key, tag.type, str(tag))
-
- # TODO: print XMP tags.
-
diff --git a/src/pyexiv2/__init__.py b/src/pyexiv2/__init__.py
new file mode 100644
index 0000000..d4e83cd
--- /dev/null
+++ b/src/pyexiv2/__init__.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+"""
+Manipulation of EXIF, IPTC and XMP metadata and thumbnails embedded in images.
+
+The L{ImageMetadata} class provides read/write access to all the metadata and
+the various thumbnails embedded in an image file such as JPEG and TIFF files.
+
+Metadata is accessed through subclasses of L{MetadataTag} and the tag values are
+conveniently wrapped in python objects.
+A tag containing a date/time information for the image
+(e.g. C{Exif.Photo.DateTimeOriginal}) will be represented by a python
+C{datetime.datetime} object.
+
+This module is a python layer on top of the low-level python binding of the
+C++ library Exiv2, libexiv2python.
+
+A typical use of this binding would be:
+
+>>> import pyexiv2
+>>> metadata = pyexiv2.ImageMetadata('test/smiley.jpg')
+>>> metadata.read()
+>>> print metadata.exif_keys
+['Exif.Image.ImageDescription', 'Exif.Image.XResolution',
+ 'Exif.Image.YResolution', 'Exif.Image.ResolutionUnit', 'Exif.Image.Software',
+ 'Exif.Image.DateTime', 'Exif.Image.Artist', 'Exif.Image.Copyright',
+ 'Exif.Image.ExifTag', 'Exif.Photo.Flash', 'Exif.Photo.PixelXDimension',
+ 'Exif.Photo.PixelYDimension']
+>>> print metadata['Exif.Image.DateTime'].value
+2004-07-13 21:23:44
+>>> import datetime
+>>> metadata['Exif.Image.DateTime'].value = datetime.datetime.today()
+>>> metadata.write()
+"""
+
+import libexiv2python
+
+from pyexiv2.metadata import ImageMetadata
+from pyexiv2.exif import ExifValueError, ExifTag
+from pyexiv2.iptc import IptcValueError, IptcTag
+from pyexiv2.xmp import XmpValueError, XmpTag
+from pyexiv2.utils import FixedOffset, UndefinedToString, StringToUndefined, \
+ Rational, NotifyingList
+
+
+__version__ = (0, 2, 1)
+
+__exiv2_version__ = libexiv2python.__exiv2_version__
+
diff --git a/src/pyexiv2/exif.py b/src/pyexiv2/exif.py
new file mode 100644
index 0000000..aaed440
--- /dev/null
+++ b/src/pyexiv2/exif.py
@@ -0,0 +1,317 @@
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+from pyexiv2.tag import MetadataTag
+from pyexiv2.utils import ListenerInterface, NotifyingList, Rational
+
+import time
+import datetime
+
+
+class ExifValueError(ValueError):
+
+ """
+ Exception raised when failing to parse the value of an EXIF tag.
+
+ @ivar value: the value that fails to be parsed
+ @type value: C{str}
+ @ivar type: the EXIF type of the tag
+ @type type: C{str}
+ """
+
+ def __init__(self, value, type):
+ self.value = value
+ self.type = type
+
+ def __str__(self):
+ return 'Invalid value for EXIF type [%s]: [%s]' % \
+ (self.type, self.value)
+
+
+class ExifTag(MetadataTag, ListenerInterface):
+
+ """
+ An EXIF metadata tag.
+ This tag has an additional field that contains the value of the tag
+ formatted as a human readable string.
+
+ @ivar fvalue: the value of the tag formatted as a human readable string
+ @type fvalue: C{str}
+ """
+
+ # According to the EXIF specification, the only accepted format for an Ascii
+ # value representing a datetime is '%Y:%m:%d %H:%M:%S', but it seems that
+ # others formats can be found in the wild.
+ _datetime_formats = ('%Y:%m:%d %H:%M:%S',
+ '%Y-%m-%d %H:%M:%S',
+ '%Y-%m-%dT%H:%M:%SZ')
+
+ _date_formats = ('%Y:%m:%d',)
+
+ def __init__(self, key, name, label, description, type, value, fvalue):
+ super(ExifTag, self).__init__(key, name, label,
+ description, type, value)
+ self.fvalue = fvalue
+ self._init_values()
+
+ def _init_values(self):
+ # Initial conversion of the raw values to their corresponding python
+ # types.
+ if self.type in ('Short', 'Long', 'SLong', 'Rational', 'SRational'):
+ # May contain multiple values
+ values = self.raw_value.split()
+ if len(values) > 1:
+ # Make values a notifying list
+ values = map(self._convert_to_python, values)
+ self._value = NotifyingList(values)
+ self._value.register_listener(self)
+ return
+ self._value = self._convert_to_python(self.raw_value)
+
+ def _get_value(self):
+ return self._value
+
+ def _set_value(self, new_value):
+ if self.metadata is not None:
+ if isinstance(new_value, (list, tuple)):
+ raw_values = map(self._convert_to_string, new_value)
+ raw_value = ' '.join(raw_values)
+ else:
+ raw_value = self._convert_to_string(new_value)
+ self.metadata._set_exif_tag_value(self.key, raw_value)
+
+ if isinstance(self._value, NotifyingList):
+ self._value.unregister_listener(self)
+
+ if isinstance(new_value, NotifyingList):
+ # Already a notifying list
+ self._value = new_value
+ self._value.register_listener(self)
+ elif isinstance(new_value, (list, tuple)):
+ # Make the values a notifying list
+ self._value = NotifyingList(new_value)
+ self._value.register_listener(self)
+ else:
+ # Single value
+ self._value = new_value
+
+ def _del_value(self):
+ if self.metadata is not None:
+ self.metadata._delete_exif_tag(self.key)
+
+ if isinstance(self._value, NotifyingList):
+ self._value.unregister_listener(self)
+
+ del self._value
+
+ """the value of the tag converted to its corresponding python type"""
+ value = property(fget=_get_value, fset=_set_value, fdel=_del_value,
+ doc=None)
+
+ def contents_changed(self):
+ """
+ Implementation of the L{ListenerInterface}.
+ React on changes to the list of values of the tag.
+ """
+ # self._value is a list of value and its contents changed.
+ self._set_value(self._value)
+
+ def _convert_to_python(self, value):
+ """
+ Convert one raw value to its corresponding python type.
+
+ @param value: the raw value to be converted
+ @type value: C{str}
+
+ @return: the value converted to its corresponding python type
+ @rtype: depends on C{self.type} (DOCME)
+
+ @raise ExifValueError: if the conversion fails
+ """
+ if self.type == 'Ascii':
+ # The value may contain a Datetime
+ for format in self._datetime_formats:
+ try:
+ t = time.strptime(value, format)
+ except ValueError:
+ continue
+ else:
+ return datetime.datetime(*t[:6])
+ # Or a Date (e.g. Exif.GPSInfo.GPSDateStamp)
+ for format in self._date_formats:
+ try:
+ t = time.strptime(value, format)
+ except ValueError:
+ continue
+ else:
+ return datetime.date(*t[:3])
+ # Default to string.
+ # There is currently no charset conversion.
+ # TODO: guess the encoding and decode accordingly into unicode
+ # where relevant.
+ return value
+
+ elif self.type == 'Byte':
+ return value
+
+ elif self.type == 'Short':
+ try:
+ return int(value)
+ except ValueError:
+ raise ExifValueError(value, self.type)
+
+ elif self.type in ('Long', 'SLong'):
+ try:
+ return long(value)
+ except ValueError:
+ raise ExifValueError(value, self.type)
+
+ elif self.type in ('Rational', 'SRational'):
+ try:
+ r = Rational.from_string(value)
+ except (ValueError, ZeroDivisionError):
+ raise ExifValueError(value, self.type)
+ else:
+ if self.type == 'Rational' and r.numerator < 0:
+ raise ExifValueError(value, self.type)
+ return r
+
+ elif self.type == 'Undefined':
+ # There is currently no charset conversion.
+ # TODO: guess the encoding and decode accordingly into unicode
+ # where relevant.
+ return self.fvalue
+
+ raise ExifValueError(value, self.type)
+
+ def _convert_to_string(self, value):
+ """
+ Convert one value to its corresponding string representation, suitable
+ to pass to libexiv2.
+
+ @param value: the value to be converted
+ @type value: depends on C{self.type} (DOCME)
+
+ @return: the value converted to its corresponding string representation
+ @rtype: C{str}
+
+ @raise ExifValueError: if the conversion fails
+ """
+ if self.type == 'Ascii':
+ if type(value) is datetime.datetime:
+ return value.strftime(self._datetime_formats[0])
+ elif type(value) is datetime.date:
+ if self.key == 'Exif.GPSInfo.GPSDateStamp':
+ # Special case
+ return value.strftime(self._date_formats[0])
+ else:
+ return value.strftime('%s 00:00:00' % self._date_formats[0])
+ elif type(value) is unicode:
+ try:
+ return value.encode('utf-8')
+ except UnicodeEncodeError:
+ raise ExifValueError(value, self.type)
+ elif type(value) is str:
+ return value
+ else:
+ raise ExifValueError(value, self.type)
+
+ elif self.type == 'Byte':
+ if type(value) is unicode:
+ try:
+ return value.encode('utf-8')
+ except UnicodeEncodeError:
+ raise ExifValueError(value, self.type)
+ elif type(value) is str:
+ return value
+ else:
+ raise ExifValueError(value, self.type)
+
+ elif self.type == 'Short':
+ if type(value) is int and value >= 0:
+ return str(value)
+ else:
+ raise ExifValueError(value, self.type)
+
+ elif self.type == 'Long':
+ if type(value) in (int, long) and value >= 0:
+ return str(value)
+ else:
+ raise ExifValueError(value, self.type)
+
+ elif self.type == 'SLong':
+ if type(value) in (int, long):
+ return str(value)
+ else:
+ raise ExifValueError(value, self.type)
+
+ elif self.type == 'Rational':
+ if type(value) is Rational and value.numerator >= 0:
+ return str(value)
+ else:
+ raise ExifValueError(value, self.type)
+
+ elif self.type == 'SRational':
+ if type(value) is Rational:
+ return str(value)
+ else:
+ raise ExifValueError(value, self.type)
+
+ elif self.type == 'Undefined':
+ if type(value) is unicode:
+ try:
+ return value.encode('utf-8')
+ except UnicodeEncodeError:
+ raise ExifValueError(value, self.type)
+ elif type(value) is str:
+ return value
+ else:
+ raise ExifValueError(value, self.type)
+
+ raise ExifValueError(value, self.type)
+
+ def __str__(self):
+ """
+ Return a string representation of the value of the EXIF tag suitable to
+ pass to libexiv2 to set it.
+
+ @rtype: C{str}
+ """
+ return self._convert_to_string(self.value)
+
+ def __repr__(self):
+ """
+ Return a string representation of the EXIF tag for debugging purposes.
+
+ @rtype: C{str}
+ """
+ left = '%s [%s]' % (self.key, self.type)
+ if self.type == 'Undefined' and len(self._value) > 100:
+ right = '(Binary value suppressed)'
+ else:
+ right = self.fvalue
+ return '<%s = %s>' % (left, right)
+
diff --git a/src/pyexiv2/iptc.py b/src/pyexiv2/iptc.py
new file mode 100644
index 0000000..4ba6b21
--- /dev/null
+++ b/src/pyexiv2/iptc.py
@@ -0,0 +1,254 @@
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+from pyexiv2.tag import MetadataTag
+from pyexiv2.utils import ListenerInterface, NotifyingList, FixedOffset
+
+import time
+import datetime
+import re
+
+
+class IptcValueError(ValueError):
+
+ """
+ Exception raised when failing to parse the value of an IPTC tag.
+
+ @ivar value: the value that fails to be parsed
+ @type value: C{str}
+ @ivar type: the IPTC type of the tag
+ @type type: C{str}
+ """
+
+ def __init__(self, value, type):
+ self.value = value
+ self.type = type
+
+ def __str__(self):
+ return 'Invalid value for IPTC type [%s]: [%s]' % \
+ (self.type, self.value)
+
+
+class IptcTag(MetadataTag, ListenerInterface):
+
+ """
+ An IPTC metadata tag.
+ This tag can have several values (tags that have the repeatable property).
+ """
+
+ # strptime is not flexible enough to handle all valid Time formats, we use a
+ # custom regular expression
+ _time_zone_re = r'(?P<sign>\+|-)(?P<ohours>\d{2}):(?P<ominutes>\d{2})'
+ _time_re = re.compile(r'(?P<hours>\d{2}):(?P<minutes>\d{2}):(?P<seconds>\d{2})(?P<tzd>%s)' % _time_zone_re)
+
+ def __init__(self, key, name, label, description, type, values):
+ super(IptcTag, self).__init__(key, name, label,
+ description, type, values)
+ self._init_values()
+
+ def _init_values(self):
+ # Initial conversion of the raw values to their corresponding python
+ # types.
+ values = map(self._convert_to_python, self.raw_value)
+ # Make values a notifying list
+ self._values = NotifyingList(values)
+ self._values.register_listener(self)
+
+ def _get_values(self):
+ return self._values
+
+ def _set_values(self, new_values):
+ if self.metadata is not None:
+ raw_values = map(self._convert_to_string, new_values)
+ self.metadata._set_iptc_tag_values(self.key, raw_values)
+ # Make values a notifying list if needed
+ if isinstance(new_values, NotifyingList):
+ self._values = new_values
+ else:
+ self._values = NotifyingList(new_values)
+
+ def _del_values(self):
+ if self.metadata is not None:
+ self.metadata._delete_iptc_tag(self.key)
+ del self._values
+
+ """the list of values of the tag converted to their corresponding python
+ type"""
+ values = property(fget=_get_values, fset=_set_values, fdel=_del_values,
+ doc=None)
+
+ def contents_changed(self):
+ """
+ Implementation of the L{ListenerInterface}.
+ React on changes to the list of values of the tag.
+ """
+ # The contents of self._values was changed.
+ # The following is a quick, non optimal solution.
+ self._set_values(self._values)
+
+ def _convert_to_python(self, value):
+ """
+ Convert one raw value to its corresponding python type.
+
+ @param value: the raw value to be converted
+ @type value: C{str}
+
+ @return: the value converted to its corresponding python type
+ @rtype: depends on C{self.type} (DOCME)
+
+ @raise IptcValueError: if the conversion fails
+ """
+ if self.type == 'Short':
+ try:
+ return int(value)
+ except ValueError:
+ raise IptcValueError(value, self.type)
+
+ elif self.type == 'String':
+ # There is currently no charset conversion.
+ # TODO: guess the encoding and decode accordingly into unicode
+ # where relevant.
+ return value
+
+ elif self.type == 'Date':
+ # According to the IPTC specification, the format for a string field
+ # representing a date is '%Y%m%d'. However, the string returned by
+ # exiv2 using method DateValue::toString() is formatted using
+ # pattern '%Y-%m-%d'.
+ format = '%Y-%m-%d'
+ try:
+ t = time.strptime(value, format)
+ return datetime.date(*t[:3])
+ except ValueError:
+ raise IptcValueError(value, self.type)
+
+ elif self.type == 'Time':
+ # According to the IPTC specification, the format for a string field
+ # representing a time is '%H%M%S±%H%M'. However, the string returned
+ # by exiv2 using method TimeValue::toString() is formatted using
+ # pattern '%H:%M:%S±%H:%M'.
+ match = IptcTag._time_re.match(value)
+ if match is None:
+ raise IptcValueError(value, self.type)
+ gd = match.groupdict()
+ try:
+ tzinfo = FixedOffset(gd['sign'], int(gd['ohours']),
+ int(gd['ominutes']))
+ except TypeError:
+ raise IptcValueError(value, self.type)
+ try:
+ return datetime.time(int(gd['hours']), int(gd['minutes']),
+ int(gd['seconds']), tzinfo=tzinfo)
+ except (TypeError, ValueError):
+ raise IptcValueError(value, self.type)
+
+ elif self.type == 'Undefined':
+ # Binary data, return it unmodified
+ return value
+
+ raise IptcValueError(value, self.type)
+
+ def _convert_to_string(self, value):
+ """
+ Convert one value to its corresponding string representation, suitable
+ to pass to libexiv2.
+
+ @param value: the value to be converted
+ @type value: depends on C{self.type} (DOCME)
+
+ @return: the value converted to its corresponding string representation
+ @rtype: C{str}
+
+ @raise IptcValueError: if the conversion fails
+ """
+ if self.type == 'Short':
+ if type(value) is int:
+ return str(value)
+ else:
+ raise IptcValueError(value, self.type)
+
+ elif self.type == 'String':
+ if type(value) is unicode:
+ try:
+ return value.encode('utf-8')
+ except UnicodeEncodeError:
+ raise IptcValueError(value, self.type)
+ elif type(value) is str:
+ return value
+ else:
+ raise IptcValueError(value, self.type)
+
+ elif self.type == 'Date':
+ if type(value) in (datetime.date, datetime.datetime):
+ # ISO 8601 date format
+ return value.strftime('%Y%m%d')
+ else:
+ raise IptcValueError(value, self.type)
+
+ elif self.type == 'Time':
+ if type(value) in (datetime.time, datetime.datetime):
+ r = value.strftime('%H%M%S')
+ if value.tzinfo is not None:
+ r += value.strftime('%z')
+ else:
+ r += '+0000'
+ return r
+ else:
+ raise IptcValueError(value, self.type)
+
+ elif self.type == 'Undefined':
+ if type(value) is str:
+ return value
+ else:
+ raise IptcValueError(value, self.type)
+
+ raise IptcValueError(value, self.type)
+
+ def to_string(self):
+ """
+ Return a list of string representations of the values of the IPTC tag
+ suitable to pass to libexiv2 to set it.
+
+ @rtype: C{list} of C{str}
+ """
+ return map(self._convert_to_string, self.values)
+
+ def __str__(self):
+ """
+ Return a string representation of the list of values of the IPTC tag.
+
+ @rtype: C{str}
+ """
+ return ', '.join(self.to_string())
+
+ def __repr__(self):
+ """
+ Return a string representation of the IPTC tag for debugging purposes.
+
+ @rtype: C{str}
+ """
+ return '<%s [%s] = %s>' % (self.key, self.type, str(self))
+
diff --git a/src/pyexiv2/main.py b/src/pyexiv2/main.py
new file mode 100755
index 0000000..e785e53
--- /dev/null
+++ b/src/pyexiv2/main.py
@@ -0,0 +1,53 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+import sys
+
+from pyexiv2.metadata import ImageMetadata
+
+
+if __name__ == '__main__':
+ import sys
+ args = sys.argv
+
+ if len(args) != 2:
+ print 'Usage: %s image_file' % args[0]
+ sys.exit(-1)
+
+ metadata = ImageMetadata(args[1])
+ metadata.read()
+
+ for key in metadata.exif_keys:
+ tag = metadata[key]
+ print '%-45s%-11s%s' % (key, tag.type, str(tag))
+
+ for key in metadata.iptc_keys:
+ tag = metadata[key]
+ print '%-45s%-11s%s' % (key, tag.type, str(tag))
+
+ # TODO: print XMP tags.
+
diff --git a/src/pyexiv2/metadata.py b/src/pyexiv2/metadata.py
new file mode 100644
index 0000000..fce964b
--- /dev/null
+++ b/src/pyexiv2/metadata.py
@@ -0,0 +1,287 @@
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+import libexiv2python
+
+from pyexiv2.exif import ExifTag
+from pyexiv2.iptc import IptcTag
+from pyexiv2.xmp import XmpTag
+
+
+class ImageMetadata(object):
+
+ """
+ A container for all the metadata attached to an image.
+
+ It provides convenient methods for the manipulation of EXIF, IPTC and XMP
+ metadata embedded in image files such as JPEG and TIFF files, using Python
+ types.
+ It also provides access to the thumbnails embedded in an image.
+ """
+
+ def __init__(self, filename):
+ """
+ @param filename: absolute path to an image file
+ @type filename: C{str} or C{unicode}
+ """
+ self.filename = filename
+ if type(filename) is unicode:
+ self.filename = filename.encode('utf-8')
+ self._image = None
+ self._keys = {'exif': None, 'iptc': None, 'xmp': None}
+ self._tags = {'exif': {}, 'iptc': {}, 'xmp': {}}
+
+ def _instantiate_image(self, filename):
+ # This method is meant to be overridden in unit tests to easily replace
+ # the internal image reference by a mock.
+ return libexiv2python.Image(filename)
+
+ def read(self):
+ """
+ Read the metadata embedded in the associated image file.
+ It is necessary to call this method once before attempting to access
+ the metadata (an exception will be raised if trying to access metadata
+ before calling this method).
+ """
+ if self._image is None:
+ self._image = self._instantiate_image(self.filename)
+ self._image.readMetadata()
+
+ def write(self):
+ """
+ Write the metadata back to the associated image file.
+ """
+ self._image.writeMetadata()
+
+ """List the keys of the available EXIF tags embedded in the image."""
+ @property
+ def exif_keys(self):
+ if self._keys['exif'] is None:
+ self._keys['exif'] = self._image.exifKeys()
+ return self._keys['exif']
+
+ """List the keys of the available IPTC tags embedded in the image."""
+ @property
+ def iptc_keys(self):
+ if self._keys['iptc'] is None:
+ self._keys['iptc'] = self._image.iptcKeys()
+ return self._keys['iptc']
+
+ """List the keys of the available XMP tags embedded in the image."""
+ @property
+ def xmp_keys(self):
+ if self._keys['xmp'] is None:
+ self._keys['xmp'] = self._image.xmpKeys()
+ return self._keys['xmp']
+
+ def _get_exif_tag(self, key):
+ # Return the EXIF tag for the given key.
+ # Throw a KeyError if the tag doesn't exist.
+ try:
+ return self._tags['exif'][key]
+ except KeyError:
+ tag = ExifTag(*self._image.getExifTag(key))
+ tag.metadata = self
+ self._tags['exif'][key] = tag
+ return tag
+
+ def _get_iptc_tag(self, key):
+ # Return the IPTC tag for the given key.
+ # Throw a KeyError if the tag doesn't exist.
+ try:
+ return self._tags['iptc'][key]
+ except KeyError:
+ tag = IptcTag(*self._image.getIptcTag(key))
+ tag.metadata = self
+ self._tags['iptc'][key] = tag
+ return tag
+
+ def _get_xmp_tag(self, key):
+ # Return the XMP tag for the given key.
+ # Throw a KeyError if the tag doesn't exist.
+ try:
+ return self._tags['xmp'][key]
+ except KeyError:
+ tag = XmpTag(*self._image.getXmpTag(key))
+ tag.metadata = self
+ self._tags['xmp'][key] = tag
+ return tag
+
+ def __getitem__(self, key):
+ """
+ Get a metadata tag for a given key.
+
+ @param key: a metadata key in the dotted form C{family.group.tag} where
+ family may be C{exif}, C{iptc} or C{xmp}.
+ @type key: C{str}
+
+ @return: the metadata tag corresponding to the key
+ @rtype: a subclass of L{pyexiv2.MetadataTag}
+
+ @raise KeyError: if the tag doesn't exist
+ """
+ family = key.split('.')[0].lower()
+ try:
+ return getattr(self, '_get_%s_tag' % family)(key)
+ except AttributeError:
+ raise KeyError(key)
+
+ def _set_exif_tag(self, tag):
+ # Set an EXIF tag. If the tag already exists, its value is overwritten.
+ if type(tag) is not ExifTag:
+ raise TypeError('Expecting an ExifTag')
+ self._image.setExifTagValue(tag.key, str(tag))
+ self._tags['exif'][tag.key] = tag
+ tag.metadata = self
+
+ def _set_exif_tag_value(self, key, value):
+ # Overwrite the tag value for an already existing tag.
+ # The tag is already in cache.
+ # Warning: this is not meant to be called directly as it doesn't update
+ # the internal cache (which would leave the object in an inconsistent
+ # state).
+ if key not in self.exif_keys:
+ raise KeyError('Cannot set the value of an inexistent tag')
+ if type(value) is not str:
+ raise TypeError('Expecting a string')
+ self._image.setExifTagValue(key, value)
+
+ def _set_iptc_tag(self, tag):
+ # Set an IPTC tag. If the tag already exists, its values are
+ # overwritten.
+ if type(tag) is not IptcTag:
+ raise TypeError('Expecting an IptcTag')
+ self._image.setIptcTagValues(tag.key, tag.to_string())
+ self._tags['iptc'][tag.key] = tag
+ tag.metadata = self
+
+ def _set_iptc_tag_values(self, key, values):
+ # Overwrite the tag values for an already existing tag.
+ # The tag is already in cache.
+ # Warning: this is not meant to be called directly as it doesn't update
+ # the internal cache (which would leave the object in an inconsistent
+ # state).
+ # FIXME: this is sub-optimal as it sets all the values regardless of how
+ # many of them really changed. Need to implement the same method with an
+ # index/range parameter (here and in the C++ wrapper).
+ if key not in self.iptc_keys:
+ raise KeyError('Cannot set the value of an inexistent tag')
+ if type(values) is not list or not \
+ reduce(lambda x, y: x and type(y) is str, values, True):
+ raise TypeError('Expecting a list of strings')
+ self._image.setIptcTagValues(key, values)
+
+ def _set_xmp_tag(self, tag):
+ # Set an XMP tag. If the tag already exists, its value is overwritten.
+ if type(tag) is not XmpTag:
+ raise TypeError('Expecting an XmpTag')
+ self._image.setXmpTagValue(tag.key, tag.to_string())
+ self._tags['xmp'][tag.key] = tag
+ tag.metadata = self
+
+ def _set_xmp_tag_value(self, key, value):
+ # Overwrite the tag value for an already existing tag.
+ # The tag is already in cache.
+ # Warning: this is not meant to be called directly as it doesn't update
+ # the internal cache (which would leave the object in an inconsistent
+ # state).
+ if key not in self.xmp_keys:
+ raise KeyError('Cannot set the value of an inexistent tag')
+ if type(value) is not str:
+ raise TypeError('Expecting a string')
+ self._image.setXmpTagValue(key, value)
+
+ def __setitem__(self, key, tag):
+ """
+ Set a metadata tag for a given key.
+ If the tag was previously set, it is overwritten.
+
+ @param key: a metadata key in the dotted form C{family.group.tag} where
+ family may be C{exif}, C{iptc} or C{xmp}.
+ @type key: C{str}
+ @param tag: a metadata tag
+ @type tag: a subclass of L{pyexiv2.MetadataTag}
+
+ @raise KeyError: if the key is invalid
+ """
+ family = key.split('.')[0].lower()
+ try:
+ return getattr(self, '_set_%s_tag' % family)(tag)
+ except AttributeError:
+ raise KeyError(key)
+
+ def _delete_exif_tag(self, key):
+ # Delete an EXIF tag.
+ # Throw a KeyError if the tag doesn't exist.
+ if key not in self.exif_keys:
+ raise KeyError('Cannot delete an inexistent tag')
+ self._image.deleteExifTag(key)
+ try:
+ del self._tags['exif'][key]
+ except KeyError:
+ # The tag was not cached.
+ pass
+
+ def _delete_iptc_tag(self, key):
+ # Delete an IPTC tag.
+ # Throw a KeyError if the tag doesn't exist.
+ if key not in self.iptc_keys:
+ raise KeyError('Cannot delete an inexistent tag')
+ self._image.deleteIptcTag(key)
+ try:
+ del self._tags['iptc'][key]
+ except KeyError:
+ # The tag was not cached.
+ pass
+
+ def _delete_xmp_tag(self, key):
+ # Delete an XMP tag.
+ # Throw a KeyError if the tag doesn't exist.
+ if key not in self.xmp_keys:
+ raise KeyError('Cannot delete an inexistent tag')
+ self._image.deleteXmpTag(key)
+ try:
+ del self._tags['xmp'][key]
+ except KeyError:
+ # The tag was not cached.
+ pass
+
+ def __delitem__(self, key):
+ """
+ Delete a metadata tag for a given key.
+
+ @param key: a metadata key in the dotted form C{family.group.tag} where
+ family may be C{exif}, C{iptc} or C{xmp}.
+ @type key: C{str}
+
+ @raise KeyError: if the tag with the given key doesn't exist
+ """
+ family = key.split('.')[0].lower()
+ try:
+ return getattr(self, '_delete_%s_tag' % family)(key)
+ except AttributeError:
+ raise KeyError(key)
+
diff --git a/src/pyexiv2/tag.py b/src/pyexiv2/tag.py
new file mode 100644
index 0000000..6776a71
--- /dev/null
+++ b/src/pyexiv2/tag.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+
+class MetadataTag(object):
+
+ """
+ A generic metadata tag.
+ It is meant to be subclassed to implement specific tag types behaviours.
+
+ @ivar key: a unique key that identifies the tag
+ @type key: C{str}
+ @ivar name: the short internal name that identifies the tag within
+ its scope
+ @type name: C{str}
+ @ivar label: a human readable label for the tag
+ @type label: C{str}
+ @ivar description: a description of the function of the tag
+ @type description: C{str}
+ @ivar type: the data type name
+ @type type: C{str}
+ @ivar raw_value: the raw value of the tag as provided by exiv2
+ @type raw_value: C{str}
+ @ivar metadata: reference to the containing metadata if any
+ @type metadata: L{pyexiv2.ImageMetadata}
+ """
+
+ def __init__(self, key, name, label, description, type, value):
+ self.key = key
+ self.name = name
+ # FIXME: all attributes that may contain a localized string should be
+ # unicode.
+ self.label = label
+ self.description = description
+ self.type = type
+ self.raw_value = value
+ self.metadata = None
+
+ def __str__(self):
+ """
+ Return a string representation of the value of the tag suitable to pass
+ to libexiv2 to set it.
+
+ @rtype: C{str}
+ """
+ return self.raw_value
+
+ def __repr__(self):
+ """
+ Return a string representation of the tag for debugging purposes.
+
+ @rtype: C{str}
+ """
+ return '<%s [%s] = %s>' % (self.key, self.type, self.raw_value)
diff --git a/src/pyexiv2/utils.py b/src/pyexiv2/utils.py
new file mode 100644
index 0000000..e87d1e4
--- /dev/null
+++ b/src/pyexiv2/utils.py
@@ -0,0 +1,361 @@
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+"""
+Utilitary classes and functions.
+"""
+
+import datetime
+import re
+
+
+class FixedOffset(datetime.tzinfo):
+
+ """
+ Fixed positive or negative offset from a local time east from UTC.
+
+ @ivar sign: the sign of the offset ('+' or '-')
+ @type sign: C{str}
+ @ivar hours: the absolute number of hours of the offset
+ @type hours: C{int}
+ @ivar minutes: the absolute number of minutes of the offset
+ @type minutes: C{int}
+
+ """
+
+ def __init__(self, sign='+', hours=0, minutes=0):
+ """
+ Initialize an offset from a sign ('+' or '-') and an absolute value
+ expressed in hours and minutes.
+ No check on the validity of those values is performed, it is the
+ responsibility of the caller to pass valid values.
+
+ @param sign: the sign of the offset ('+' or '-')
+ @type sign: C{str}
+ @param hours: an absolute number of hours
+ @type hours: C{int}
+ @param minutes: an absolute number of minutes
+ @type minutes: C{int}
+ """
+ self.sign = sign
+ self.hours = hours
+ self.minutes = minutes
+
+ def utcoffset(self, dt):
+ """
+ Return offset of local time from UTC, in minutes east of UTC.
+ If local time is west of UTC, this value will be negative.
+
+ @param dt: the local time
+ @type dt: C{datetime.time}
+
+ @return: a whole number of minutes in the range -1439 to 1439 inclusive
+ @rtype: C{datetime.timedelta}
+ """
+ total = self.hours * 60 + self.minutes
+ if self.sign == '-':
+ total = -total
+ return datetime.timedelta(minutes = total)
+
+ def dst(self, dt):
+ """
+ Return the daylight saving time (DST) adjustment.
+ In this implementation, it is always nil.
+
+ @param dt: the local time
+ @type dt: C{datetime.time}
+
+ @return: the DST adjustment (always nil)
+ @rtype: C{datetime.timedelta}
+ """
+ return datetime.timedelta(0)
+
+ def tzname(self, dt):
+ """
+ Return a string representation of the offset in the format '±%H:%M'.
+ If the offset is nil, the representation is, by convention, 'Z'.
+
+ @param dt: the local time
+ @type dt: C{datetime.time}
+
+ @return: a human-readable representation of the offset
+ @rtype: C{str}
+ """
+ if self.hours == 0 and self.minutes == 0:
+ return 'Z'
+ else:
+ return '%s%02d:%02d' % (self.sign, self.hours, self.minutes)
+
+ def __equal__(self, other):
+ """
+ Test equality between this offset and another offset.
+
+ @param other: another offset
+ @type other: L{FixedOffset}
+
+ @return: C{True} if the offset are equal, C{False} otherwise
+ @rtype: C{bool}
+ """
+ return (self.sign == other.sign) and (self.hours == other.hours) and \
+ (self.minutes == other.minutes)
+
+
+def UndefinedToString(undefined):
+ """
+ Convert an undefined string into its corresponding sequence of bytes.
+ The undefined string must contain the ascii codes of a sequence of bytes,
+ each followed by a blank space (e.g. "48 50 50 49 " will be converted into
+ "0221").
+ The Undefined type is part of the EXIF specification.
+
+ @param undefined: an undefined string
+ @type undefined: C{str}
+
+ @return: the corresponding decoded string
+ @rtype: C{str}
+ """
+ return ''.join(map(lambda x: chr(int(x)), undefined.rstrip().split(' ')))
+
+
+def StringToUndefined(sequence):
+ """
+ Convert a string into its undefined form.
+ The undefined form contains a sequence of ascii codes, each followed by a
+ blank space (e.g. "0221" will be converted into "48 50 50 49 ").
+ The Undefined type is part of the EXIF specification.
+
+ @param sequence: a sequence of bytes
+ @type sequence: C{str}
+
+ @return: the corresponding undefined string
+ @rtype: C{str}
+ """
+ return ''.join(map(lambda x: '%d ' % ord(x), sequence))
+
+
+class Rational(object):
+
+ """
+ A class representing a rational number.
+
+ Its numerator and denominator are read-only properties.
+ """
+
+ _format_re = re.compile(r'(?P<numerator>-?\d+)/(?P<denominator>\d+)')
+
+ def __init__(self, numerator, denominator):
+ """
+ Constructor.
+
+ @param numerator: the numerator
+ @type numerator: C{long}
+ @param denominator: the denominator
+ @type denominator: C{long}
+
+ @raise ZeroDivisionError: if the denominator equals zero
+ """
+ if denominator == 0:
+ msg = 'Denominator of a rational number cannot be zero.'
+ raise ZeroDivisionError(msg)
+ self._numerator = long(numerator)
+ self._denominator = long(denominator)
+
+ @property
+ def numerator(self):
+ return self._numerator
+
+ @property
+ def denominator(self):
+ return self._denominator
+
+ @staticmethod
+ def from_string(string):
+ """
+ Instantiate a Rational from a string formatted as
+ C{[-]numerator/denominator}.
+
+ @param string: a string representation of a rational number
+ @type string: C{str}
+
+ @return: the rational number parsed
+ @rtype: L{Rational}
+
+ @raise ValueError: if the format of the string is invalid
+ """
+ match = Rational._format_re.match(string)
+ if match is None:
+ raise ValueError('Invalid format for a rational: %s' % string)
+ gd = match.groupdict()
+ return Rational(long(gd['numerator']), long(gd['denominator']))
+
+ def to_float(self):
+ """
+ @return: a floating point number approximation of the value
+ @rtype: C{float}
+ """
+ return float(self._numerator) / self._denominator
+
+ def __eq__(self, other):
+ """
+ Compare two rational numbers for equality.
+
+ Two rational numbers are equal if their reduced forms are equal.
+
+ @param other: the rational number to compare to self for equality
+ @type other: L{Rational}
+
+ @return: C{True} if equal, C{False} otherwise
+ @rtype: C{bool}
+ """
+ return (self._numerator * other._denominator) == \
+ (other._numerator * self._denominator)
+
+ def __str__(self):
+ """
+ Return a string representation of the rational number.
+ """
+ return '%d/%d' % (self._numerator, self._denominator)
+
+
+class ListenerInterface(object):
+
+ """
+ Interface that an object that wants to listen to changes on another object
+ should implement.
+ """
+
+ def contents_changed(self):
+ """
+ React on changes on the object observed.
+ Override to implement specific behaviours.
+ """
+ raise NotImplementedError()
+
+
+class NotifyingList(list):
+
+ """
+ A simplistic implementation of a notifying list.
+ Any changes to the list are notified in a synchronous way to all previously
+ registered listeners. A listener must implement the L{ListenerInterface}.
+ """
+
+ # Useful documentation:
+ # file:///usr/share/doc/python2.5/html/lib/typesseq-mutable.html
+ # http://docs.python.org/reference/datamodel.html#additional-methods-for-emulation-of-sequence-types
+
+ def __init__(self, items=[]):
+ super(NotifyingList, self).__init__(items)
+ self._listeners = set()
+
+ def register_listener(self, listener):
+ """
+ Register a new listener to be notified of changes.
+
+ @param listener: any object that listens for changes
+ @type listener: any class that implements the L{ListenerInterface}
+ """
+ self._listeners.add(listener)
+
+ def unregister_listener(self, listener):
+ """
+ Unregister a previously registered listener.
+
+ @param listener: a previously registered listener
+ @type listener: any class that implements the L{ListenerInterface}
+
+ @raise KeyError: if the listener was not previously registered
+ """
+ self._listeners.remove(listener)
+
+ def _notify_listeners(self, *args):
+ for listener in self._listeners:
+ listener.contents_changed(*args)
+
+ def __setitem__(self, index, item):
+ # FIXME: support slice arguments for extended slicing
+ super(NotifyingList, self).__setitem__(index, item)
+ self._notify_listeners()
+
+ def __delitem__(self, index):
+ # FIXME: support slice arguments for extended slicing
+ super(NotifyingList, self).__delitem__(index)
+ self._notify_listeners()
+
+ def append(self, item):
+ super(NotifyingList, self).append(item)
+ self._notify_listeners()
+
+ def extend(self, items):
+ super(NotifyingList, self).extend(items)
+ self._notify_listeners()
+
+ def insert(self, index, item):
+ super(NotifyingList, self).insert(index, item)
+ self._notify_listeners()
+
+ def pop(self, index=None):
+ if index is None:
+ item = super(NotifyingList, self).pop()
+ else:
+ item = super(NotifyingList, self).pop(index)
+ self._notify_listeners()
+ return item
+
+ def remove(self, item):
+ super(NotifyingList, self).remove(item)
+ self._notify_listeners()
+
+ def reverse(self):
+ super(NotifyingList, self).reverse()
+ self._notify_listeners()
+
+ def sort(self, cmp=None, key=None, reverse=False):
+ super(NotifyingList, self).sort(cmp, key, reverse)
+ self._notify_listeners()
+
+ def __iadd__(self, other):
+ self = super(NotifyingList, self).__iadd__(other)
+ self._notify_listeners()
+ return self
+
+ def __imul__(self, coefficient):
+ self = super(NotifyingList, self).__imul__(coefficient)
+ self._notify_listeners()
+ return self
+
+ def __setslice__(self, i, j, items):
+ # __setslice__ is deprecated but needs to be overridden for completeness
+ super(NotifyingList, self).__setslice__(i, j, items)
+ self._notify_listeners()
+
+ def __delslice__(self, i, j):
+ # __delslice__ is deprecated but needs to be overridden for completeness
+ deleted = self[i:j]
+ super(NotifyingList, self).__delslice__(i, j)
+ if deleted:
+ self._notify_listeners()
+
diff --git a/src/pyexiv2/xmp.py b/src/pyexiv2/xmp.py
new file mode 100644
index 0000000..4e8cb1b
--- /dev/null
+++ b/src/pyexiv2/xmp.py
@@ -0,0 +1,371 @@
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+from pyexiv2.tag import MetadataTag
+from pyexiv2.utils import ListenerInterface, NotifyingList, FixedOffset
+
+import datetime
+import re
+
+
+class XmpValueError(ValueError):
+
+ """
+ Exception raised when failing to parse the value of an XMP tag.
+
+ @ivar value: the value that fails to be parsed
+ @type value: C{str}
+ @ivar type: the XMP type of the tag
+ @type type: C{str}
+ """
+ def __init__(self, value, type):
+ self.value = value
+ self.type = type
+
+ def __str__(self):
+ return 'Invalid value for XMP type [%s]: [%s]' % \
+ (self.type, self.value)
+
+
+class XmpTag(MetadataTag):
+
+ """
+ An XMP metadata tag.
+ """
+
+ # strptime is not flexible enough to handle all valid Date formats, we use a
+ # custom regular expression
+ _time_zone_re = r'Z|((?P<sign>\+|-)(?P<ohours>\d{2}):(?P<ominutes>\d{2}))'
+ _time_re = r'(?P<hours>\d{2})(:(?P<minutes>\d{2})(:(?P<seconds>\d{2})(.(?P<decimal>\d+))?)?(?P<tzd>%s))?' % _time_zone_re
+ _date_re = re.compile(r'(?P<year>\d{4})(-(?P<month>\d{2})(-(?P<day>\d{2})(T(?P<time>%s))?)?)?' % _time_re)
+
+ def __init__(self, key, name, label, description, type, value):
+ super(XmpTag, self).__init__(key, name, label, description, type, value)
+ self._value = XmpTag._convert_to_python(value, type)
+
+ def _get_value(self):
+ return self._value
+
+ def _set_value(self, new_value):
+ if self.metadata is not None:
+ raw_value = XmpTag._convert_to_string(new_value, self.type)
+ self.metadata._set_xmp_tag_value(self.key, raw_value)
+ self._value = new_value
+
+ def _del_value(self):
+ if self.metadata is not None:
+ self.metadata._delete_xmp_tag(self.key)
+ del self._value
+
+ """the value of the tag converted to its corresponding python type"""
+ value = property(fget=_get_value, fset=_set_value, fdel=_del_value,
+ doc=None)
+
+ @staticmethod
+ def _convert_to_python(value, xtype):
+ """
+ Convert a raw value to its corresponding python type.
+
+ @param value: the raw value to be converted
+ @type value: C{str}
+ @param xtype: the XMP type of the value
+ @type xtype: C{str}
+
+ @return: the value converted to its corresponding python type
+ @rtype: depends on xtype (DOCME)
+
+ @raise XmpValueError: if the conversion fails
+ """
+ if xtype.startswith('bag '):
+ # FIXME: make the value a notifying list.
+ if value == '':
+ return []
+ values = value.split(', ')
+ return map(lambda x: XmpTag._convert_to_python(x, xtype[4:]), values)
+
+ elif xtype == 'Boolean':
+ if value == 'True':
+ return True
+ elif value == 'False':
+ return False
+ else:
+ raise XmpValueError(value, xtype)
+
+ elif xtype == 'Choice':
+ # TODO
+ raise NotImplementedError('XMP conversion for type [%s]' % xtype)
+
+ elif xtype == 'Colorant':
+ # TODO
+ raise NotImplementedError('XMP conversion for type [%s]' % xtype)
+
+ elif xtype == 'Date':
+ match = XmpTag._date_re.match(value)
+ if match is None:
+ raise XmpValueError(value, xtype)
+ gd = match.groupdict()
+ if gd['month'] is not None:
+ month = int(gd['month'])
+ else:
+ month = 1
+ if gd['day'] is not None:
+ day = int(gd['day'])
+ else:
+ day = 1
+ if gd['time'] is None:
+ try:
+ return datetime.date(int(gd['year']), month, day)
+ except ValueError:
+ raise XmpValueError(value, xtype)
+ else:
+ if gd['minutes'] is None:
+ # Malformed time
+ raise XmpValueError(value, xtype)
+ if gd['seconds'] is not None:
+ seconds = int(gd['seconds'])
+ else:
+ seconds = 0
+ if gd['decimal'] is not None:
+ microseconds = int(float('0.%s' % gd['decimal']) * 1E6)
+ else:
+ microseconds = 0
+ if gd['tzd'] == 'Z':
+ tzinfo = FixedOffset()
+ else:
+ tzinfo = FixedOffset(gd['sign'], int(gd['ohours']),
+ int(gd['ominutes']))
+ try:
+ return datetime.datetime(int(gd['year']), month, day,
+ int(gd['hours']), int(gd['minutes']),
+ seconds, microseconds, tzinfo)
+ except ValueError:
+ raise XmpValueError(value, xtype)
+
+ elif xtype == 'Dimensions':
+ # TODO
+ raise NotImplementedError('XMP conversion for type [%s]' % xtype)
+
+ elif xtype == 'Font':
+ # TODO
+ raise NotImplementedError('XMP conversion for type [%s]' % xtype)
+
+ elif xtype == 'Integer':
+ try:
+ return int(value)
+ except ValueError:
+ raise XmpValueError(value, xtype)
+
+ elif xtype == 'Lang Alt':
+ matches = value.split('lang="')
+ nb = len(matches)
+ if nb < 2 or matches[0] != '':
+ raise XmpValueError(value, xtype)
+ result = {}
+ for i, match in enumerate(matches[1:]):
+ try:
+ qualifier, text = match.split('" ', 1)
+ except ValueError:
+ raise XmpValueError(value, xtype)
+ else:
+ if not text.rstrip().endswith(','):
+ if (i < nb - 2):
+ # If not the last match, it should end with a comma
+ raise XmpValueError(value, xtype)
+ else:
+ result[qualifier] = text
+ try:
+ result[qualifier] = unicode(text, 'utf-8')
+ except TypeError:
+ raise XmpValueError(value, xtype)
+ else:
+ try:
+ result[qualifier] = unicode(text.rstrip()[:-1], 'utf-8')
+ except TypeError:
+ raise XmpValueError(value, xtype)
+ return result
+
+ elif xtype == 'Locale':
+ # TODO
+ # See RFC 3066
+ raise NotImplementedError('XMP conversion for type [%s]' % xtype)
+
+ elif xtype == 'MIMEType':
+ try:
+ mtype, msubtype = value.split('/', 1)
+ except ValueError:
+ raise XmpValueError(value, xtype)
+ else:
+ return {'type': mtype, 'subtype': msubtype}
+
+ elif xtype == 'Real':
+ # TODO
+ raise NotImplementedError('XMP conversion for type [%s]' % xtype)
+
+ elif xtype in ('ProperName', 'Text'):
+ try:
+ return unicode(value, 'utf-8')
+ except TypeError:
+ raise XmpValueError(value, xtype)
+
+ elif xtype == 'Thumbnail':
+ # TODO
+ raise NotImplementedError('XMP conversion for type [%s]' % xtype)
+
+ elif xtype in ('URI', 'URL'):
+ return value
+
+ elif xtype == 'XPath':
+ # TODO
+ raise NotImplementedError('XMP conversion for type [%s]' % xtype)
+
+ raise NotImplementedError('XMP conversion for type [%s]' % xtype)
+
+ @staticmethod
+ def _convert_to_string(value, xtype):
+ """
+ Convert a value to its corresponding string representation, suitable to
+ pass to libexiv2.
+
+ @param value: the value to be converted
+ @type value: depends on xtype (DOCME)
+ @param xtype: the XMP type of the value
+ @type xtype: C{str}
+
+ @return: the value converted to its corresponding string representation
+ @rtype: C{str}
+
+ @raise XmpValueError: if the conversion fails
+ """
+ if xtype.startswith('bag '):
+ if type(value) in (list, tuple):
+ return ', '.join(map(lambda x: XmpTag._convert_to_string(x, xtype[4:]), value))
+ else:
+ raise XmpValueError(value, xtype)
+
+ elif xtype == 'Boolean':
+ if type(value) is bool:
+ return str(value)
+ else:
+ raise XmpValueError(value, xtype)
+
+ elif xtype == 'Date':
+ if type(value) is datetime.date:
+ return value.isoformat()
+ elif type(value) is datetime.datetime:
+ if value.hour == 0 and value.minute == 0 and \
+ value.second == 0 and value.microsecond == 0 and \
+ (value.tzinfo is None or value.tzinfo == FixedOffset()):
+ return value.strftime('%Y-%m-%d')
+ elif value.second == 0 and value.microsecond == 0:
+ return value.strftime('%Y-%m-%dT%H:%M%Z')
+ elif value.microsecond == 0:
+ return value.strftime('%Y-%m-%dT%H:%M:%S%Z')
+ else:
+ r = value.strftime('%Y-%m-%dT%H:%M:%S.')
+ r += str(int(value.microsecond) / 1E6)[2:]
+ r += value.strftime('%Z')
+ return r
+ else:
+ raise XmpValueError(value, xtype)
+
+ elif xtype == 'Integer':
+ if type(value) in (int, long):
+ return str(value)
+ else:
+ raise XmpValueError(value, xtype)
+
+ elif xtype == 'Lang Alt':
+ if type(value) is dict and len(value) > 0:
+ r = ''
+ for key, avalue in value.iteritems():
+ if type(key) is unicode:
+ try:
+ rkey = key.encode('utf-8')
+ except UnicodeEncodeError:
+ raise XmpValueError(value, xtype)
+ elif type(key) is str:
+ rkey = key
+ else:
+ raise XmpValueError(value, xtype)
+ if type(avalue) is unicode:
+ try:
+ ravalue = avalue.encode('utf-8')
+ except UnicodeEncodeError:
+ raise XmpValueError(value, xtype)
+ elif type(avalue) is str:
+ ravalue = avalue
+ else:
+ raise XmpValueError(value, xtype)
+ r += 'lang="%s" %s, ' % (rkey, ravalue)
+ return r[:-2]
+ else:
+ raise XmpValueError(value, xtype)
+
+ elif xtype == 'MIMEType':
+ if type(value) is dict:
+ try:
+ return '%s/%s' % (value['type'], value['subtype'])
+ except KeyError:
+ raise XmpValueError(value, xtype)
+ else:
+ raise XmpValueError(value, xtype)
+
+ elif xtype in ('ProperName', 'Text', 'URI', 'URL'):
+ if type(value) is unicode:
+ try:
+ return value.encode('utf-8')
+ except UnicodeEncodeError:
+ raise XmpValueError(value, xtype)
+ elif type(value) is str:
+ return value
+ else:
+ raise XmpValueError(value, xtype)
+
+ raise NotImplementedError('XMP conversion for type [%s]' % xtype)
+
+ def to_string(self):
+ """
+ Return a string representation of the XMP tag suitable to pass to
+ libexiv2 to set the value of the tag.
+
+ @rtype: C{str}
+ """
+ return XmpTag._convert_to_string(self.value, self.type)
+
+ def __str__(self):
+ """
+ Return a string representation of the XMP tag for debugging purposes.
+
+ @rtype: C{str}
+ """
+ r = 'Key = ' + self.key + os.linesep + \
+ 'Name = ' + self.name + os.linesep + \
+ 'Label = ' + self.label + os.linesep + \
+ 'Description = ' + self.description + os.linesep + \
+ 'Type = ' + self.type + os.linesep + \
+ 'Values = ' + str(self.values)
+ return r
+
diff --git a/test/exif.py b/test/exif.py
index d32e30d..44ae394 100644
--- a/test/exif.py
+++ b/test/exif.py
@@ -25,7 +25,10 @@
# ******************************************************************************
import unittest
-from pyexiv2 import ExifTag, ExifValueError, Rational
+
+from pyexiv2.exif import ExifTag, ExifValueError
+from pyexiv2.utils import Rational
+
import datetime
diff --git a/test/iptc.py b/test/iptc.py
index ba02599..5473090 100644
--- a/test/iptc.py
+++ b/test/iptc.py
@@ -25,7 +25,10 @@
# ******************************************************************************
import unittest
-from pyexiv2 import IptcTag, IptcValueError, FixedOffset
+
+from pyexiv2.iptc import IptcTag, IptcValueError
+from pyexiv2.utils import FixedOffset
+
import datetime
diff --git a/test/metadata.py b/test/metadata.py
new file mode 100644
index 0000000..af8646f
--- /dev/null
+++ b/test/metadata.py
@@ -0,0 +1,692 @@
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+import unittest
+
+from pyexiv2.metadata import ImageMetadata
+from pyexiv2.exif import ExifTag
+from pyexiv2.iptc import IptcTag
+from pyexiv2.xmp import XmpTag
+
+import datetime
+
+
+class ImageMock(object):
+
+ def __init__(self, filename):
+ self.filename = filename
+ self.read = False
+ self.written = False
+ self.tags = {'exif': {}, 'iptc': {}, 'xmp': {}}
+
+ def readMetadata(self):
+ self.read = True
+
+ def writeMetadata(self):
+ self.written = True
+
+ def exifKeys(self):
+ return self.tags['exif'].keys()
+
+ def getExifTag(self, key):
+ return self.tags['exif'][key]
+
+ def setExifTagValue(self, key, value):
+ self.tags['exif'][key] = value
+
+ def deleteExifTag(self, key):
+ try:
+ del self.tags['exif'][key]
+ except KeyError:
+ pass
+
+ def iptcKeys(self):
+ return self.tags['iptc'].keys()
+
+ def getIptcTag(self, key):
+ return self.tags['iptc'][key]
+
+ def setIptcTagValues(self, key, values):
+ self.tags['iptc'][key] = values
+
+ def deleteIptcTag(self, key):
+ try:
+ del self.tags['iptc'][key]
+ except KeyError:
+ pass
+
+ def xmpKeys(self):
+ return self.tags['xmp'].keys()
+
+ def getXmpTag(self, key):
+ return self.tags['xmp'][key]
+
+ def setXmpTagValue(self, key, value):
+ self.tags['xmp'][key] = value
+
+ def deleteXmpTag(self, key):
+ try:
+ del self.tags['xmp'][key]
+ except KeyError:
+ pass
+
+
+class TestImageMetadata(unittest.TestCase):
+
+ def setUp(self):
+ self.metadata = ImageMetadata('nofile')
+ self.metadata._instantiate_image = lambda filename: ImageMock(filename)
+
+ def _set_exif_tags(self):
+ tags = {}
+ tags['Exif.Image.Make'] = \
+ ('Exif.Image.Make', 'Make', 'Manufacturer', 'blabla', 'Ascii',
+ 'EASTMAN KODAK COMPANY', 'EASTMAN KODAK COMPANY')
+ tags['Exif.Image.DateTime'] = \
+ ('Exif.Image.DateTime', 'DateTime', 'Date and Time', 'blabla',
+ 'Ascii', '2009:02:09 13:33:20', '2009:02:09 13:33:20')
+ tags['Exif.Photo.ExifVersion'] = \
+ ('Exif.Photo.ExifVersion', 'ExifVersion', 'Exif Version', 'blabla',
+ 'Undefined', '48 50 50 49 ', '2.21')
+ self.metadata._image.tags['exif'] = tags
+
+ def _set_iptc_tags(self):
+ tags = {}
+ tags['Iptc.Application2.Caption'] = \
+ ('Iptc.Application2.Caption', 'Caption', 'Caption',
+ 'A textual description of the object data.', 'String',
+ ['blabla'])
+ tags['Iptc.Application2.DateCreated'] = \
+ ('Iptc.Application2.DateCreated', 'DateCreated', 'Date Created',
+ 'Represented in the form CCYYMMDD to designate the date the ' \
+ 'intellectual content of the object data was created rather ' \
+ 'than the date of the creation of the physical representation. ' \
+ 'Follows ISO 8601 standard.', 'Date', ['2004-07-13'])
+ self.metadata._image.tags['iptc'] = tags
+
+ def _set_xmp_tags(self):
+ tags = {}
+ tags['Xmp.dc.format'] = \
+ ('Xmp.dc.format', 'format', 'Format', 'The file format used when ' \
+ 'saving the resource. Tools and applications should set this ' \
+ 'property to the save format of the data. It may include ' \
+ 'appropriate qualifiers.', 'MIMEType', 'image/jpeg')
+ tags['Xmp.dc.subject'] = \
+ ('Xmp.dc.subject', 'subject', 'Subject', 'An unordered array of ' \
+ 'descriptive phrases or keywords that specify the topic of the ' \
+ 'content of the resource.', 'bag Text', 'image, test, pyexiv2')
+ tags['Xmp.xmp.CreateDate'] = \
+ ('Xmp.xmp.CreateDate', 'CreateDate', 'Create Date',
+ 'The date and time the resource was originally created.', 'Date',
+ '2005-09-07T15:07:40-07:00')
+ tags['Xmp.xmpMM.DocumentID'] = \
+ ('Xmp.xmpMM.DocumentID', 'DocumentID', 'Document ID',
+ 'The common identifier for all versions and renditions of a ' \
+ 'document. It should be based on a UUID; see Document and ' \
+ 'Instance IDs below.', 'URI',
+ 'uuid:9A3B7F52214211DAB6308A7391270C13')
+ self.metadata._image.tags['xmp'] = tags
+
+ ######################
+ # Test general methods
+ ######################
+
+ def test_read(self):
+ self.assertEqual(self.metadata._image, None)
+ self.metadata.read()
+ self.failIfEqual(self.metadata._image, None)
+ self.failUnless(self.metadata._image.read)
+
+ def test_write(self):
+ self.metadata.read()
+ self.failIf(self.metadata._image.written)
+ self.metadata.write()
+ self.failUnless(self.metadata._image.written)
+
+ ###########################
+ # Test EXIF-related methods
+ ###########################
+
+ def test_exif_keys(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ self.assertEqual(self.metadata._keys['exif'], None)
+ keys = self.metadata.exif_keys
+ self.assertEqual(len(keys), 3)
+ self.assertEqual(self.metadata._keys['exif'], keys)
+
+ def test_get_exif_tag(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ self.assertEqual(self.metadata._tags['exif'], {})
+ # Get an existing tag
+ key = 'Exif.Image.Make'
+ tag = self.metadata._get_exif_tag(key)
+ self.assertEqual(type(tag), ExifTag)
+ self.assertEqual(tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['exif'][key], tag)
+ # Try to get an nonexistent tag
+ key = 'Exif.Photo.Sharpness'
+ self.failUnlessRaises(KeyError, self.metadata._get_exif_tag, key)
+
+ def test_set_exif_tag_wrong(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ self.assertEqual(self.metadata._tags['exif'], {})
+ # Try to set a tag with wrong type
+ tag = 'Not an exif tag'
+ self.failUnlessRaises(TypeError, self.metadata._set_exif_tag, tag)
+ self.assertEqual(self.metadata._tags['exif'], {})
+
+ def test_set_exif_tag_create(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ self.assertEqual(self.metadata._tags['exif'], {})
+ # Create a new tag
+ tag = ExifTag('Exif.Thumbnail.Orientation', 'Orientation',
+ 'Orientation', 'The image orientation viewed in terms ' \
+ 'of rows and columns.', 'Short', '1', 'top, left')
+ self.assertEqual(tag.metadata, None)
+ self.metadata._set_exif_tag(tag)
+ self.assertEqual(tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['exif'], {tag.key: tag})
+ self.assert_(self.metadata._image.tags['exif'].has_key(tag.key))
+ self.assertEqual(self.metadata._image.tags['exif'][tag.key],
+ tag.raw_value)
+
+ def test_set_exif_tag_overwrite(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ self.assertEqual(self.metadata._tags['exif'], {})
+ # Overwrite an existing tag
+ tag = ExifTag('Exif.Image.DateTime', 'DateTime', 'Date and Time',
+ 'blabla', 'Ascii', '2009:03:20 20:32:00',
+ '2009:03:20 20:32:00')
+ self.assertEqual(tag.metadata, None)
+ self.metadata._set_exif_tag(tag)
+ self.assertEqual(tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['exif'], {tag.key: tag})
+ self.assert_(self.metadata._image.tags['exif'].has_key(tag.key))
+ self.assertEqual(self.metadata._image.tags['exif'][tag.key],
+ tag.raw_value)
+
+ def test_set_exif_tag_overwrite_already_cached(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ self.assertEqual(self.metadata._tags['exif'], {})
+ # Overwrite an existing tag already cached
+ key = 'Exif.Photo.ExifVersion'
+ tag = self.metadata._get_exif_tag(key)
+ self.assertEqual(self.metadata._tags['exif'][key], tag)
+ new_tag = ExifTag(key, 'ExifVersion', 'Exif Version', 'blabla',
+ 'Undefined', '48 50 50 48 ', '2.20')
+ self.assertEqual(new_tag.metadata, None)
+ self.metadata._set_exif_tag(new_tag)
+ self.assertEqual(new_tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['exif'], {key: new_tag})
+ self.assert_(self.metadata._image.tags['exif'].has_key(key))
+ # Special case where the formatted value is used instead of the raw
+ # value.
+ self.assertEqual(self.metadata._image.tags['exif'][key], new_tag.fvalue)
+
+ def test_set_exif_tag_value_inexistent(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ key = 'Exif.Photo.ExposureTime'
+ value = '1/500'
+ self.failUnlessRaises(KeyError, self.metadata._set_exif_tag_value,
+ key, value)
+
+ def test_set_exif_tag_value_wrong_type(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ key = 'Exif.Image.DateTime'
+ value = datetime.datetime(2009, 3, 24, 9, 37, 36)
+ self.failUnlessRaises(TypeError, self.metadata._set_exif_tag_value,
+ key, value)
+
+ def test_set_exif_tag_value(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ key = 'Exif.Image.DateTime'
+ tag = self.metadata._get_exif_tag(key)
+ value = '2009:03:24 09:37:36'
+ self.failIfEqual(self.metadata._image.tags['exif'][key], value)
+ self.metadata._set_exif_tag_value(key, value)
+ self.assertEqual(self.metadata._image.tags['exif'][key], value)
+
+ def test_delete_exif_tag_inexistent(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ key = 'Exif.Image.Artist'
+ self.failUnlessRaises(KeyError, self.metadata._delete_exif_tag, key)
+
+ def test_delete_exif_tag_not_cached(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ key = 'Exif.Image.DateTime'
+ self.assertEqual(self.metadata._tags['exif'], {})
+ self.assert_(self.metadata._image.tags['exif'].has_key(key))
+ self.metadata._delete_exif_tag(key)
+ self.assertEqual(self.metadata._tags['exif'], {})
+ self.failIf(self.metadata._image.tags['exif'].has_key(key))
+
+ def test_delete_exif_tag_cached(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ key = 'Exif.Image.DateTime'
+ self.assert_(self.metadata._image.tags['exif'].has_key(key))
+ tag = self.metadata._get_exif_tag(key)
+ self.assertEqual(self.metadata._tags['exif'][key], tag)
+ self.metadata._delete_exif_tag(key)
+ self.assertEqual(self.metadata._tags['exif'], {})
+ self.failIf(self.metadata._image.tags['exif'].has_key(key))
+
+ ###########################
+ # Test IPTC-related methods
+ ###########################
+
+ def test_iptc_keys(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ self.assertEqual(self.metadata._keys['iptc'], None)
+ keys = self.metadata.iptc_keys
+ self.assertEqual(len(keys), 2)
+ self.assertEqual(self.metadata._keys['iptc'], keys)
+
+ def test_get_iptc_tag(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ self.assertEqual(self.metadata._tags['iptc'], {})
+ # Get an existing tag
+ key = 'Iptc.Application2.DateCreated'
+ tag = self.metadata._get_iptc_tag(key)
+ self.assertEqual(type(tag), IptcTag)
+ self.assertEqual(tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['iptc'][key], tag)
+ # Try to get an nonexistent tag
+ key = 'Iptc.Application2.Copyright'
+ self.failUnlessRaises(KeyError, self.metadata._get_iptc_tag, key)
+
+ def test_set_iptc_tag_wrong(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ self.assertEqual(self.metadata._tags['iptc'], {})
+ # Try to set a tag with wrong type
+ tag = 'Not an iptc tag'
+ self.failUnlessRaises(TypeError, self.metadata._set_iptc_tag, tag)
+ self.assertEqual(self.metadata._tags['iptc'], {})
+
+ def test_set_iptc_tag_create(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ self.assertEqual(self.metadata._tags['iptc'], {})
+ # Create a new tag
+ tag = IptcTag('Iptc.Application2.Writer', 'Writer', 'Writer',
+ 'Identification of the name of the person involved in ' \
+ 'the writing, editing or correcting the object data or ' \
+ 'caption/abstract.', 'String', ['Nobody'])
+ self.assertEqual(tag.metadata, None)
+ self.metadata._set_iptc_tag(tag)
+ self.assertEqual(tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['iptc'], {tag.key: tag})
+ self.assert_(self.metadata._image.tags['iptc'].has_key(tag.key))
+ self.assertEqual(self.metadata._image.tags['iptc'][tag.key],
+ tag.raw_value)
+
+ def test_set_iptc_tag_overwrite(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ self.assertEqual(self.metadata._tags['iptc'], {})
+ # Overwrite an existing tag
+ tag = IptcTag('Iptc.Application2.Caption', 'Caption', 'Caption',
+ 'A textual description of the object data.', 'String',
+ ['A picture.'])
+ self.assertEqual(tag.metadata, None)
+ self.metadata._set_iptc_tag(tag)
+ self.assertEqual(tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['iptc'], {tag.key: tag})
+ self.assert_(self.metadata._image.tags['iptc'].has_key(tag.key))
+ self.assertEqual(self.metadata._image.tags['iptc'][tag.key],
+ tag.raw_value)
+
+ def test_set_iptc_tag_overwrite_already_cached(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ self.assertEqual(self.metadata._tags['iptc'], {})
+ # Overwrite an existing tag already cached
+ key = 'Iptc.Application2.Caption'
+ tag = self.metadata._get_iptc_tag(key)
+ self.assertEqual(self.metadata._tags['iptc'][key], tag)
+ new_tag = IptcTag(key, 'Caption', 'Caption',
+ 'A textual description of the object data.', 'String',
+ ['A picture.'])
+ self.assertEqual(new_tag.metadata, None)
+ self.metadata._set_iptc_tag(new_tag)
+ self.assertEqual(new_tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['iptc'], {key: new_tag})
+ self.assert_(self.metadata._image.tags['iptc'].has_key(key))
+ self.assertEqual(self.metadata._image.tags['iptc'][key],
+ new_tag.raw_value)
+
+ def test_set_iptc_tag_values_inexistent(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ key = 'Iptc.Application2.Urgency'
+ values = ['1']
+ self.failUnlessRaises(KeyError, self.metadata._set_iptc_tag_values,
+ key, values)
+
+ def test_set_iptc_tag_values_wrong_type(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ key = 'Iptc.Application2.DateCreated'
+ value = '20090324'
+ self.failUnlessRaises(TypeError, self.metadata._set_iptc_tag_values,
+ key, value)
+ values = [datetime.date(2009, 3, 24)]
+ self.failUnlessRaises(TypeError, self.metadata._set_iptc_tag_values,
+ key, values)
+
+ def test_set_iptc_tag_values(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ key = 'Iptc.Application2.DateCreated'
+ tag = self.metadata._get_iptc_tag(key)
+ values = ['2009-04-07']
+ self.failIfEqual(self.metadata._image.tags['iptc'][key], values)
+ self.metadata._set_iptc_tag_values(key, values)
+ self.assertEqual(self.metadata._image.tags['iptc'][key], values)
+
+ def test_delete_iptc_tag_inexistent(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ key = 'Iptc.Application2.LocationCode'
+ self.failUnlessRaises(KeyError, self.metadata._delete_iptc_tag, key)
+
+ def test_delete_iptc_tag_not_cached(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ key = 'Iptc.Application2.Caption'
+ self.assertEqual(self.metadata._tags['iptc'], {})
+ self.assert_(self.metadata._image.tags['iptc'].has_key(key))
+ self.metadata._delete_iptc_tag(key)
+ self.assertEqual(self.metadata._tags['iptc'], {})
+ self.failIf(self.metadata._image.tags['iptc'].has_key(key))
+
+ def test_delete_iptc_tag_cached(self):
+ self.metadata.read()
+ self._set_iptc_tags()
+ key = 'Iptc.Application2.Caption'
+ self.assert_(self.metadata._image.tags['iptc'].has_key(key))
+ tag = self.metadata._get_iptc_tag(key)
+ self.assertEqual(self.metadata._tags['iptc'][key], tag)
+ self.metadata._delete_iptc_tag(key)
+ self.assertEqual(self.metadata._tags['iptc'], {})
+ self.failIf(self.metadata._image.tags['iptc'].has_key(key))
+
+ ##########################
+ # Test XMP-related methods
+ ##########################
+
+ def test_xmp_keys(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ self.assertEqual(self.metadata._keys['xmp'], None)
+ keys = self.metadata.xmp_keys
+ self.assertEqual(len(keys), 4)
+ self.assertEqual(self.metadata._keys['xmp'], keys)
+
+ def test_get_xmp_tag(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ self.assertEqual(self.metadata._tags['xmp'], {})
+ # Get an existing tag
+ key = 'Xmp.dc.subject'
+ tag = self.metadata._get_xmp_tag(key)
+ self.assertEqual(type(tag), XmpTag)
+ self.assertEqual(tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['xmp'][key], tag)
+ # Try to get an nonexistent tag
+ key = 'Xmp.xmp.Label'
+ self.failUnlessRaises(KeyError, self.metadata._get_xmp_tag, key)
+
+ def test_set_xmp_tag_wrong(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ self.assertEqual(self.metadata._tags['xmp'], {})
+ # Try to set a tag with wrong type
+ tag = 'Not an xmp tag'
+ self.failUnlessRaises(TypeError, self.metadata._set_xmp_tag, tag)
+ self.assertEqual(self.metadata._tags['xmp'], {})
+
+ def test_set_xmp_tag_create(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ self.assertEqual(self.metadata._tags['xmp'], {})
+ # Create a new tag
+ tag = XmpTag('Xmp.dc.title', 'title', 'Title', 'The title of the ' \
+ 'document, or the name given to the resource. Typically,' \
+ ' it will be a name by which the resource is formally ' \
+ 'known.', 'Lang Alt',
+ 'lang="x-default" This is not a title, ' \
+ 'lang="fr-FR" Ceci n\'est pas un titre')
+ self.assertEqual(tag.metadata, None)
+ self.metadata._set_xmp_tag(tag)
+ self.assertEqual(tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['xmp'], {tag.key: tag})
+ self.assert_(self.metadata._image.tags['xmp'].has_key(tag.key))
+ self.assertEqual(self.metadata._image.tags['xmp'][tag.key],
+ tag.raw_value)
+
+ def test_set_xmp_tag_overwrite(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ self.assertEqual(self.metadata._tags['xmp'], {})
+ # Overwrite an existing tag
+ tag = XmpTag('Xmp.dc.format', 'format', 'Format', 'The file format ' \
+ 'used when saving the resource. Tools and applications ' \
+ 'should set this property to the save format of the ' \
+ 'data. It may include appropriate qualifiers.',
+ 'MIMEType', 'image/png')
+ self.assertEqual(tag.metadata, None)
+ self.metadata._set_xmp_tag(tag)
+ self.assertEqual(tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['xmp'], {tag.key: tag})
+ self.assert_(self.metadata._image.tags['xmp'].has_key(tag.key))
+ self.assertEqual(self.metadata._image.tags['xmp'][tag.key],
+ tag.raw_value)
+
+ def test_set_xmp_tag_overwrite_already_cached(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ self.assertEqual(self.metadata._tags['xmp'], {})
+ # Overwrite an existing tag already cached
+ key = 'Xmp.xmp.CreateDate'
+ tag = self.metadata._get_xmp_tag(key)
+ self.assertEqual(self.metadata._tags['xmp'][key], tag)
+ new_tag = XmpTag(key, 'CreateDate', 'Create Date',
+ 'The date and time the resource was originally ' \
+ 'created.', 'Date', '2009-04-21T20:07+01:00')
+ self.assertEqual(new_tag.metadata, None)
+ self.metadata._set_xmp_tag(new_tag)
+ self.assertEqual(new_tag.metadata, self.metadata)
+ self.assertEqual(self.metadata._tags['xmp'], {key: new_tag})
+ self.assert_(self.metadata._image.tags['xmp'].has_key(key))
+ self.assertEqual(self.metadata._image.tags['xmp'][key],
+ new_tag.raw_value)
+
+ def test_set_xmp_tag_value_inexistent(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ key = 'Xmp.xmp.Nickname'
+ value = 'oSoMoN'
+ self.failUnlessRaises(KeyError, self.metadata._set_xmp_tag_value,
+ key, value)
+
+ def test_set_xmp_tag_value_wrong_type(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ key = 'Xmp.xmp.CreateDate'
+ value = datetime.datetime(2009, 4, 21, 20, 11, 0)
+ self.failUnlessRaises(TypeError, self.metadata._set_xmp_tag_value,
+ key, value)
+
+ def test_set_xmp_tag_value(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ key = 'Xmp.xmp.CreateDate'
+ tag = self.metadata._get_xmp_tag(key)
+ value = '2009-04-21T20:12:47+01:00'
+ self.failIfEqual(self.metadata._image.tags['xmp'][key], value)
+ self.metadata._set_xmp_tag_value(key, value)
+ self.assertEqual(self.metadata._image.tags['xmp'][key], value)
+
+ def test_delete_xmp_tag_inexistent(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ key = 'Xmp.xmp.CreatorTool'
+ self.failUnlessRaises(KeyError, self.metadata._delete_xmp_tag, key)
+
+ def test_delete_xmp_tag_not_cached(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ key = 'Xmp.dc.subject'
+ self.assertEqual(self.metadata._tags['xmp'], {})
+ self.assert_(self.metadata._image.tags['xmp'].has_key(key))
+ self.metadata._delete_xmp_tag(key)
+ self.assertEqual(self.metadata._tags['xmp'], {})
+ self.failIf(self.metadata._image.tags['xmp'].has_key(key))
+
+ def test_delete_xmp_tag_cached(self):
+ self.metadata.read()
+ self._set_xmp_tags()
+ key = 'Xmp.dc.subject'
+ self.assert_(self.metadata._image.tags['xmp'].has_key(key))
+ tag = self.metadata._get_xmp_tag(key)
+ self.assertEqual(self.metadata._tags['xmp'][key], tag)
+ self.metadata._delete_xmp_tag(key)
+ self.assertEqual(self.metadata._tags['xmp'], {})
+ self.failIf(self.metadata._image.tags['xmp'].has_key(key))
+
+ ###########################
+ # Test dictionary interface
+ ###########################
+
+ def test_getitem(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ self._set_iptc_tags()
+ self._set_xmp_tags()
+ # Get existing tags
+ key = 'Exif.Photo.ExifVersion'
+ tag = self.metadata[key]
+ self.assertEqual(type(tag), ExifTag)
+ key = 'Iptc.Application2.Caption'
+ tag = self.metadata[key]
+ self.assertEqual(type(tag), IptcTag)
+ key = 'Xmp.xmp.CreateDate'
+ tag = self.metadata[key]
+ self.assertEqual(type(tag), XmpTag)
+ # Try to get nonexistent tags
+ keys = ('Exif.Image.SamplesPerPixel', 'Iptc.Application2.FixtureId',
+ 'Xmp.xmp.Rating', 'Wrong.Noluck.Raise')
+ for key in keys:
+ self.failUnlessRaises(KeyError, self.metadata.__getitem__, key)
+
+ def test_setitem(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ self._set_iptc_tags()
+ self._set_xmp_tags()
+ # Set new tags
+ key = 'Exif.Photo.ExposureBiasValue'
+ tag = ExifTag(key, 'ExposureBiasValue', 'Exposure Bias',
+ 'The exposure bias. The units is the APEX value. ' \
+ 'Ordinarily it is given in the range of -99.99 to 99.99.',
+ 'SRational', '0/3', '0')
+ self.metadata[key] = tag
+ self.failUnless(key in self.metadata._tags['exif'])
+ self.failUnlessEqual(self.metadata._tags['exif'][key], tag)
+ key = 'Iptc.Application2.City'
+ tag = IptcTag(key, 'City', 'City', 'Identifies city of object data ' \
+ 'origin according to guidelines established by the ' \
+ 'provider.', 'String', ['Barcelona'])
+ self.metadata[key] = tag
+ self.failUnless(key in self.metadata._tags['iptc'])
+ self.failUnlessEqual(self.metadata._tags['iptc'][key], tag)
+ key = 'Xmp.dc.description'
+ tag = XmpTag(key, 'description', 'Description', 'A textual ' \
+ 'description of the content of the resource. Multiple ' \
+ 'values may be present for different languages.',
+ 'Lang Alt', 'lang="x-default" Sunset picture.')
+ self.metadata[key] = tag
+ self.failUnless(key in self.metadata._tags['xmp'])
+ self.failUnlessEqual(self.metadata._tags['xmp'][key], tag)
+ # Replace existing tags
+ key = 'Exif.Photo.ExifVersion'
+ tag = ExifTag(key, 'ExifVersion', 'Exif Version', 'The version of ' \
+ 'this standard supported. Nonexistence of this field is' \
+ ' taken to mean nonconformance to the standard.',
+ 'Undefined', '48 50 50 48 ', '2.20')
+ self.metadata[key] = tag
+ self.failUnless(key in self.metadata._tags['exif'])
+ self.failUnlessEqual(self.metadata._tags['exif'][key], tag)
+ key = 'Iptc.Application2.Caption'
+ tag = IptcTag(key, 'Caption', 'Caption', 'A textual description of ' \
+ 'the object data.', 'String', ['Sunset on Barcelona.'])
+ self.metadata[key] = tag
+ self.failUnless(key in self.metadata._tags['iptc'])
+ self.failUnlessEqual(self.metadata._tags['iptc'][key], tag)
+ key = 'Xmp.dc.subject'
+ tag = XmpTag(key, 'subject', 'Subject', 'An unordered array of ' \
+ 'descriptive phrases or keywords that specify the topic ' \
+ 'of the content of the resource.', 'bag Text',
+ 'sunset, Barcelona, beautiful, beach')
+ self.metadata[key] = tag
+ self.failUnless(key in self.metadata._tags['xmp'])
+ self.failUnlessEqual(self.metadata._tags['xmp'][key], tag)
+
+ def test_delitem(self):
+ self.metadata.read()
+ self._set_exif_tags()
+ self._set_iptc_tags()
+ self._set_xmp_tags()
+ # Delete existing tags
+ key = 'Exif.Photo.ExifVersion'
+ del self.metadata[key]
+ self.failIf(key in self.metadata._tags['exif'])
+ key = 'Iptc.Application2.Caption'
+ del self.metadata[key]
+ self.failIf(key in self.metadata._tags['iptc'])
+ key = 'Xmp.xmp.CreateDate'
+ del self.metadata[key]
+ self.failIf(key in self.metadata._tags['xmp'])
+ # Try to delete nonexistent tags
+ keys = ('Exif.Image.SamplesPerPixel', 'Iptc.Application2.FixtureId',
+ 'Xmp.xmp.Rating', 'Wrong.Noluck.Raise')
+ for key in keys:
+ self.failUnlessRaises(KeyError, self.metadata.__delitem__, key)
diff --git a/test/notifying_list.py b/test/notifying_list.py
new file mode 100644
index 0000000..e80ec9c
--- /dev/null
+++ b/test/notifying_list.py
@@ -0,0 +1,338 @@
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+import unittest
+from pyexiv2.utils import ListenerInterface, NotifyingList
+import random
+
+
+class SimpleListener(ListenerInterface):
+
+ def __init__(self):
+ self.changes = 0
+
+ def contents_changed(self):
+ self.changes += 1
+
+
+class TestNotifyingList(unittest.TestCase):
+
+ def setUp(self):
+ self.values = NotifyingList([5, 7, 9, 14, 57, 3, 2])
+
+ def test_no_listener(self):
+ # No listener is registered, nothing should happen.
+ self.values[3] = 13
+ del self.values[5]
+ self.values.append(17)
+ self.values.extend([11, 22])
+ self.values.insert(4, 24)
+ self.values.pop()
+ self.values.remove(9)
+ self.values.reverse()
+ self.values.sort()
+ self.values += [8, 4]
+ self.values *= 3
+ self.values[3:4] = [8, 4]
+ del self.values[3:5]
+
+ def test_listener_interface(self):
+ self.values.register_listener(ListenerInterface())
+ self.failUnlessRaises(NotImplementedError,
+ self.values.__setitem__, 3, 13)
+ self.failUnlessRaises(NotImplementedError, self.values.__delitem__, 5)
+ self.failUnlessRaises(NotImplementedError, self.values.append, 17)
+ self.failUnlessRaises(NotImplementedError, self.values.extend, [11, 22])
+ self.failUnlessRaises(NotImplementedError, self.values.insert, 4, 24)
+ self.failUnlessRaises(NotImplementedError, self.values.pop)
+ self.failUnlessRaises(NotImplementedError, self.values.remove, 9)
+ self.failUnlessRaises(NotImplementedError, self.values.reverse)
+ self.failUnlessRaises(NotImplementedError, self.values.sort)
+ self.failUnlessRaises(NotImplementedError, self.values.__iadd__, [8, 4])
+ self.failUnlessRaises(NotImplementedError, self.values.__imul__, 3)
+ self.failUnlessRaises(NotImplementedError, self.values.__setslice__,
+ 3, 4, [8, 4])
+ self.failUnlessRaises(NotImplementedError, self.values.__delslice__,
+ 3, 5)
+
+ def _register_listeners(self):
+ # Register a random number of listeners
+ listeners = [SimpleListener() for i in xrange(random.randint(3, 20))]
+ for listener in listeners:
+ self.values.register_listener(listener)
+ return listeners
+
+ def test_setitem(self):
+ listeners = self._register_listeners()
+
+ self.values[3] = 13
+ self.failUnlessEqual(self.values, [5, 7, 9, 13, 57, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ self.failUnlessRaises(IndexError, self.values.__setitem__, 9, 27)
+ self.failUnlessEqual(self.values, [5, 7, 9, 13, 57, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ def test_delitem(self):
+ listeners = self._register_listeners()
+
+ del self.values[5]
+ self.failUnlessEqual(self.values, [5, 7, 9, 14, 57, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ self.failUnlessRaises(IndexError, self.values.__delitem__, 9)
+ self.failUnlessEqual(self.values, [5, 7, 9, 14, 57, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ def test_append(self):
+ listeners = self._register_listeners()
+
+ self.values.append(17)
+ self.failUnlessEqual(self.values, [5, 7, 9, 14, 57, 3, 2, 17])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ def test_extend(self):
+ listeners = self._register_listeners()
+
+ self.values.extend([11, 22])
+ self.failUnlessEqual(self.values, [5, 7, 9, 14, 57, 3, 2, 11, 22])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ self.failUnlessRaises(TypeError, self.values.extend, 26)
+ self.failUnlessEqual(self.values, [5, 7, 9, 14, 57, 3, 2, 11, 22])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ def test_insert(self):
+ listeners = self._register_listeners()
+
+ self.values.insert(4, 24)
+ self.failUnlessEqual(self.values, [5, 7, 9, 14, 24, 57, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ def test_pop(self):
+ listeners = self._register_listeners()
+
+ self.values.pop()
+ self.failUnlessEqual(self.values, [5, 7, 9, 14, 57, 3])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ self.values.pop(4)
+ self.failUnlessEqual(self.values, [5, 7, 9, 14, 3])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 2)
+
+ self.values.pop(-2)
+ self.failUnlessEqual(self.values, [5, 7, 9, 3])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 3)
+
+ self.failUnlessRaises(IndexError, self.values.pop, 33)
+ self.failUnlessEqual(self.values, [5, 7, 9, 3])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 3)
+
+ def test_remove(self):
+ listeners = self._register_listeners()
+
+ self.values.remove(9)
+ self.failUnlessEqual(self.values, [5, 7, 14, 57, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ self.failUnlessRaises(ValueError, self.values.remove, 33)
+ self.failUnlessEqual(self.values, [5, 7, 14, 57, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ def test_reverse(self):
+ listeners = self._register_listeners()
+
+ self.values.reverse()
+ self.failUnlessEqual(self.values, [2, 3, 57, 14, 9, 7, 5])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ def test_sort(self):
+ listeners = self._register_listeners()
+
+ self.values.sort()
+ self.failUnlessEqual(self.values, [2, 3, 5, 7, 9, 14, 57])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ self.values.sort(cmp=lambda x, y: y - x)
+ self.failUnlessEqual(self.values, [57, 14, 9, 7, 5, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 2)
+
+ self.values.sort(key=lambda x: x * x)
+ self.failUnlessEqual(self.values, [2, 3, 5, 7, 9, 14, 57])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 3)
+
+ self.values.sort(reverse=True)
+ self.failUnlessEqual(self.values, [57, 14, 9, 7, 5, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 4)
+
+ def test_iadd(self):
+ listeners = self._register_listeners()
+
+ self.values += [44, 31, 19]
+ self.failUnlessEqual(self.values, [5, 7, 9, 14, 57, 3, 2, 44, 31, 19])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ def test_imul(self):
+ listeners = self._register_listeners()
+
+ self.values *= 3
+ self.failUnlessEqual(self.values,
+ [5, 7, 9, 14, 57, 3, 2,
+ 5, 7, 9, 14, 57, 3, 2,
+ 5, 7, 9, 14, 57, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ def test_setslice(self):
+ listeners = self._register_listeners()
+
+ # Basic slicing (of the form [i:j]): implemented as __setslice__.
+
+ self.values[2:4] = [3, 4]
+ self.failUnlessEqual(self.values, [5, 7, 3, 4, 57, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ self.values[3:5] = [77, 8, 12]
+ self.failUnlessEqual(self.values, [5, 7, 3, 77, 8, 12, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 2)
+
+ self.values[2:5] = [1, 0]
+ self.failUnlessEqual(self.values, [5, 7, 1, 0, 12, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 3)
+
+ self.values[0:2] = []
+ self.failUnlessEqual(self.values, [1, 0, 12, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 4)
+
+ self.values[2:2] = [7, 5]
+ self.failUnlessEqual(self.values, [1, 0, 7, 5, 12, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 5)
+
+ # With negatives indexes
+
+ self.values[4:-2] = [9]
+ self.failUnlessEqual(self.values, [1, 0, 7, 5, 9, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 6)
+
+ self.values[-2:1] = [6, 4]
+ self.failUnlessEqual(self.values, [1, 0, 7, 5, 9, 6, 4, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 7)
+
+ self.values[-5:-2] = [8]
+ self.failUnlessEqual(self.values, [1, 0, 7, 5, 8, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 8)
+
+ # With missing (implicit) indexes
+
+ self.values[:2] = [4]
+ self.failUnlessEqual(self.values, [4, 7, 5, 8, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 9)
+
+ self.values[4:] = [1]
+ self.failUnlessEqual(self.values, [4, 7, 5, 8, 1])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 10)
+
+ self.values[:] = [5, 7, 9, 14, 57, 3, 2]
+ self.failUnlessEqual(self.values, [5, 7, 9, 14, 57, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 11)
+
+ def test_delslice(self):
+ listeners = self._register_listeners()
+
+ del self.values[2:3]
+ self.failUnlessEqual(self.values, [5, 7, 14, 57, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ del self.values[2:2]
+ self.failUnlessEqual(self.values, [5, 7, 14, 57, 3, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 1)
+
+ # With negatives indexes
+
+ del self.values[4:-1]
+ self.failUnlessEqual(self.values, [5, 7, 14, 57, 2])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 2)
+
+ del self.values[-1:5]
+ self.failUnlessEqual(self.values, [5, 7, 14, 57])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 3)
+
+ del self.values[-2:-1]
+ self.failUnlessEqual(self.values, [5, 7, 57])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 4)
+
+ # With missing (implicit) indexes
+
+ del self.values[:1]
+ self.failUnlessEqual(self.values, [7, 57])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 5)
+
+ del self.values[1:]
+ self.failUnlessEqual(self.values, [7])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 6)
+
+ del self.values[:]
+ self.failUnlessEqual(self.values, [])
+ for listener in listeners:
+ self.failUnlessEqual(listener.changes, 7)
diff --git a/test/rational.py b/test/rational.py
new file mode 100644
index 0000000..070bf6c
--- /dev/null
+++ b/test/rational.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2008-2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+import unittest
+
+from pyexiv2.utils import Rational
+
+
+class TestRational(unittest.TestCase):
+
+ def test_constructor(self):
+ r = Rational(2, 1)
+ self.assertEqual(r.numerator, 2)
+ self.assertEqual(r.denominator, 1)
+ self.assertRaises(ZeroDivisionError, Rational, 1, 0)
+
+ def test_read_only(self):
+ r = Rational(3, 4)
+ try:
+ r.numerator = 5
+ except AttributeError:
+ pass
+ else:
+ self.fail('Numerator is not read-only.')
+ try:
+ r.denominator = 5
+ except AttributeError:
+ pass
+ else:
+ self.fail('Denominator is not read-only.')
+
+ def test_from_string(self):
+ self.assertEqual(Rational.from_string('4/3'), Rational(4, 3))
+ self.assertEqual(Rational.from_string('-4/3'), Rational(-4, 3))
+ self.assertRaises(ValueError, Rational.from_string, '+3/5')
+ self.assertRaises(ValueError, Rational.from_string, '3 / 5')
+ self.assertRaises(ValueError, Rational.from_string, '3/-5')
+ self.assertRaises(ValueError, Rational.from_string, 'invalid')
+
+ def test_to_string(self):
+ self.assertEqual(str(Rational(3, 5)), '3/5')
+ self.assertEqual(str(Rational(-3, 5)), '-3/5')
+
+ def test_to_float(self):
+ self.assertEqual(Rational(3, 6).to_float(), 0.5)
+ self.assertEqual(Rational(11, 11).to_float(), 1.0)
+ self.assertEqual(Rational(-2, 8).to_float(), -0.25)
+ self.assertEqual(Rational(0, 3).to_float(), 0.0)
+
+ def test_equality(self):
+ r1 = Rational(2, 1)
+ r2 = Rational(2, 1)
+ r3 = Rational(8, 4)
+ r4 = Rational(3, 2)
+ self.assertEqual(r1, r2)
+ self.assertEqual(r1, r3)
+ self.assertNotEqual(r1, r4)
diff --git a/test/xmp.py b/test/xmp.py
new file mode 100644
index 0000000..a61e375
--- /dev/null
+++ b/test/xmp.py
@@ -0,0 +1,370 @@
+# -*- coding: utf-8 -*-
+
+# ******************************************************************************
+#
+# Copyright (C) 2009 Olivier Tilloy <olivier@tilloy.net>
+#
+# This file is part of the pyexiv2 distribution.
+#
+# pyexiv2 is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# pyexiv2 is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyexiv2; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA.
+#
+# Author: Olivier Tilloy <olivier@tilloy.net>
+#
+# ******************************************************************************
+
+import unittest
+
+from pyexiv2.xmp import XmpTag, XmpValueError
+from pyexiv2.utils import FixedOffset
+
+import datetime
+
+
+class ImageMetadataMock(object):
+
+ tags = {}
+
+ def _set_xmp_tag_value(self, key, value):
+ self.tags[key] = value
+
+ def _delete_xmp_tag(self, key):
+ try:
+ del self.tags[key]
+ except KeyError:
+ pass
+
+
+class TestXmpTag(unittest.TestCase):
+
+ def test_convert_to_python_bag(self):
+ xtype = 'bag Text'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_python('', xtype), [])
+ self.assertEqual(XmpTag._convert_to_python('One value only', xtype), [u'One value only'])
+ self.assertEqual(XmpTag._convert_to_python('Some, text, keyword, this is a test', xtype),
+ [u'Some', u'text', u'keyword', u'this is a test'])
+
+ def test_convert_to_string_bag(self):
+ xtype = 'bag Text'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_string([], xtype), '')
+ self.assertEqual(XmpTag._convert_to_string(['One value only'], xtype), 'One value only')
+ self.assertEqual(XmpTag._convert_to_string([u'One value only'], xtype), 'One value only')
+ self.assertEqual(XmpTag._convert_to_string([u'Some', u'text', u'keyword', u'this is a test'], xtype),
+ 'Some, text, keyword, this is a test')
+ self.assertEqual(XmpTag._convert_to_string(['Some ', ' text '], xtype),
+ 'Some , text ')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, 'invalid', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, [1, 2, 3], xtype)
+
+ def test_convert_to_python_boolean(self):
+ xtype = 'Boolean'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_python('True', xtype), True)
+ self.assertEqual(XmpTag._convert_to_python('False', xtype), False)
+ # Invalid values: not converted
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, 'invalid', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, None, xtype)
+
+ def test_convert_to_string_boolean(self):
+ xtype = 'Boolean'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_string(True, xtype), 'True')
+ self.assertEqual(XmpTag._convert_to_string(False, xtype), 'False')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, 'invalid', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, None, xtype)
+
+ def test_convert_to_python_date(self):
+ xtype = 'Date'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_python('1999', xtype),
+ datetime.date(1999, 1, 1))
+ self.assertEqual(XmpTag._convert_to_python('1999-10', xtype),
+ datetime.date(1999, 10, 1))
+ self.assertEqual(XmpTag._convert_to_python('1999-10-13', xtype),
+ datetime.date(1999, 10, 13))
+ self.assertEqual(XmpTag._convert_to_python('1999-10-13T05:03Z', xtype) - \
+ datetime.datetime(1999, 10, 13, 5, 3, tzinfo=FixedOffset()),
+ datetime.timedelta(0))
+ self.assertEqual(XmpTag._convert_to_python('1999-10-13T05:03+06:00', xtype) - \
+ datetime.datetime(1999, 10, 13, 5, 3, tzinfo=FixedOffset('+', 6, 0)),
+ datetime.timedelta(0))
+ self.assertEqual(XmpTag._convert_to_python('1999-10-13T05:03-06:00', xtype) - \
+ datetime.datetime(1999, 10, 13, 5, 3, tzinfo=FixedOffset('-', 6, 0)),
+ datetime.timedelta(0))
+ self.assertEqual(XmpTag._convert_to_python('1999-10-13T05:03:54Z', xtype) - \
+ datetime.datetime(1999, 10, 13, 5, 3, 54, tzinfo=FixedOffset()),
+ datetime.timedelta(0))
+ self.assertEqual(XmpTag._convert_to_python('1999-10-13T05:03:54+06:00', xtype) - \
+ datetime.datetime(1999, 10, 13, 5, 3, 54, tzinfo=FixedOffset('+', 6, 0)),
+ datetime.timedelta(0))
+ self.assertEqual(XmpTag._convert_to_python('1999-10-13T05:03:54-06:00', xtype) - \
+ datetime.datetime(1999, 10, 13, 5, 3, 54, tzinfo=FixedOffset('-', 6, 0)),
+ datetime.timedelta(0))
+ self.assertEqual(XmpTag._convert_to_python('1999-10-13T05:03:54.721Z', xtype) - \
+ datetime.datetime(1999, 10, 13, 5, 3, 54, 721000, tzinfo=FixedOffset()),
+ datetime.timedelta(0))
+ self.assertEqual(XmpTag._convert_to_python('1999-10-13T05:03:54.721+06:00', xtype) - \
+ datetime.datetime(1999, 10, 13, 5, 3, 54, 721000, tzinfo=FixedOffset('+', 6, 0)),
+ datetime.timedelta(0))
+ self.assertEqual(XmpTag._convert_to_python('1999-10-13T05:03:54.721-06:00', xtype) - \
+ datetime.datetime(1999, 10, 13, 5, 3, 54, 721000, tzinfo=FixedOffset('-', 6, 0)),
+ datetime.timedelta(0))
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, 'invalid', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, '11/10/1983', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, '-1000', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, '2009-13', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, '2009-10-32', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, '2009-10-30T25:12Z', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, '2009-10-30T23:67Z', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, '2009-01-22T21', xtype)
+
+ def test_convert_to_string_date(self):
+ xtype = 'Date'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_string(datetime.date(2009, 2, 4), xtype),
+ '2009-02-04')
+ self.assertEqual(XmpTag._convert_to_string(datetime.datetime(1999, 10, 13), xtype),
+ '1999-10-13')
+ self.assertEqual(XmpTag._convert_to_string(datetime.datetime(1999, 10, 13, 5, 3, tzinfo=FixedOffset()), xtype),
+ '1999-10-13T05:03Z')
+ self.assertEqual(XmpTag._convert_to_string(datetime.datetime(1999, 10, 13, 5, 3, tzinfo=FixedOffset('+', 5, 30)), xtype),
+ '1999-10-13T05:03+05:30')
+ self.assertEqual(XmpTag._convert_to_string(datetime.datetime(1999, 10, 13, 5, 3, tzinfo=FixedOffset('-', 11, 30)), xtype),
+ '1999-10-13T05:03-11:30')
+ self.assertEqual(XmpTag._convert_to_string(datetime.datetime(1999, 10, 13, 5, 3, 27, tzinfo=FixedOffset()), xtype),
+ '1999-10-13T05:03:27Z')
+ self.assertEqual(XmpTag._convert_to_string(datetime.datetime(1999, 10, 13, 5, 3, 27, tzinfo=FixedOffset('+', 5, 30)), xtype),
+ '1999-10-13T05:03:27+05:30')
+ self.assertEqual(XmpTag._convert_to_string(datetime.datetime(1999, 10, 13, 5, 3, 27, tzinfo=FixedOffset('-', 11, 30)), xtype),
+ '1999-10-13T05:03:27-11:30')
+ self.assertEqual(XmpTag._convert_to_string(datetime.datetime(1999, 10, 13, 5, 3, 27, 124300, tzinfo=FixedOffset()), xtype),
+ '1999-10-13T05:03:27.1243Z')
+ self.assertEqual(XmpTag._convert_to_string(datetime.datetime(1999, 10, 13, 5, 3, 27, 124300, tzinfo=FixedOffset('+', 5, 30)), xtype),
+ '1999-10-13T05:03:27.1243+05:30')
+ self.assertEqual(XmpTag._convert_to_string(datetime.datetime(1999, 10, 13, 5, 3, 27, 124300, tzinfo=FixedOffset('-', 11, 30)), xtype),
+ '1999-10-13T05:03:27.1243-11:30')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, 'invalid', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, None, xtype)
+
+ def test_convert_to_python_integer(self):
+ xtype = 'Integer'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_python('23', xtype), 23)
+ self.assertEqual(XmpTag._convert_to_python('+5628', xtype), 5628)
+ self.assertEqual(XmpTag._convert_to_python('-4', xtype), -4)
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, 'abc', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, '5,64', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, '47.0001', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, '1E3', xtype)
+
+ def test_convert_to_string_integer(self):
+ xtype = 'Integer'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_string(123, xtype), '123')
+ self.assertEqual(XmpTag._convert_to_string(-57, xtype), '-57')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, 'invalid', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, 3.14, xtype)
+
+ def test_convert_to_python_langalt(self):
+ xtype = 'Lang Alt'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_python('lang="x-default" some text', xtype),
+ {'x-default': u'some text'})
+ self.assertEqual(XmpTag._convert_to_python('lang="x-default" some text, lang="fr-FR" du texte', xtype),
+ {'x-default': u'some text', 'fr-FR': u'du texte'})
+ self.assertEqual(XmpTag._convert_to_python('lang="x-default" some text , lang="fr-FR" du texte ', xtype),
+ {'x-default': u'some text ', 'fr-FR': u' du texte '})
+ self.assertEqual(XmpTag._convert_to_python('lang="x-default" some text, lang="fr-FR" du texte, lang="es-ES" un texto', xtype),
+ {'x-default': u'some text', 'fr-FR': u'du texte', 'es-ES': u'un texto'})
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, 'invalid', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, 'lang="malformed', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, 'xlang="x-default" some text', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, 'lang="x-default" some text, xlang="fr-FR" du texte', xtype)
+
+ def test_convert_to_string_langalt(self):
+ xtype = 'Lang Alt'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_string({'x-default': 'some text'}, xtype),
+ 'lang="x-default" some text')
+ self.assertEqual(XmpTag._convert_to_string({'x-default': u'some text'}, xtype),
+ 'lang="x-default" some text')
+ self.assertEqual(XmpTag._convert_to_string({'x-default': 'some text', 'fr-FR': 'du texte'}, xtype),
+ 'lang="x-default" some text, lang="fr-FR" du texte')
+ self.assertEqual(XmpTag._convert_to_string({'x-default': u'some text', 'fr-FR': 'du texte'}, xtype),
+ 'lang="x-default" some text, lang="fr-FR" du texte')
+ self.assertEqual(XmpTag._convert_to_string({'x-default': u'some text', 'fr-FR': 'du texte', 'es-ES': 'un texto'}, xtype),
+ 'lang="x-default" some text, lang="es-ES" un texto, lang="fr-FR" du texte')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, 'invalid', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, {}, xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, {'x-default': 25}, xtype)
+
+ def test_convert_to_python_mimetype(self):
+ xtype = 'MIMEType'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_python('image/jpeg', xtype),
+ {'type': 'image', 'subtype': 'jpeg'})
+ self.assertEqual(XmpTag._convert_to_python('video/ogg', xtype),
+ {'type': 'video', 'subtype': 'ogg'})
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, 'invalid', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, 'image-jpeg', xtype)
+
+ def test_convert_to_string_mimetype(self):
+ xtype = 'MIMEType'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_string({'type': 'image', 'subtype': 'jpeg'}, xtype), 'image/jpeg')
+ self.assertEqual(XmpTag._convert_to_string({'type': 'video', 'subtype': 'ogg'}, xtype), 'video/ogg')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, 'invalid', xtype)
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, {'type': 'image'}, xtype)
+
+ def test_convert_to_python_propername(self):
+ xtype = 'ProperName'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_python('Gérard', xtype), u'Gérard')
+ self.assertEqual(XmpTag._convert_to_python('Python Software Foundation', xtype), u'Python Software Foundation')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, None, xtype)
+
+ def test_convert_to_string_propername(self):
+ xtype = 'ProperName'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_string('Gérard', xtype), 'Gérard')
+ self.assertEqual(XmpTag._convert_to_string(u'Gérard', xtype), 'Gérard')
+ self.assertEqual(XmpTag._convert_to_string(u'Python Software Foundation', xtype), 'Python Software Foundation')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, None, xtype)
+
+ def test_convert_to_python_text(self):
+ xtype = 'Text'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_python('Some text.', xtype), u'Some text.')
+ self.assertEqual(XmpTag._convert_to_python('Some text with exotic chàräctérʐ.', xtype),
+ u'Some text with exotic chàräctérʐ.')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_python, None, xtype)
+
+ def test_convert_to_string_text(self):
+ xtype = 'Text'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_string(u'Some text', xtype), 'Some text')
+ self.assertEqual(XmpTag._convert_to_string(u'Some text with exotic chàräctérʐ.', xtype),
+ 'Some text with exotic chàräctérʐ.')
+ self.assertEqual(XmpTag._convert_to_string('Some text with exotic chàräctérʐ.', xtype),
+ 'Some text with exotic chàräctérʐ.')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, None, xtype)
+
+ def test_convert_to_python_uri(self):
+ xtype = 'URI'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_python('http://example.com', xtype), 'http://example.com')
+ self.assertEqual(XmpTag._convert_to_python('https://example.com', xtype), 'https://example.com')
+ self.assertEqual(XmpTag._convert_to_python('http://localhost:8000/resource', xtype),
+ 'http://localhost:8000/resource')
+ self.assertEqual(XmpTag._convert_to_python('uuid:9A3B7F52214211DAB6308A7391270C13', xtype),
+ 'uuid:9A3B7F52214211DAB6308A7391270C13')
+
+ def test_convert_to_string_uri(self):
+ xtype = 'URI'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_string('http://example.com', xtype), 'http://example.com')
+ self.assertEqual(XmpTag._convert_to_string(u'http://example.com', xtype), 'http://example.com')
+ self.assertEqual(XmpTag._convert_to_string('https://example.com', xtype), 'https://example.com')
+ self.assertEqual(XmpTag._convert_to_string('http://localhost:8000/resource', xtype),
+ 'http://localhost:8000/resource')
+ self.assertEqual(XmpTag._convert_to_string('uuid:9A3B7F52214211DAB6308A7391270C13', xtype),
+ 'uuid:9A3B7F52214211DAB6308A7391270C13')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, None, xtype)
+
+ def test_convert_to_python_url(self):
+ xtype = 'URL'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_python('http://example.com', xtype), 'http://example.com')
+ self.assertEqual(XmpTag._convert_to_python('https://example.com', xtype), 'https://example.com')
+ self.assertEqual(XmpTag._convert_to_python('http://localhost:8000/resource', xtype),
+ 'http://localhost:8000/resource')
+
+ def test_convert_to_string_url(self):
+ xtype = 'URL'
+ # Valid values
+ self.assertEqual(XmpTag._convert_to_string('http://example.com', xtype), 'http://example.com')
+ self.assertEqual(XmpTag._convert_to_string(u'http://example.com', xtype), 'http://example.com')
+ self.assertEqual(XmpTag._convert_to_string('https://example.com', xtype), 'https://example.com')
+ self.assertEqual(XmpTag._convert_to_string('http://localhost:8000/resource', xtype),
+ 'http://localhost:8000/resource')
+ # Invalid values
+ self.failUnlessRaises(XmpValueError, XmpTag._convert_to_string, None, xtype)
+
+ # TODO: other types
+
+
+ def test_set_value_no_metadata(self):
+ tag = XmpTag('Xmp.xmp.ModifyDate', 'ModifyDate', 'Modify Date',
+ 'The date and time the resource was last modified. Note:' \
+ ' The value of this property is not necessarily the same' \
+ "as the file's system modification date because it is " \
+ 'set before the file is saved.', 'Date',
+ '2005-09-07T15:09:51-07:00')
+ old_value = tag.value
+ tag.value = datetime.datetime(2009, 4, 22, 8, 30, 27, tzinfo=FixedOffset())
+ self.failIfEqual(tag.value, old_value)
+
+ def test_set_value_with_metadata(self):
+ tag = XmpTag('Xmp.xmp.ModifyDate', 'ModifyDate', 'Modify Date',
+ 'The date and time the resource was last modified. Note:' \
+ ' The value of this property is not necessarily the same' \
+ "as the file's system modification date because it is " \
+ 'set before the file is saved.', 'Date',
+ '2005-09-07T15:09:51-07:00')
+ tag.metadata = ImageMetadataMock()
+ old_value = tag.value
+ tag.value = datetime.datetime(2009, 4, 22, 8, 30, 27, tzinfo=FixedOffset())
+ self.failIfEqual(tag.value, old_value)
+ self.assertEqual(tag.metadata.tags[tag.key], '2009-04-22T08:30:27Z')
+
+ def test_del_value_no_metadata(self):
+ tag = XmpTag('Xmp.xmp.ModifyDate', 'ModifyDate', 'Modify Date',
+ 'The date and time the resource was last modified. Note:' \
+ ' The value of this property is not necessarily the same' \
+ "as the file's system modification date because it is " \
+ 'set before the file is saved.', 'Date',
+ '2005-09-07T15:09:51-07:00')
+ del tag.value
+ self.failIf(hasattr(tag, 'value'))
+
+ def test_del_value_with_metadata(self):
+ tag = XmpTag('Xmp.xmp.ModifyDate', 'ModifyDate', 'Modify Date',
+ 'The date and time the resource was last modified. Note:' \
+ ' The value of this property is not necessarily the same' \
+ "as the file's system modification date because it is " \
+ 'set before the file is saved.', 'Date',
+ '2005-09-07T15:09:51-07:00')
+ tag.metadata = ImageMetadataMock()
+ tag.metadata._set_xmp_tag_value(tag.key, tag.to_string())
+ self.assertEqual(tag.metadata.tags, {tag.key: '2005-09-07T15:09:51-07:00'})
+ del tag.value
+ self.failIf(hasattr(tag, 'value'))
+ self.failIf(tag.metadata.tags.has_key(tag.key))