diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index e6ac8118..2f25f355 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -2420,9 +2420,9 @@ def _query( has_likes=False, no_likes=False, ): - """ Run a query against PhotosDB to extract the photos based on user supply criteria used by query and export commands + """Run a query against PhotosDB to extract the photos based on user supply criteria used by query and export commands - Args: + Args: photosdb: PhotosDB object """ @@ -3201,8 +3201,8 @@ def load_uuid_from_file(filename): def write_export_report(report_file, results): - """ write CSV report with results from export - + """write CSV report with results from export + Args: report_file: path to report file results: ExportResults object @@ -3331,7 +3331,7 @@ def write_export_report(report_file, results): def cleanup_files(dest_path, files_to_keep, fileutil): - """ cleanup dest_path by deleting and files and empty directories + """cleanup dest_path by deleting and files and empty directories not in files_to_keep Args: @@ -3375,8 +3375,8 @@ def write_finder_tags( exiftool_merge_keywords=None, finder_tag_template=None, ): - """ Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written - + """Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written + Args: photo: a PhotoInfo object files: list of file paths to write Finder tags to @@ -3384,7 +3384,7 @@ def write_finder_tags( keyword_template: list of keyword templates to evaluate for determining keywords album_keyword: if True, use album names as keywords person_keyword: if True, use person in image as keywords - exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords + exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords finder_tag_template: list of templates to evaluate for determining Finder tags Returns: diff --git a/osxphotos/_version.py b/osxphotos/_version.py index de56f1bc..781d74ee 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,5 +1,5 @@ """ version info """ -__version__ = "0.39.0" +__version__ = "0.39.1" diff --git a/osxphotos/exiftool.py b/osxphotos/exiftool.py index 2f0972ee..8f83eb4a 100644 --- a/osxphotos/exiftool.py +++ b/osxphotos/exiftool.py @@ -33,8 +33,8 @@ def get_exiftool_path(): class _ExifToolProc: - """ Runs exiftool in a subprocess via Popen - Creates a singleton object """ + """Runs exiftool in a subprocess via Popen + Creates a singleton object""" def __new__(cls, *args, **kwargs): """ create new object or return instance of already created singleton """ @@ -44,8 +44,8 @@ class _ExifToolProc: return cls.instance def __init__(self, exiftool=None): - """ construct _ExifToolProc singleton object or return instance of already created object - exiftool: optional path to exiftool binary (if not provided, will search path to find it) """ + """construct _ExifToolProc singleton object or return instance of already created object + exiftool: optional path to exiftool binary (if not provided, will search path to find it)""" if hasattr(self, "_process_running") and self._process_running: # already running @@ -56,8 +56,8 @@ class _ExifToolProc: ) return - self._exiftool = exiftool or get_exiftool_path() self._process_running = False + self._exiftool = exiftool or get_exiftool_path() self._start_proc() @property @@ -106,8 +106,8 @@ class _ExifToolProc: def _stop_proc(self): """ stop the exiftool process if it's running, otherwise, do nothing """ + if not self._process_running: - logging.warning("exiftool process is not running") return self._process.stdin.write(b"-stay_open\n") @@ -133,7 +133,7 @@ class ExifTool: """ Basic exiftool interface for reading and writing EXIF tags """ def __init__(self, filepath, exiftool=None, overwrite=True, flags=None): - """ Create ExifTool object + """Create ExifTool object Args: file: path to image file @@ -157,15 +157,15 @@ class ExifTool: self._read_exif() def setvalue(self, tag, value): - """ Set tag to value(s); if value is None, will delete tag + """Set tag to value(s); if value is None, will delete tag Args: tag: str; name of tag to set value: str; value to set tag to - + Returns: True if success otherwise False - + If error generated by exiftool, returns False and sets self.error to error string If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string If called in context manager, returns True (execution is delayed until exiting context manager) @@ -184,26 +184,26 @@ class ExifTool: return error is None def addvalues(self, tag, *values): - """ Add one or more value(s) to tag + """Add one or more value(s) to tag If more than one value is passed, each value will be added to the tag Args: tag: str; tag to set *values: str; one or more values to set - + Returns: True if success otherwise False - + If error generated by exiftool, returns False and sets self.error to error string If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string If called in context manager, returns True (execution is delayed until exiting context manager) - + Notes: exiftool may add duplicate values for some tags so the caller must ensure the values being added are not already in the EXIF data - For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords, + For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords, but for others, such as EXIF:ISO, this will literally add a value to the existing value. It's up to the caller to know what exiftool will do for each tag - If setvalue called before addvalues, exiftool does not appear to add duplicates, + If setvalue called before addvalues, exiftool does not appear to add duplicates, but if addvalues called without first calling setvalue, exiftool will add duplicate values """ if not values: @@ -226,7 +226,7 @@ class ExifTool: return error is None def run_commands(self, *commands, no_file=False): - """ Run commands in the exiftool process and return result. + """Run commands in the exiftool process and return result. Args: *commands: exiftool commands to run @@ -301,8 +301,8 @@ class ExifTool: return ver.decode("utf-8") def asdict(self): - """ return dictionary of all EXIF tags and values from exiftool - returns empty dict if no tags + """return dictionary of all EXIF tags and values from exiftool + returns empty dict if no tags """ json_str, _, _ = self.run_commands("-json") if json_str: diff --git a/osxphotos/photoinfo/photoinfo.py b/osxphotos/photoinfo/photoinfo.py index f5a60800..083eb07b 100644 --- a/osxphotos/photoinfo/photoinfo.py +++ b/osxphotos/photoinfo/photoinfo.py @@ -86,8 +86,8 @@ class PhotoInfo: @property def original_filename(self): - """ original filename of the picture - Photos 5 mangles filenames upon import """ + """original filename of the picture + Photos 5 mangles filenames upon import""" if ( self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw @@ -106,8 +106,8 @@ class PhotoInfo: @property def date_modified(self): - """ image modification date as timezone aware datetime object - or None if no modification date set """ + """image modification date as timezone aware datetime object + or None if no modification date set""" # Photos <= 4 provides no way to get date of adjustment and will update # lastmodifieddate anytime photo database record is updated (e.g. adding tags) @@ -492,9 +492,9 @@ class PhotoInfo: @property def ismissing(self): - """ returns true if photo is missing from disk (which means it's not been downloaded from iCloud) + """returns true if photo is missing from disk (which means it's not been downloaded from iCloud) NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos - do not immediately get written to disk. In particular, I've noticed that downloading + do not immediately get written to disk. In particular, I've noticed that downloading an image from the cloud does not force the database to be updated until something else e.g. an edit, keyword, etc. occurs forcing a database synch The exact process / timing is a mystery to be but be aware that if some photos were recently @@ -539,8 +539,8 @@ class PhotoInfo: @property def shared(self): - """ returns True if photos is in a shared iCloud album otherwise false - Only valid on Photos 5; returns None on older versions """ + """returns True if photos is in a shared iCloud album otherwise false + Only valid on Photos 5; returns None on older versions""" if self._db._db_version > _PHOTOS_4_VERSION: return self._info["shared"] else: @@ -548,8 +548,8 @@ class PhotoInfo: @property def uti(self): - """ Returns Uniform Type Identifier (UTI) for the image - for example: public.jpeg or com.apple.quicktime-movie + """Returns Uniform Type Identifier (UTI) for the image + for example: public.jpeg or com.apple.quicktime-movie """ if self._db._db_version <= _PHOTOS_4_VERSION: if self.hasadjustments: @@ -564,8 +564,8 @@ class PhotoInfo: @property def uti_original(self): - """ Returns Uniform Type Identifier (UTI) for the original image - for example: public.jpeg or com.apple.quicktime-movie + """Returns Uniform Type Identifier (UTI) for the original image + for example: public.jpeg or com.apple.quicktime-movie """ if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]: return self._info["raw_pair_info"]["UTI"] @@ -577,9 +577,9 @@ class PhotoInfo: @property def uti_edited(self): - """ Returns Uniform Type Identifier (UTI) for the edited image - if the photo has been edited, otherwise None; - for example: public.jpeg + """Returns Uniform Type Identifier (UTI) for the edited image + if the photo has been edited, otherwise None; + for example: public.jpeg """ if self._db._db_version >= _PHOTOS_5_VERSION: return self.uti if self.hasadjustments else None @@ -588,36 +588,34 @@ class PhotoInfo: @property def uti_raw(self): - """ Returns Uniform Type Identifier (UTI) for the RAW image if there is one - for example: com.canon.cr2-raw-image - Returns None if no associated RAW image + """Returns Uniform Type Identifier (UTI) for the RAW image if there is one + for example: com.canon.cr2-raw-image + Returns None if no associated RAW image """ return self._info["UTI_raw"] @property def ismovie(self): - """ Returns True if file is a movie, otherwise False - """ + """Returns True if file is a movie, otherwise False""" return True if self._info["type"] == _MOVIE_TYPE else False @property def isphoto(self): - """ Returns True if file is an image, otherwise False - """ + """Returns True if file is an image, otherwise False""" return True if self._info["type"] == _PHOTO_TYPE else False @property def incloud(self): - """ Returns True if photo is cloud asset and is synched to cloud - False if photo is cloud asset and not yet synched to cloud - None if photo is not cloud asset + """Returns True if photo is cloud asset and is synched to cloud + False if photo is cloud asset and not yet synched to cloud + None if photo is not cloud asset """ return self._info["incloud"] @property def iscloudasset(self): - """ Returns True if photo is a cloud asset (in an iCloud library), - otherwise False + """Returns True if photo is a cloud asset (in an iCloud library), + otherwise False """ if self._db._db_version <= _PHOTOS_4_VERSION: return ( @@ -636,9 +634,9 @@ class PhotoInfo: @property def burst_photos(self): - """ If photo is a burst photo, returns list of PhotoInfo objects - that are part of the same burst photo set; otherwise returns empty list. - self is not included in the returned list """ + """If photo is a burst photo, returns list of PhotoInfo objects + that are part of the same burst photo set; otherwise returns empty list. + self is not included in the returned list""" if self._info["burst"]: burst_uuid = self._info["burstUUID"] return [ @@ -656,9 +654,9 @@ class PhotoInfo: @property def path_live_photo(self): - """ Returns path to the associated video file for a live photo - If photo is not a live photo, returns None - If photo is missing, returns None """ + """Returns path to the associated video file for a live photo + If photo is not a live photo, returns None + If photo is missing, returns None""" photopath = None if self._db._db_version <= _PHOTOS_4_VERSION: @@ -785,9 +783,9 @@ class PhotoInfo: @property def raw_original(self): - """ returns True if associated raw image and the raw image is selected in Photos - via "Use RAW as Original " - otherwise returns False """ + """returns True if associated raw image and the raw image is selected in Photos + via "Use RAW as Original " + otherwise returns False""" return self._info["raw_is_original"] @property @@ -841,20 +839,20 @@ class PhotoInfo: Args: template_str: a template string with fields to render none_str: a str to use if template field renders to None, default is "_". - path_sep: a single character str to use as path separator when joining + path_sep: a single character str to use as path separator when joining fields like folder_album; if not provided, defaults to os.path.sep - expand_inplace: expand multi-valued substitutions in-place as a single string + expand_inplace: expand multi-valued substitutions in-place as a single string instead of returning individual strings inplace_sep: optional string to use as separator between multi-valued keywords with expand_inplace; default is ',' filename: if True, template output will be sanitized to produce valid file name - dirname: if True, template output will be sanitized to produce valid directory name + dirname: if True, template output will be sanitized to produce valid directory name replacement: str, value to replace any illegal file path characters with; default = ":" - + Returns: ([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values """ - template = PhotoTemplate(self) + template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path) return template.render( template_str, none_str=none_str, @@ -877,11 +875,11 @@ class PhotoInfo: return self._info["latitude"] def _get_album_uuids(self): - """ Return list of album UUIDs this photo is found in - + """Return list of album UUIDs this photo is found in + Filters out albums in the trash and any special album types - Returns: list of album UUIDs + Returns: list of album UUIDs """ if self._db._db_version <= _PHOTOS_4_VERSION: version4 = True diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py index c34ddad8..d4a61bb9 100644 --- a/osxphotos/phototemplate.py +++ b/osxphotos/phototemplate.py @@ -149,13 +149,15 @@ MULTI_VALUE_SUBSTITUTIONS = [ class PhotoTemplate: """ PhotoTemplate class to render a template string from a PhotoInfo object """ - def __init__(self, photo): - """ Inits PhotoTemplate class with photo, non_str, and path_sep + def __init__(self, photo, exiftool_path=None): + """ Inits PhotoTemplate class with photo Args: photo: a PhotoInfo instance. + exiftool_path: optional path to exiftool for use with {exiftool:} template; if not provided, will look for exiftool in $PATH """ self.photo = photo + self.exiftool_path = exiftool_path # holds value of current date/time for {today.x} fields # gets initialized in get_template_value @@ -517,7 +519,7 @@ class PhotoTemplate: if not self.photo.path: values = [None] else: - exif = ExifTool(self.photo.path) + exif = ExifTool(self.photo.path, exiftool=self.exiftool_path) exifdict = exif.asdict() exifdict = {k.lower(): v for (k, v) in exifdict.items()} subfield = subfield.lower() diff --git a/tests/test_cli.py b/tests/test_cli.py index cf57815b..83964fa7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -357,6 +357,7 @@ CLI_EXIFTOOL = { "XMP:TagsList": "Kids", "XMP:Title": "I found one!", "EXIF:ImageDescription": "Girl holding pumpkin", + "EXIF:Make": "Canon", "XMP:Description": "Girl holding pumpkin", "XMP:PersonInImage": "Katie", "XMP:Subject": "Kids", @@ -1114,6 +1115,54 @@ def test_export_exiftool_path(): assert exif[key] == CLI_EXIFTOOL[uuid][key] +@pytest.mark.skipif(exiftool is None, reason="exiftool not installed") +def test_export_exiftool_path_render_template(): + """ test --exiftool-path with {exiftool:} template rendering """ + import glob + import os + import os.path + import re + import shutil + import sys + import tempfile + from osxphotos.__main__ import export + from osxphotos.exiftool import ExifTool + from osxphotos.utils import noop + + exiftool_source = osxphotos.exiftool.get_exiftool_path() + + # monkey patch get_exiftool_path so it returns None + get_exiftool_path = osxphotos.exiftool.get_exiftool_path + osxphotos.exiftool.get_exiftool_path = noop + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + tempdir = tempfile.TemporaryDirectory() + exiftool_path = os.path.join(tempdir.name, "myexiftool") + shutil.copy2(exiftool_source, exiftool_path) + for uuid in CLI_EXIFTOOL: + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_6), + ".", + "-V", + "--filename", + "{original_name}_{exiftool:EXIF:Make}", + "--uuid", + f"{uuid}", + "--exiftool-path", + exiftool_path, + ], + ) + assert result.exit_code == 0 + assert re.search(r"Exporting.*Canon", result.output) + + osxphotos.exiftool.get_exiftool_path = get_exiftool_path + + @pytest.mark.skipif(exiftool is None, reason="exiftool not installed") def test_export_exiftool_ignore_date_modified(): import glob @@ -5066,4 +5115,3 @@ def test_export_finder_tag_template_keywords(): persons = [persons] if type(persons) != list else persons expected = [Tag(x) for x in keywords + persons] assert sorted(md.tags) == sorted(expected) -