PK!\sjexiffield/__init__.py__version__ = '0.1' PK!D%%exiffield/exceptions.pyclass ExifError(Exception): pass PK!.J  exiffield/fields.pyimport json import logging import shutil import subprocess from pathlib import Path from typing import Generator, List from django.core import checks, exceptions from django.db import models from django.db.models.fields.files import FieldFile from django.db.models.signals import post_init, pre_save from jsonfield import JSONField from .exceptions import ExifError logger = logging.getLogger(__name__) def get_exif(file_: FieldFile) -> str: """ Use exiftool to extract exif data from the given file field. """ exiftool_path = shutil.which('exiftool') if not exiftool_path: raise ExifError('Could not find `exiftool`') if not file_._committed: # pipe file content to exiftool file_._file.seek(0) process = subprocess.run( [exiftool_path, '-j', '-l', '-'], check=True, input=file_._file.read(), stdout=subprocess.PIPE, ) return process.stdout else: # pass physical file to exiftool file_path = file_.path return subprocess.check_output( [exiftool_path, '-j', '-l', file_path], ) class ExifField(JSONField): def __init__(self, *args, **kwargs) -> None: """ Extract fields for denormalized exif values. """ self.denormalized_fields = kwargs.pop('denormalized_fields', {}) self.source = kwargs.pop('source', None) self.sync = kwargs.pop('sync', True) kwargs['editable'] = False kwargs['default'] = {} super().__init__(*args, **kwargs) def check(self, **kwargs) -> List[checks.CheckMessage]: """ Check if current configuration is valid. """ errors = super().check(**kwargs) if not self.model._meta.abstract: errors.extend(self._check_for_exiftool()) errors.extend(self._check_fields()) errors.extend(self._check_for_source()) return errors def _check_for_exiftool(self) -> Generator[checks.CheckMessage, None, None]: """ Return an error if `exiftool` is not available. """ if not shutil.which('exiftool'): yield checks.Error( "`exiftool` not found.", hint="Please install `exiftool.`", obj=self, id='exiffield.E001', ) def _check_for_source(self) -> Generator[checks.CheckMessage, None, None]: """ Return errors if the source field is invalid. """ if not self.source: yield checks.Error( f"`self.source` not set on {self.name}.", hint="Set `self.source` to an existing FileField.", obj=self, id='exiffield.E002', ) return # check wether field is valid try: field = self.model._meta.get_field(self.source) except exceptions.FieldDoesNotExist: yield checks.Error( f"`{self.source}` not found on {self.model}.", hint="Check spelling or add field to model.", obj=self, id='exiffield.E003', ) return if not isinstance(field, models.FileField): yield checks.Error( f"`{self.source}` on {self.model} must be a FileField.", obj=self, id='exiffield.E004', ) def _check_fields(self) -> Generator[checks.CheckMessage, None, None]: """ Return errors if any denormalized field is editable. python out loud """ if not isinstance(self.denormalized_fields, dict): yield checks.Error( f"`denormalized_fields` on {self.model} should be a dictionary.", hint="Check the kwargs of `ExifField`", obj=self, id='exiffield.E005', ) return for fieldname, func in self.denormalized_fields.items(): try: field = self.model._meta.get_field(fieldname) except exceptions.FieldDoesNotExist: yield checks.Error( f"`{fieldname}` not found on {self.model}.", hint="Check spelling or add field to model.", obj=self, id='exiffield.E006', ) continue if field.editable: yield checks.Error( f"`{fieldname}` on {self.model} should not be editable.", hint=f"Set `editable=False` on {fieldname}.", obj=self, id='exiffield.E007', ) if not callable(func): yield checks.Error( f"`Value for {fieldname}` on {self.model} should not be a callable.", hint=f"Check your values for `denormalized_fields`", obj=self, id='exiffield.E008', ) def contribute_to_class( self, cls: models.Model, name: str, **kwargs, ) -> None: """ Register signals for retrieving and writing of exif data. """ super().contribute_to_class(cls, name, **kwargs) # Only run post-initialization exif update on non-abstract models if not cls._meta.abstract: if self.sync: pre_save.connect(self.update_exif, sender=cls) # denormalize exif values pre_save.connect(self.denormalize_exif, sender=cls) post_init.connect(self.denormalize_exif, sender=cls) def denormalize_exif( self, instance: models.Model, **kwargs, ) -> None: """ Update denormalized fields with new exif values. """ exif_data = getattr(instance, self.name) if not exif_data: return for model_field, extract_from_exif in self.denormalized_fields.items(): value = None try: value = extract_from_exif(exif_data) except Exception: logger.warning( 'Could not execute `%s` to extract value for `%s.%s`', extract_from_exif.__name__, instance.__class__.__name__, model_field, exc_info=True, ) if not value: continue setattr(instance, model_field, value) def update_exif( self, instance: models.Model, force: bool = False, commit: bool = False, **kwargs, ) -> None: """ Load exif data from file. """ file_ = getattr(instance, self.source) if not file_: # there is no file attached to the FileField return # check whether extraction of the exif is required exif_data = getattr(instance, self.name, None) or {} has_exif = bool(exif_data) filename = Path(file_.path).name exif_for_filename = exif_data.get('FileName', {}).get('val', '') file_changed = exif_for_filename != filename or not file_._committed if has_exif and not file_changed and not force: # nothing to do since the file has not been changed return try: exif_json = get_exif(file_) except Exception: logger.exception('Could not read metainformation from file: %s', file_.path) return try: exif_data = json.loads(exif_json)[0] except IndexError: return else: if 'FileName' not in exif_data: # If the file is uncommited, exiftool cannot extract a filenmae # We guess, that no other file with the same filename exists in # the storage. # In the worst case the exif is extracted twice... exif_data['FileName'] = { 'desc': 'Filename', 'val': filename, } setattr(instance, self.name, exif_data) if commit: instance.save() PK!C4 4 exiffield/getters.pyimport datetime from enum import Enum from typing import Any, Callable, Dict, Optional from choicesenum import ChoicesEnum from .exceptions import ExifError ExifType = Dict[str, Dict[str, Any]] class Orientation(ChoicesEnum, Enum): # NOTE inherits from `Enum` to make `mypy` happy LANDSCAPE = 'landscape' PORTRAIT = 'portrait' class Mode(ChoicesEnum, Enum): # NOTE inherits from `Enum` to make `mypy` happy TIMELAPSE = 'timelapse' BURST = 'burst' BRACKETING = 'bracketing' SINGLE = 'single' def exifgetter(field: str) -> Callable[[ExifType], Any]: """ Return the unmodified value. """ def inner(exif: ExifType) -> Any: return exif[field]['val'] inner.__name__ = f'exifgetter(\'{field}\')' return inner def get_type(exif: ExifType) -> str: """ Return type of file, e.g. image. """ return exif['MIMEType']['val'].split('/')[0] def get_datetaken(exif: ExifType) -> Optional[datetime.datetime]: """ Return when the file was created. """ for key in ['DateTimeOriginal', 'GPSDateTime']: try: datetime_str = exif[key]['val'] except KeyError: continue try: return datetime.datetime.strptime( datetime_str, '%Y:%m:%d %H:%M:%S', ) except ValueError as e: raise ExifError(f'Could not parse {datetime_str}') from e raise ExifError(f'Could not find date') def get_orientation(exif: ExifType) -> Orientation: """ Return orientation of the file. """ orientation = exif['Orientation']['num'] width, height = exif['ImageWidth']['val'], exif['ImageHeight']['val'] if orientation > 4: # image rotated image by 90 degrees width, height = height, width if width < height: return Orientation.PORTRAIT return Orientation.LANDSCAPE def get_sequencetype(exif) -> Mode: """ Return the recoding mode. """ # burst or bracketing try: mode = exif['BurstMode']['num'] except KeyError: pass else: if mode == 1: return Mode.BURST if mode == 2: return Mode.BRACKETING # time lapse try: mode = exif['TimerRecording']['num'] except KeyError: pass else: if mode == 1: return Mode.TIMELAPSE return Mode.SINGLE def get_sequencenumber(exif) -> int: """ Return position of image within the recoding sequence. """ try: return exif['SequenceNumber']['num'] except KeyError: return 0 PK!37(django_exiffield-2.0.0.dist-info/LICENSECopyright (c) 2018, Alexander Frenzel All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the Alexander Frenzel nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. PK!HnHTU&django_exiffield-2.0.0.dist-info/WHEEL A н#Z;/"d&F[xzw@Zpy3Fv]\fi4WZ^EgM_-]#0(q7PK!H<)django_exiffield-2.0.0.dist-info/METADATAX[o6~ׯ Ї$ NfmaԳ8lI{ i9DE;d˱ȠC"~Ρp"4V^eD1ױ|RYhNi4\>!OΈYF*fM׬r*SbOsb Jv-)Otޕ6L{]DLoGxG^iư1D-A&DJY"$PY `d&L!ђ ѝRFv bM5&Fks.sY86rUzkETF4w ;s~Y8 oR6R%Dz` ҵC[(nFlPF/lwXn CFkdx5%}fslRb QRzzow8ƺT ̭;IQF%mKεNLm+z,Yhʢ)_<' JglՅWDv*HN$ $twzIE.|IuvsܛvR[Y'FdTftH3`ql(V$~/XE8l:h^80"|X e CsK֡'\/x3R9և>þW/I3+'95POhce7FV+n Ś\I|PY!ル<5BӸ8vŗeg>_zT%2>siNKƅN\B4^ʤtf:#|:Mae%e1d TxR8[7 heGе/N!q+Vb0?Zi(iM&C٢a3N}Ui "#1$㱒x̚S3M?"/3_"ay*3"i&>@\Oi[>|Fqr㑯{5Y~cGà)!Bk*8ADm~C"gSVrvmRDPiw䚠ٺ73Ni):7\\q>Q4RgG($NN ^w>Th0YVg*I T-U%NܻlM-\-m 9@PwKU)[5IO7nqVuQpE>ha.sf - >t)zhSYU$uD4i ?Cpc 3ѡ9b[T%H`_چt/޺74Å0 ,SGhȬ촏rb> pρ&>ls\#Ɏv&]r.S9SҾyqQeۣ;a?xeQTZbK74'9@||퍊aI>猪R3tU `g>*-qakarC,%k#ph)ja(rOr X &¯lW滽 .~o Y,v 7TÀ m@>Sz*ih۟FcZx7x1>|_^_\ nG[G8طnS`LhlE*~lZEeZ:^`&M2Zbo {3$⒁Ei˴\' La B5wTEԪ`!l& [=7tWj-K#AZMùkW% -Ѳ24a-/7vFL6D_rap&Y70dHR6bz+Vn}1r0 VZBG =KQM>QHw7?\'>ډWt= ^ +p_ю*3T!qo1D|ͥJ uqQqXxuiԩx AQ;PK!H  }'django_exiffield-2.0.0.dist-info/RECORD͹@,ՀN6'yiu9FMEs} 11"Y]WwWK=ILNM 5(6B3i\ueUP Cf \~l;+,>[Q~8}_˂%kG3tkI[,[Yg4y+猋t )[>1\&ifUq[tѯI2exIݧɯ@ٺzVՀfT]ʓ\/As>pVdxRB_}mHH84-/؅<\']|<— yP`@nĦn\' HSCfO++PK!\sjexiffield/__init__.pyPK!D%%Gexiffield/exceptions.pyPK!.J  exiffield/fields.pyPK!C4 4 Q!exiffield/getters.pyPK!37(+django_exiffield-2.0.0.dist-info/LICENSEPK!HnHTU&2django_exiffield-2.0.0.dist-info/WHEELPK!H<)2django_exiffield-2.0.0.dist-info/METADATAPK!H  }'|;django_exiffield-2.0.0.dist-info/RECORDPKaN=