Compare commits

..

13 Commits

Author SHA1 Message Date
Rhet Turnbull
b7b06b9fdb Merge pull request #310 from RhetTbull/finder_tags
Added Finder tags, partial implementation for issue #242
2020-12-30 13:52:37 -08:00
Rhet Turnbull
29e424575a Added tests for Finder tags 2020-12-30 13:37:15 -08:00
Rhet Turnbull
ea373c4197 Updated requirements.txt 2020-12-30 08:52:58 -08:00
Rhet Turnbull
f25a299309 Updated README for finder tags 2020-12-30 08:51:01 -08:00
Rhet Turnbull
5885b23d32 Initial implementation for Finder tags 2020-12-30 08:32:42 -08:00
Rhet Turnbull
5dccdf7750 Fixed --exiftool-path bug, issue #308 2020-12-30 07:31:07 -08:00
Rhet Turnbull
e9134f84df Updated CHANGELOG.md 2020-12-29 09:51:18 -08:00
Rhet Turnbull
3872e7ae64 Fixed --exiftool-path to work with --exiftool-merge-keywords/persons 2020-12-29 09:47:03 -08:00
Rhet Turnbull
b3e86dffc8 Updated CHANGELOG.md 2020-12-29 09:46:25 -08:00
Rhet Turnbull
4897fc4b05 Added --exiftool-path to CLI 2020-12-29 09:23:51 -08:00
Rhet Turnbull
1dbf22fdc9 Updated CHANGELOG.md 2020-12-29 08:03:21 -08:00
Rhet Turnbull
fa58af8b88 Added exiftool signature to JSON output, issue #303 2020-12-29 07:51:34 -08:00
Rhet Turnbull
9c9bcb08b3 Updated CHANGELOG.md 2020-12-28 15:42:09 -08:00
11 changed files with 579 additions and 72 deletions

View File

@@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.38.21](https://github.com/RhetTbull/osxphotos/compare/v0.38.20...v0.38.21)
> 29 December 2020
- Fixed --exiftool-path to work with --exiftool-merge-keywords/persons [`3872e7a`](https://github.com/RhetTbull/osxphotos/commit/3872e7ae649f42d849de472a7dbf78a241d54407)
#### [v0.38.20](https://github.com/RhetTbull/osxphotos/compare/v0.38.19...v0.38.20)
> 29 December 2020
- Added --exiftool-path to CLI [`4897fc4`](https://github.com/RhetTbull/osxphotos/commit/4897fc4b05cc7a3bea314f9cce8a2163bf3922b2)
#### [v0.38.19](https://github.com/RhetTbull/osxphotos/compare/v0.38.18...v0.38.19)
> 29 December 2020
- Added exiftool signature to JSON output, issue #303 [`fa58af8`](https://github.com/RhetTbull/osxphotos/commit/fa58af8b883da11fdfa723d2da75a600d927d46e)
#### [v0.38.18](https://github.com/RhetTbull/osxphotos/compare/v0.38.17...v0.38.18)
> 28 December 2020
- Added --exiftool-merge-keywords/persons, issue #299, #292 [`b1cb99f`](https://github.com/RhetTbull/osxphotos/commit/b1cb99f83f55128a314d265d4588134cb79026c6)
#### [v0.38.17](https://github.com/RhetTbull/osxphotos/compare/v0.38.16...v0.38.17)
> 28 December 2020

View File

@@ -363,6 +363,8 @@ Options:
(see also --ignore-date-modified);
QuickTime:GPSCoordinates;
UserData:GPSCoordinates.
--exiftool-path EXIFTOOL_PATH Optionally specify path to exiftool; if not
provided, will look for exiftool in $PATH.
--exiftool-option OPTION Optional flag/option to pass to exiftool
when using --exiftool. For example,
--exiftool-option '-m' to ignore minor
@@ -410,6 +412,20 @@ Options:
could specify --description-template
"{descr} exported with osxphotos on
{today.date}" See Templating System below.
--finder-tag-template TEMPLATE Set Finder tags to TEMPLATE. These tags can
be searched in the Finder or Spotlight with
'tag:tagname' format. For example, '--
finder-tag-template "{label}"' to set Finder
tags to photo labels. You may specify
multiple TEMPLATE values by using '--finder-
tag-template' multiple times. See also '--
finder-tag-keywords and Extended Attributes
below.'.
--finder-tag-keywords Set Finder tags to keywords; any keywords
specified via '--keyword-template', '--
person-keyword', etc. will also be used as
Finder tags. See also '--finder-tag-template
and Extended Attributes below.'.
--directory DIRECTORY Optional template for specifying name of
output directory in the form
'{name,DEFAULT}'. See below for additional
@@ -512,6 +528,24 @@ option to re-export the entire library thus rebuilding the
'.osxphotos_export.db' database.
** Extended Attributes **
Some options (currently '--finder-tag-template' and '--finder-tag-keywords')
write additional metadata to extended attributes in the file. These options
will only work if the destination filesystem supports extended attributes
(most do). For example, --finder-tag-keyword writes all keywords (including
any specified by '--keyword-template' or other options) to Finder tags that
are searchable in Spotlight using the syntax: 'tag:tagname'. For example, if
you have images with keyword "Travel" then using '--finder-tag-keywords' you
could quickly find those images in the Finder by typing 'tag:Travel' in the
Spotlight search bar. Finder tags are written to the
'com.apple.metadata:_kMDItemUserTags' extended attribute. Unlike EXIF
metadata, extended attributes do not modify the actual file. Most cloud
storage services do not synch extended attributes. Dropbox does sync them and
any changes to a file's extended attributes will cause Dropbox to re-sync the
files.
** Templating System **
Several options, such as --directory, allow you to specify a template which

View File

@@ -13,12 +13,14 @@ import unicodedata
import click
import yaml
import osxmetadata
import osxphotos
from ._constants import (
_EXIF_TOOL_URL,
_PHOTOS_4_VERSION,
_UNKNOWN_PLACE,
_OSXPHOTOS_NONE_SENTINEL,
CLI_COLOR_ERROR,
CLI_COLOR_WARNING,
DEFAULT_EDITED_SUFFIX,
@@ -179,6 +181,24 @@ class ExportCommand(click.Command):
+ "You can always run export without the --update option to re-export the entire library thus "
+ f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database."
)
formatter.write("\n\n")
formatter.write_text("** Extended Attributes **")
formatter.write("\n")
formatter.write_text(
"""
Some options (currently '--finder-tag-template' and '--finder-tag-keywords') write
additional metadata to extended attributes in the file. These options will only work
if the destination filesystem supports extended attributes (most do).
For example, --finder-tag-keyword writes all keywords (including any specified by '--keyword-template'
or other options) to Finder tags that are searchable in Spotlight using the syntax: 'tag:tagname'.
For example, if you have images with keyword "Travel" then using '--finder-tag-keywords' you could quickly
find those images in the Finder by typing 'tag:Travel' in the Spotlight search bar.
Finder tags are written to the 'com.apple.metadata:_kMDItemUserTags' extended attribute.
Unlike EXIF metadata, extended attributes do not modify the actual file. Most cloud storage services
do not synch extended attributes. Dropbox does sync them and any changes to a file's extended attributes
will cause Dropbox to re-sync the files.
"""
)
formatter.write("\n\n")
formatter.write_text("** Templating System **")
@@ -1157,8 +1177,9 @@ def query(
_list_libraries()
return
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
photos = _query(
db=db,
photosdb=photosdb,
keyword=keyword,
person=person,
album=album,
@@ -1383,6 +1404,12 @@ def query(
"(video files only): QuickTime:CreationDate; QuickTime:CreateDate; QuickTime:ModifyDate (see also --ignore-date-modified); "
"QuickTime:GPSCoordinates; UserData:GPSCoordinates.",
)
@click.option(
"--exiftool-path",
metavar="EXIFTOOL_PATH",
type=click.Path(exists=True),
help="Optionally specify path to exiftool; if not provided, will look for exiftool in $PATH.",
)
@click.option(
"--exiftool-option",
multiple=True,
@@ -1447,6 +1474,22 @@ def query(
'--description-template "{descr} exported with osxphotos on {today.date}" '
"See Templating System below.",
)
@click.option(
"--finder-tag-template",
metavar="TEMPLATE",
multiple=True,
default=None,
help="Set MacOS Finder tags to TEMPLATE. These tags can be searched in the Finder or Spotlight with "
"'tag:tagname' format. For example, '--finder-tag-template \"{label}\"' to set Finder tags to photo labels. "
"You may specify multiple TEMPLATE values by using '--finder-tag-template' multiple times. "
"See also '--finder-tag-keywords and Extended Attributes below.'.",
)
@click.option(
"--finder-tag-keywords",
is_flag=True,
help="Set MacOS Finder tags to keywords; any keywords specified via '--keyword-template', '--person-keyword', etc. "
"will also be used as Finder tags. See also '--finder-tag-template and Extended Attributes below.'.",
)
@click.option(
"--directory",
metavar="DIRECTORY",
@@ -1587,6 +1630,8 @@ def export(
album_keyword,
keyword_template,
description_template,
finder_tag_template,
finder_tag_keywords,
current_name,
convert_to_jpeg,
jpeg_quality,
@@ -1601,6 +1646,7 @@ def export(
download_missing,
dest,
exiftool,
exiftool_path,
exiftool_option,
exiftool_merge_keywords,
exiftool_merge_persons,
@@ -1678,7 +1724,7 @@ def export(
)
raise click.Abort()
# re-set the local function vars to the corresponding config value
# re-set the local vars to the corresponding config value
# this isn't elegant but avoids having to rewrite this function to use cfg.varname for every parameter
db = cfg.db
photos_library = cfg.photos_library
@@ -1722,6 +1768,8 @@ def export(
album_keyword = cfg.album_keyword
keyword_template = cfg.keyword_template
description_template = cfg.description_template
finder_tag_template = cfg.finder_tag_template
finder_tag_keywords = cfg.finder_tag_keywords
current_name = cfg.current_name
convert_to_jpeg = cfg.convert_to_jpeg
jpeg_quality = cfg.jpeg_quality
@@ -1735,6 +1783,7 @@ def export(
not_live = cfg.not_live
download_missing = cfg.download_missing
exiftool = cfg.exiftool
exiftool_path = cfg.exiftool_path
exiftool_option = cfg.exiftool_option
exiftool_merge_keywords = cfg.exiftool_merge_keywords
exiftool_merge_persons = cfg.exiftool_merge_persons
@@ -1881,10 +1930,15 @@ def export(
not x for x in [skip_edited, skip_bursts, skip_live, skip_raw]
]
# verify exiftool installed an in path
if exiftool:
# verify exiftool installed and in path if path not provided and exiftool will be used
# NOTE: this won't catch use of {exiftool:} in a template
# but those will raise error during template eval if exiftool path not set
if (
any([exiftool, exiftool_merge_keywords, exiftool_merge_persons])
and not exiftool_path
):
try:
_ = get_exiftool_path()
exiftool_path = get_exiftool_path()
except FileNotFoundError:
click.echo(
click.style(
@@ -1896,6 +1950,9 @@ def export(
)
ctx.exit(2)
if any([exiftool, exiftool_merge_keywords, exiftool_merge_persons]):
verbose_(f"exiftool path: {exiftool_path}")
isphoto = ismovie = True # default searches for everything
if only_movies:
isphoto = False
@@ -1977,8 +2034,9 @@ def export(
f"Upgraded export database {export_db_path} from version {upgraded[0]} to {upgraded[1]}"
)
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_, exiftool=exiftool_path)
photos = _query(
db=db,
photosdb=photosdb,
keyword=keyword,
person=person,
album=album,
@@ -2057,8 +2115,10 @@ def export(
original_name = not current_name
results = ExportResults()
if verbose:
for p in photos:
# send progress bar output to /dev/null if verbose to hide the progress bar
fp = open(os.devnull, "w") if verbose else None
with click.progressbar(photos, file=fp) as bar:
for p in bar:
export_results = export_photo(
photo=p,
dest=dest,
@@ -2100,56 +2160,30 @@ def export(
)
results += export_results
# if convert_to_jpeg and p.isphoto and p.uti != "public.jpeg":
# for photo_file in set(
# results.exported + results.updated + results.exif_updated
# ):
# verbose_(f"Converting {photo_file} to jpeg")
else:
# show progress bar
with click.progressbar(photos) as bar:
for p in bar:
export_results = export_photo(
photo=p,
dest=dest,
verbose=verbose,
export_by_date=export_by_date,
sidecar=sidecar,
sidecar_drop_ext=sidecar_drop_ext,
update=update,
ignore_signature=ignore_signature,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
export_edited=export_edited,
skip_original_if_edited=skip_original_if_edited,
original_name=original_name,
export_live=export_live,
download_missing=download_missing,
exiftool=exiftool,
exiftool_merge_keywords=exiftool_merge_keywords,
exiftool_merge_persons=exiftool_merge_persons,
directory=directory,
filename_template=filename_template,
export_raw=export_raw,
if finder_tag_keywords or finder_tag_template:
files = set(
export_results.exported
+ export_results.new
+ export_results.updated
+ export_results.exif_updated
+ export_results.converted_to_jpeg
+ export_results.skipped
)
tags_written, tags_skipped = write_finder_tags(
p,
files,
keywords=finder_tag_keywords,
keyword_template=keyword_template,
album_keyword=album_keyword,
person_keyword=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
export_db=export_db,
fileutil=fileutil,
dry_run=dry_run,
touch_file=touch_file,
edited_suffix=edited_suffix,
original_suffix=original_suffix,
use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
exiftool_option=exiftool_option,
exiftool_merge_keywords=exiftool_merge_keywords,
finder_tag_template=finder_tag_template,
)
results += export_results
results.xattr_written.extend(tags_written)
results.xattr_skipped.extend(tags_skipped)
if fp is not None:
fp.close()
if cleanup:
all_files = (
@@ -2327,7 +2361,7 @@ def print_photo_info(photos, json=False):
def _query(
db=None,
photosdb,
keyword=None,
person=None,
album=None,
@@ -2386,12 +2420,12 @@ 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
arguments must be passed in same order as query and export
if either is modified, need to ensure all three functions are updated"""
""" Run a query against PhotosDB to extract the photos based on user supply criteria used by query and export commands
Args:
photosdb: PhotosDB object
"""
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
if deleted or deleted_only:
photos = photosdb.photos(
uuid=uuid,
@@ -3193,6 +3227,8 @@ def write_export_report(report_file, results):
"error": 0,
"exiftool_warning": "",
"exiftool_error": "",
"extended_attributes_written": 0,
"extended_attributes_skipped": 0,
}
for result in results.all_files()
}
@@ -3254,6 +3290,12 @@ def write_export_report(report_file, results):
for result in results.exiftool_error:
all_results[result[0]]["exiftool_error"] = result[1]
for result in results.xattr_written:
all_results[result]["extended_attributes_written"] = 1
for result in results.xattr_skipped:
all_results[result]["extended_attributes_skipped"] = 1
report_columns = [
"filename",
"exported",
@@ -3270,6 +3312,8 @@ def write_export_report(report_file, results):
"error",
"exiftool_warning",
"exiftool_error",
"extended_attributes_written",
"extended_attributes_skipped",
]
try:
@@ -3321,5 +3365,84 @@ def cleanup_files(dest_path, files_to_keep, fileutil):
return (deleted_files, deleted_dirs)
def write_finder_tags(
photo,
files,
keywords=False,
keyword_template=None,
album_keyword=None,
person_keyword=None,
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
Args:
photo: a PhotoInfo object
files: list of file paths to write Finder tags to
keywords: if True, sets Finder tags to all keywords including any evaluated from keyword_template, album_keyword, person_keyword, exiftool_merge_keywords
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
finder_tag_template: list of templates to evaluate for determining Finder tags
Returns:
(list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating)
"""
tags = []
written = []
skipped = []
if keywords:
# match whatever keywords would've been used in --exiftool or --sidecar
exif = photo._exiftool_dict(
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
merge_exif_keywords=exiftool_merge_keywords,
)
try:
if exif["IPTC:Keywords"]:
tags.extend(exif["IPTC:Keywords"])
except KeyError:
pass
if finder_tag_template:
rendered_tags = []
for template_str in finder_tag_template:
rendered, unmatched = photo.render_template(
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
if unmatched:
click.echo(
click.style(
f"Warning: unmatched template substitution for template: {template_str} {unmatched}",
fg=CLI_COLOR_WARNING,
),
err=True,
)
rendered_tags.extend(rendered)
# filter out any template values that didn't match by looking for sentinel
rendered_tags = [
tag for tag in rendered_tags if _OSXPHOTOS_NONE_SENTINEL not in tag
]
tags.extend(rendered_tags)
tags = [osxmetadata.Tag(tag) for tag in set(tags)]
for f in files:
md = osxmetadata.OSXMetaData(f)
if sorted(md.tags) != sorted(tags):
verbose_(f"Writing Finder tags to {f}")
md.tags = tags
written.append(f)
else:
verbose_(f"Skipping Finder tags for {f}: nothing to do")
skipped.append(f)
return (written, skipped)
if __name__ == "__main__":
cli() # pylint: disable=no-value-for-parameter

View File

@@ -1,5 +1,5 @@
""" version info """
__version__ = "0.38.18"
__version__ = "0.39.0"

View File

@@ -49,7 +49,7 @@ class _ExifToolProc:
if hasattr(self, "_process_running") and self._process_running:
# already running
if exiftool is not None:
if exiftool is not None and exiftool != self._exiftool:
logging.warning(
f"exiftool subprocess already running, "
f"ignoring exiftool={exiftool}"
@@ -266,7 +266,7 @@ class ExifTool:
+ b"\n"
+ b"-execute\n"
)
# send the command
self._process.stdin.write(command_str)
self._process.stdin.flush()

View File

@@ -18,12 +18,11 @@ def exiftool(self):
return self._exiftool
except AttributeError:
try:
exiftool_path = get_exiftool_path()
exiftool_path = self._db._exiftool_path or get_exiftool_path()
if self.path is not None and os.path.isfile(self.path):
exiftool = ExifTool(self.path)
exiftool = ExifTool(self.path, exiftool=exiftool_path)
else:
exiftool = None
logging.debug(f"exiftool: missing path {self.uuid}")
except FileNotFoundError:
# get_exiftool_path raises FileNotFoundError if exiftool not found
exiftool = None

View File

@@ -74,6 +74,8 @@ class ExportResults:
error=None,
exiftool_warning=None,
exiftool_error=None,
xattr_written=None,
xattr_skipped=None,
):
self.exported = exported or []
self.new = new or []
@@ -92,6 +94,8 @@ class ExportResults:
self.error = error or []
self.exiftool_warning = exiftool_warning or []
self.exiftool_error = exiftool_error or []
self.xattr_written = xattr_written or []
self.xattr_skipped = xattr_skipped or []
def all_files(self):
""" return all filenames contained in results """
@@ -912,6 +916,7 @@ def export2(
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
filename=dest.name,
)
sidecars.append(
(
@@ -934,6 +939,7 @@ def export2(
tag_groups=False,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
filename=dest.name,
)
sidecars.append(
(
@@ -1356,7 +1362,7 @@ def _write_exif_data(
merge_exif_persons=merge_exif_persons,
)
with ExifTool(filepath, flags=flags) as exiftool:
with ExifTool(filepath, flags=flags, exiftool=self._db._exiftool_path) as exiftool:
for exiftag, val in exif_info.items():
if type(val) == list:
for v in val:
@@ -1375,11 +1381,13 @@ def _exiftool_dict(
ignore_date_modified=False,
merge_exif_keywords=False,
merge_exif_persons=False,
filename=None,
):
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
Args:
filename: name of source image file (without path); if not None, exiftool JSON signature will be included; if None, signature will not be included
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
@@ -1413,7 +1421,16 @@ def _exiftool_dict(
UserData:GPSCoordinates
"""
exif = {}
exif = (
{
"SourceFile": filename,
"ExifTool:ExifToolVersion": "12.00",
"File:FileName": filename,
}
if filename is not None
else {}
)
if description_template is not None:
rendered = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
@@ -1611,6 +1628,7 @@ def _exiftool_json_sidecar(
tag_groups=True,
merge_exif_keywords=False,
merge_exif_persons=False,
filename=None,
):
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
@@ -1624,6 +1642,7 @@ def _exiftool_json_sidecar(
tag_groups: if True, tags are in form Group:TagName, e.g. IPTC:Keywords, otherwise group name is omitted, e.g. Keywords
merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
filename: filename of the destination image file for including in exiftool signature in JSON sidecar
Returns: dict with exiftool tags / values
@@ -1657,6 +1676,7 @@ def _exiftool_json_sidecar(
ignore_date_modified=ignore_date_modified,
merge_exif_keywords=merge_exif_keywords,
merge_exif_persons=merge_exif_persons,
filename=filename,
)
if not tag_groups:

View File

@@ -70,12 +70,13 @@ class PhotosDB:
from ._photosdb_process_scoreinfo import _process_scoreinfo
from ._photosdb_process_comments import _process_comments
def __init__(self, dbfile=None, verbose=None):
def __init__(self, dbfile=None, verbose=None, exiftool=None):
""" Create a new PhotosDB object.
Args:
dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH
Raises:
FileNotFoundError if dbfile is not a valid Photos library.
@@ -98,6 +99,8 @@ class PhotosDB:
raise TypeError("verbose must be callable")
self._verbose = verbose
self._exiftool_path = exiftool
# create a temporary directory
# tempfile.TemporaryDirectory gets cleaned up when the object does
self._tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")

View File

@@ -42,6 +42,7 @@ mccabe==0.6.1
modulegraph==0.18
more-itertools==7.2.0
multidict==4.7.6
osxmetadata>=0.99.11
packaging==19.0
parso==0.6.2
pathspec==0.7.0

View File

@@ -81,6 +81,7 @@ setup(
"wurlitzer>=2.0.1",
"photoscript>=0.1.0",
"toml>=0.10.0",
"osxmetadata>=0.99.11",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,

View File

@@ -439,6 +439,19 @@ CLI_EXIFTOOL_DUPLICATE_KEYWORDS = {
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": "wedding.jpg"
}
CLI_FINDER_TAGS = {
"D79B8D77-BFFC-460B-9312-034F2877D35B": {
"File:FileName": "Pumkins2.jpg",
"IPTC:Keywords": "Kids",
"XMP:TagsList": "Kids",
"XMP:Title": "I found one!",
"EXIF:ImageDescription": "Girl holding pumpkin",
"XMP:Description": "Girl holding pumpkin",
"XMP:PersonInImage": "Katie",
"XMP:Subject": "Kids",
}
}
LABELS_JSON = {
"labels": {
"Plant": 7,
@@ -1055,6 +1068,52 @@ def test_export_exiftool():
assert exif[key] == CLI_EXIFTOOL[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_path():
""" test --exiftool with --exiftool-path """
import glob
import os
import os.path
import shutil
import tempfile
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool, get_exiftool_path
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
tempdir = tempfile.TemporaryDirectory()
exiftool_source = get_exiftool_path()
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",
"--exiftool",
"--uuid",
f"{uuid}",
"--exiftool-path",
exiftool_path,
],
)
assert result.exit_code == 0
assert f"exiftool path: {exiftool_path}" in result.output
files = glob.glob("*")
assert sorted(files) == sorted([CLI_EXIFTOOL[uuid]["File:FileName"]])
exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict()
for key in CLI_EXIFTOOL[uuid]:
if type(exif[key]) == list:
assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key])
else:
assert exif[key] == CLI_EXIFTOOL[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_ignore_date_modified():
import glob
@@ -3382,9 +3441,12 @@ def test_export_sidecar_keyword_template():
json_expected = json.loads(
"""
[{"EXIF:ImageDescription": "Girl holding pumpkin",
[{"SourceFile": "Pumkins2.jpg",
"ExifTool:ExifToolVersion": "12.00",
"File:FileName": "Pumkins2.jpg",
"EXIF:ImageDescription": "Girl holding pumpkin",
"XMP:Description": "Girl holding pumpkin",
"XMP:Title": "I found one!",
"XMP:Title": "I found one!",
"XMP:TagsList": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
"IPTC:Keywords": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
"XMP:PersonInImage": ["Katie"],
@@ -4765,3 +4827,243 @@ def test_export_exportdb():
"Error: --exportdb must be specified as filename not path" in result.output
)
def test_export_finder_tag_keywords():
""" test --finder-tag-keywords """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_FINDER_TAGS:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-keywords",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
# run again with --update, should skip writing extended attributes
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-keywords",
"--uuid",
f"{uuid}",
"--update",
],
)
assert result.exit_code == 0
assert "Skipping Finder tags" in result.output
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
# clear tags and run again, should update extended attributes
md.tags = None
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-keywords",
"--uuid",
f"{uuid}",
"--update",
],
)
assert result.exit_code == 0
assert "Writing Finder tags" in result.output
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
def test_export_finder_tag_template():
""" test --finder-tag-template """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_FINDER_TAGS:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-template",
"{person}",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
# run again with --update, should skip writing extended attributes
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-template",
"{person}",
"--uuid",
f"{uuid}",
"--update",
],
)
assert result.exit_code == 0
assert "Skipping Finder tags" in result.output
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
# clear tags and run again, should update extended attributes
md.tags = None
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-template",
"{person}",
"--uuid",
f"{uuid}",
"--update",
],
)
assert result.exit_code == 0
assert "Writing Finder tags" in result.output
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
def test_export_finder_tag_template_multiple():
""" test --finder-tag-template used more than once """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_FINDER_TAGS:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-template",
"{keyword}",
"--finder-tag-template",
"{person}",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords
persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
persons = [persons] if type(persons) != list else persons
expected = [Tag(x) for x in keywords + persons]
assert sorted(md.tags) == sorted(expected)
def test_export_finder_tag_template_keywords():
""" test --finder-tag-template with --finder-tag-keywords """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_FINDER_TAGS:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-keywords",
"--finder-tag-template",
"{person}",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords
persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
persons = [persons] if type(persons) != list else persons
expected = [Tag(x) for x in keywords + persons]
assert sorted(md.tags) == sorted(expected)