Initial implementation of exiftool command, #691 (#696)

* Initial implementation of exiftool command, #691

* updated comment [skip ci]
This commit is contained in:
Rhet Turnbull 2022-05-20 22:12:48 -07:00 committed by GitHub
parent 6d5af5c5e8
commit 79e4b333e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 763 additions and 41 deletions

View File

@ -149,6 +149,7 @@ Commands:
diff Compare two Photos databases and print out differences
docs Open osxphotos documentation in your browser.
dump Print list of all photos & associated info from the Photos...
exiftool Run exiftool on previously exported files to update metadata.
export Export photos from the Photos database.
exportdb Utilities for working with the osxphotos export database
help Print help; for help on commands: help <command>.

View File

@ -102,6 +102,7 @@ Alternatively, you can also run the command line utility like this: ``python3 -m
diff Compare two Photos databases and print out differences
docs Open osxphotos documentation in your browser.
dump Print list of all photos & associated info from the Photos...
exiftool Run exiftool on previously exported files to update metadata.
export Export photos from the Photos database.
exportdb Utilities for working with the osxphotos export database
help Print help; for help on commands: help <command>.

View File

@ -48,6 +48,7 @@ from .cli import cli_main
from .common import get_photos_db, load_uuid_from_file
from .debug_dump import debug_dump
from .dump import dump
from .exiftool_cli import exiftool
from .export import export
from .exportdb import exportdb
from .grep import grep
@ -74,6 +75,7 @@ __all__ = [
"debug_dump",
"diff",
"dump",
"exiftool_cli",
"export",
"exportdb",
"grep",

View File

@ -10,6 +10,7 @@ from .common import DB_OPTION, JSON_OPTION, OSXPHOTOS_HIDDEN
from .debug_dump import debug_dump
from .docs import docs
from .dump import dump
from .exiftool_cli import exiftool
from .export import export
from .exportdb import exportdb
from .grep import grep
@ -67,6 +68,7 @@ for command in [
diff,
docs,
dump,
exiftool,
export,
exportdb,
grep,

View File

@ -0,0 +1,408 @@
"""exiftool command for osxphotos CLI to update an previous export with exiftool metadata"""
import pathlib
import sys
from typing import Callable
import click
from osxphotos import PhotosDB
from osxphotos._constants import OSXPHOTOS_EXPORT_DB
from osxphotos._version import __version__
from osxphotos.configoptions import ConfigOptions, ConfigOptionsLoadError
from osxphotos.export_db import ExportDB, ExportDBInMemory
from osxphotos.export_db_utils import export_db_get_config
from osxphotos.fileutil import FileUtil, FileUtilNoOp
from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter
from osxphotos.utils import pluralize
from .click_rich_echo import (
rich_click_echo,
rich_echo,
rich_echo_error,
set_rich_console,
set_rich_theme,
set_rich_timestamp,
)
from .color_themes import get_theme
from .common import DB_OPTION, THEME_OPTION, get_photos_db
from .export import export, render_and_validate_report
from .param_types import ExportDBType, TemplateString
from .report_writer import ReportWriterNoOp, report_writer_factory
from .rich_progress import rich_progress
from .verbose import get_verbose_console, verbose_print
@click.command(name="exiftool")
@click.option(
"--db-config",
is_flag=True,
help="Load configuration options from the export database to match the last export; "
"If any other command line options are used in conjunction with --db-config, "
"they will override the corresponding values loaded from the export database; "
"see also --load-config.",
)
@click.option(
"--load-config",
required=False,
metavar="CONFIG_FILE",
default=None,
help=(
"Load options from file as written with --save-config. "
"If any other command line options are used in conjunction with --load-config, "
"they will override the corresponding values in the config file; "
"see also --db-config."
),
type=click.Path(exists=True),
)
@click.option(
"--save-config",
required=False,
metavar="CONFIG_FILE",
default=None,
help="Save options to file for use with --load-config. File format is TOML. ",
type=click.Path(),
)
@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,
metavar="OPTION",
help="Optional flag/option to pass to exiftool when using --exiftool. "
"For example, --exiftool-option '-m' to ignore minor warnings. "
"Specify these as you would on the exiftool command line. "
"See exiftool docs at https://exiftool.org/exiftool_pod.html for full list of options. "
"More than one option may be specified by repeating the option, e.g. "
"--exiftool-option '-m' --exiftool-option '-F'. ",
)
@click.option(
"--exiftool-merge-keywords",
is_flag=True,
help="Merge any keywords found in the original file with keywords used for '--exiftool' and '--sidecar'.",
)
@click.option(
"--exiftool-merge-persons",
is_flag=True,
help="Merge any persons found in the original file with persons used for '--exiftool' and '--sidecar'.",
)
@click.option(
"--ignore-date-modified",
is_flag=True,
help="If used with --exiftool or --sidecar, will ignore the photo "
"modification date and set EXIF:ModifyDate to EXIF:DateTimeOriginal; "
"this is consistent with how Photos handles the EXIF:ModifyDate tag.",
)
@click.option(
"--person-keyword",
is_flag=True,
help="Use person in image as keyword/tag when exporting metadata.",
)
@click.option(
"--album-keyword",
is_flag=True,
help="Use album name as keyword/tag when exporting metadata.",
)
@click.option(
"--keyword-template",
metavar="TEMPLATE",
multiple=True,
default=None,
help="For use with --exiftool, --sidecar; specify a template string to use as "
"keyword in the form '{name,DEFAULT}' "
"This is the same format as --directory. For example, if you wanted to add "
"the full path to the folder and album photo is contained in as a keyword when exporting "
'you could specify --keyword-template "{folder_album}" '
'You may specify more than one template, for example --keyword-template "{folder_album}" '
'--keyword-template "{created.year}". '
"See '--replace-keywords' and Templating System below.",
type=TemplateString(),
)
@click.option(
"--replace-keywords",
is_flag=True,
help="Replace keywords with any values specified with --keyword-template. "
"By default, --keyword-template will add keywords to any keywords already associated "
"with the photo. If --replace-keywords is specified, values from --keyword-template "
"will replace any existing keywords instead of adding additional keywords.",
)
@click.option(
"--description-template",
metavar="TEMPLATE",
multiple=False,
default=None,
help="For use with --exiftool, --sidecar; specify a template string to use as "
"description in the form '{name,DEFAULT}' "
"This is the same format as --directory. For example, if you wanted to append "
"'exported with osxphotos on [today's date]' to the description, you could specify "
'--description-template "{descr} exported with osxphotos on {today.date}" '
"See Templating System below.",
type=TemplateString(),
)
@click.option(
"--exportdb",
help="Optional path to export database (if not in the default location in the export directory).",
type=ExportDBType(),
)
@click.option(
"--report",
metavar="REPORT_FILE",
help="Write a report of all files that were exported. "
"The extension of the report filename will be used to determine the format. "
"Valid extensions are: "
".csv (CSV file), .json (JSON), .db and .sqlite (SQLite database). "
"REPORT_FILE may be a template string (see Templating System), for example, "
"--report 'export_{today.date}.csv' will write a CSV report file named with today's date. "
"See also --append.",
type=TemplateString(),
)
@click.option(
"--append",
is_flag=True,
help="If used with --report, add data to existing report file instead of overwriting it. "
"See also --report.",
)
@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.")
@click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output")
@click.option(
"--dry-run",
is_flag=True,
help="Run in dry-run mode (don't actually update files), e.g. for use with --update-signatures.",
)
@THEME_OPTION
@DB_OPTION
@click.argument(
"export_dir",
metavar="EXPORT_DIRECTORY",
nargs=1,
type=click.Path(exists=True, file_okay=False),
)
def exiftool(
album_keyword,
append,
db_config,
db,
description_template,
dry_run,
exiftool_merge_keywords,
exiftool_merge_persons,
exiftool_option,
exiftool_path,
export_dir,
exportdb,
ignore_date_modified,
keyword_template,
load_config,
person_keyword,
replace_keywords,
report,
save_config,
theme,
timestamp,
verbose,
):
"""Run exiftool on previously exported files to update metadata.
If you previously exported photos with `osxphotos export` but did not include the
`--exiftool` option and you now want to update the metadata of the exported files with
exiftool, you can use this command to do so.
If you simply re-run the `osxphotos export` with `--update` and `--exiftool`, osxphotos will
re-export all photos because it will detect that the previously exported photos do not have the
exiftool metadata updates. This command will run exiftool on the previously exported photos
to update all metadata then will update the export database so that using `--exiftool --update`
with `osxphotos export` in the future will work correctly and not unnecessarily re-export photos.
"""
# save locals for initializing config options
locals_ = locals()
if load_config and db_config:
raise click.UsageError("Cannot specify both --load-config and --db-config")
exportdb = exportdb or pathlib.Path(export_dir) / OSXPHOTOS_EXPORT_DB
if not exportdb.exists():
raise click.UsageError(f"Export database {exportdb} does not exist")
# grab all the variables we need from the export command
# export is a click Command so can walk through it's params to get the option names
for param in export.params:
if param.name not in locals_:
locals_[param.name] = None
# need to ensure --exiftool is true in the config options
locals_["exiftool"] = True
config = ConfigOptions(
"export",
locals_,
ignore=[
"cli_obj",
"config_only",
"ctx",
"db_config",
"dest",
"export_dir",
"load_config",
"save_config",
],
)
color_theme = get_theme(theme)
verbose_ = verbose_print(
verbose, timestamp, rich=True, theme=color_theme, highlight=False
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_theme(color_theme)
set_rich_timestamp(timestamp)
# load config options from either file or export database
# values already set in config will take precedence over any values
# in the config file or database
if load_config:
try:
config.load_from_file(load_config)
except ConfigOptionsLoadError as e:
rich_click_echo(
f"[error]Error parsing {load_config} config file: {e.message}", err=True
)
sys.exit(1)
verbose_(f"Loaded options from file [filepath]{load_config}")
elif db_config:
config = export_db_get_config(exportdb, config)
verbose_("Loaded options from export database")
# from here on out, use config.param_name instead of using the params passed into the function
# as the values may have been updated from config file or database
if load_config or db_config:
# config file might have changed verbose
color_theme = get_theme(config.theme)
verbose_ = verbose_print(
config.verbose,
config.timestamp,
rich=True,
theme=color_theme,
highlight=False,
)
# set console for rich_echo to be same as for verbose_
set_rich_console(get_verbose_console())
set_rich_timestamp(config.timestamp)
# validate options
if append and not report:
raise click.UsageError("--append requires --report")
# need to ensure we have a photos database
config.db = get_photos_db(config.db)
if save_config:
verbose_(f"Saving options to config file '[filepath]{save_config}'")
config.write_to_file(save_config)
process_files(exportdb, export_dir, verbose=verbose_, options=config)
def process_files(
exportdb: str, export_dir: str, verbose: Callable, options: ConfigOptions
):
"""Process files in the export database.
Args:
exportdb: Path to export database.
export_dir: Path to export directory.
verbose: Callable for verbose output.
options: ConfigOptions
"""
if options.report:
report = render_and_validate_report(
options.report, options.exiftool_path, export_dir
)
report_writer = report_writer_factory(report, options.append)
else:
report_writer = ReportWriterNoOp()
photosdb = PhotosDB(options.db, verbose=verbose)
if options.dry_run:
export_db = ExportDBInMemory(exportdb, export_dir)
fileutil = FileUtilNoOp
else:
export_db = ExportDB(exportdb, export_dir)
fileutil = FileUtil
# get_exported_files is a generator which returns tuple of (uuid, filepath)
files = list(export_db.get_exported_files())
# filter out sidecar files
files = [
(u, f)
for u, f in files
if pathlib.Path(f).suffix.lower() not in [".json", ".xmp"]
]
total = len(files)
count = 1
all_results = ExportResults()
with rich_progress(console=get_verbose_console(), mock=options.verbose) as progress:
task = progress.add_task("Processing files", total=total)
for uuid, file in files:
if not pathlib.Path(file).exists():
verbose(f"Skipping missing file [filepath]{file}[/]")
report_writer.write(ExportResults(missing=[file]))
continue
# zzz put in check for hardlink
verbose(f"Processing file [filepath]{file}[/] ([num]{count}/{total}[/num])")
photo = photosdb.get_photo(uuid)
export_options = ExportOptions(
description_template=options.description_template,
dry_run=options.dry_run,
exiftool_flags=options.exiftool_option,
exiftool=True,
export_db=export_db,
ignore_date_modified=options.ignore_date_modified,
keyword_template=options.keyword_template,
merge_exif_keywords=options.exiftool_merge_keywords,
merge_exif_persons=options.exiftool_merge_persons,
replace_keywords=options.replace_keywords,
use_albums_as_keywords=options.album_keyword,
use_persons_as_keywords=options.person_keyword,
verbose=verbose,
)
exporter = PhotoExporter(photo)
results = exporter.write_exiftool_metadata_to_file(
src=file, dest=file, options=export_options
)
all_results += results
for warning_ in results.exiftool_warning:
verbose(
f"[warning]exiftool warning for file {warning_[0]}: {warning_[1]}"
)
for error_ in results.exiftool_error:
rich_echo_error(
f"[error]exiftool error for file {error_[0]}: {error_[1]}"
)
for result in results.exif_updated:
verbose(f"Updated EXIF metadata for [filepath]{result}")
# update the database
with export_db.get_file_record(file) as rec:
rec.dest_sig = fileutil.file_sig(file)
rec.export_options = export_options.bit_flags
rec.exifdata = exporter.exiftool_json_sidecar(export_options)
report_writer.write(results)
count += 1
progress.advance(task)
photo_str_total = pluralize(total, "photo", "photos")
summary = (
f"Processed: [num]{total}[/] {photo_str_total}, "
f"skipped: [num]{len(all_results.skipped)}[/], "
f"updated EXIF data: [num]{len(all_results.exif_updated)}[/], "
)
verbose(summary)
if options.report:
verbose(f"Wrote export report to [filepath]{report}")
report_writer.close()

View File

@ -2729,14 +2729,3 @@ def render_and_validate_report(report: str, exiftool_path: str, export_dir: str)
return report
# def _export_with_profiler(args: Dict):
# """ "Run export with cProfile"""
# try:
# args.pop("profile")
# except KeyError:
# pass
# cProfile.runctx(
# "_export(**args)", globals=globals(), locals=locals(), sort="tottime"
# )

View File

@ -57,8 +57,8 @@ class ConfigOptions:
setattr(self, attr, self._attrs[attr])
else:
setattr(self, attr, arg)
except KeyError:
raise KeyError(f"Missing argument: {attr}")
except KeyError as e:
raise KeyError(f"Missing argument: {attr}") from e
def validate(self, exclusive=None, inclusive=None, dependent=None, cli=False):
"""validate combinations of otions
@ -153,9 +153,26 @@ class ConfigOptions:
ConfigOptionsLoadError if there are any errors during the parsing of the TOML file
"""
loaded = toml.load(filename)
return self._load_from_toml_dict(loaded, override)
def load_from_str(self, str, override=False):
"""Load options from a TOML str.
Args:
str: TOML str
override: bool; if True, values in the TOML str will override values already set in the instance
Raises:
ConfigOptionsLoadError if there are any errors during the parsing of the TOML str
"""
loaded = toml.loads(str)
return self._load_from_toml_dict(loaded, override)
def _load_from_toml_dict(self, loaded, override):
"""Load options from a TOML dict (as returned by toml.load or toml.loads)"""
name = self._name
if name not in loaded:
raise ConfigOptionsLoadError(f"[{name}] section missing from {filename}")
raise ConfigOptionsLoadError(f"[{name}] section missing from config file")
for attr in loaded[name]:
if attr not in self._attrs:

View File

@ -282,6 +282,20 @@ class ExportDB:
results = None
return results
def get_exported_files(self):
"""Returns tuple of (uuid, filepath) for all paths of all exported files tracked in the database"""
conn = self._conn
try:
c = conn.cursor()
c.execute("SELECT uuid, filepath FROM export_data")
except Error as e:
logging.warning(e)
return
while row := c.fetchone():
yield row[0], os.path.join(self.export_dir, row[1])
return
def close(self):
"""close the database connection"""
try:
@ -299,7 +313,7 @@ class ExportDB:
if not os.path.isfile(dbfile):
conn = self._get_db_connection(dbfile)
if not conn:
raise Exception("Error getting connection to database {dbfile}")
raise Exception(f"Error getting connection to database {dbfile}")
self._create_or_migrate_db_tables(conn)
self.was_created = True
self.was_upgraded = ()

View File

@ -12,10 +12,11 @@ from rich import print
from ._constants import OSXPHOTOS_EXPORT_DB
from ._version import __version__
from .utils import noop
from .configoptions import ConfigOptions
from .export_db import OSXPHOTOS_EXPORTDB_VERSION, ExportDB
from .fileutil import FileUtil
from .photosdb import PhotosDB
from .utils import noop
__all__ = [
"export_db_check_signatures",
@ -125,6 +126,23 @@ def export_db_save_config_to_file(
f.write(row[0])
def export_db_get_config(
export_db: Union[str, pathlib.Path], config: ConfigOptions, override=False
) -> ConfigOptions:
"""Load last run config to config
Args:
export_db: path to export database
override: if True, any loaded config values will overwrite existing values in config
"""
conn = sqlite3.connect(str(export_db))
c = conn.cursor()
row = c.execute("SELECT config FROM config ORDER BY id DESC LIMIT 1;").fetchone()
if not row:
return ValueError("No config found in export_db")
return config.load_from_str(row[0], override=override)
def export_db_check_signatures(
dbfile: Union[str, pathlib.Path],
export_dir: Union[str, pathlib.Path],

View File

@ -735,7 +735,7 @@ class PhotoExporter:
return ShouldUpdate.EXPORT_OPTIONS_DIFFERENT
if options.exiftool:
current_exifdata = self._exiftool_json_sidecar(options=options)
current_exifdata = self.exiftool_json_sidecar(options=options)
rv = current_exifdata != file_record.exifdata
# if using exiftool, don't need to continue checking edited below
# as exiftool will be used to update edited file
@ -1143,7 +1143,7 @@ class PhotoExporter:
# point src to the tmp_file so that the original source is not modified
# and the export grabs the new file
src = tmp_file
exif_results = self._write_exif_metadata_to_file(
exif_results = self.write_exiftool_metadata_to_file(
src, dest, options=options
)
@ -1185,7 +1185,7 @@ class PhotoExporter:
if not options.ignore_signature:
rec.dest_sig = fileutil.file_sig(dest)
if options.exiftool:
rec.exifdata = self._exiftool_json_sidecar(options)
rec.exifdata = self.exiftool_json_sidecar(options)
if self.photo.hexdigest != rec.digest:
results.metadata_changed = [dest_str]
rec.digest = self.photo.hexdigest
@ -1320,7 +1320,7 @@ class PhotoExporter:
sidecar_filename = dest.parent / pathlib.Path(
f"{dest.stem}{dest_suffix}.json"
)
sidecar_str = self._exiftool_json_sidecar(
sidecar_str = self.exiftool_json_sidecar(
filename=dest.name, options=options
)
sidecars.append(
@ -1337,7 +1337,7 @@ class PhotoExporter:
sidecar_filename = dest.parent / pathlib.Path(
f"{dest.stem}{dest_suffix}.json"
)
sidecar_str = self._exiftool_json_sidecar(
sidecar_str = self.exiftool_json_sidecar(
tag_groups=False, filename=dest.name, options=options
)
sidecars.append(
@ -1436,19 +1436,20 @@ class PhotoExporter:
return results
def _write_exif_metadata_to_file(
def write_exiftool_metadata_to_file(
self,
src,
dest,
options: ExportOptions,
) -> ExportResults:
"""Write exif metadata to file using exiftool
"""Write exif metadata to src file using exiftool
Note: this method modifies src so src must be a copy of the original file;
Caution: This method modifies *src*, not *dest*,
so src must be a copy of the original file if you don't want the source modified;
it also does not write to dest (dest is the intended destination for purposes of
referencing the export database. This allows the exiftool update to be done on the
local machine prior to being copied to the export destination which may be on a
network drive or other slower external storage."""
network drive or other slower external storage)."""
verbose = options.verbose or self._verbose
exiftool_results = ExportResults()
@ -1491,7 +1492,7 @@ class PhotoExporter:
old_data = exif_record.exifdata if exif_record else None
if old_data is not None:
old_data = json.loads(old_data)[0]
current_data = json.loads(self._exiftool_json_sidecar(options=options))
current_data = json.loads(self.exiftool_json_sidecar(options=options))
current_data = current_data[0]
if old_data != current_data:
files_are_different = True
@ -1831,7 +1832,7 @@ class PhotoExporter:
pass
return persons
def _exiftool_json_sidecar(
def exiftool_json_sidecar(
self,
options: t.Optional[ExportOptions] = None,
tag_groups: bool = True,

View File

@ -80,19 +80,19 @@ def generate_sidecars(dbname, uuid_dict):
# generate JSON files
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json")
json_ = exporter._exiftool_json_sidecar()
json_ = exporter.exiftool_json_sidecar()
with open(sidecar, "w") as file:
file.write(json_)
# no tag groups
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_no_tag_groups.json")
json_ = exporter._exiftool_json_sidecar(tag_groups=False)
json_ = exporter.exiftool_json_sidecar(tag_groups=False)
with open(sidecar, "w") as file:
file.write(json_)
# ignore_date_modified
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_ignore_date_modified.json")
json_ = exporter._exiftool_json_sidecar(
json_ = exporter.exiftool_json_sidecar(
ExportOptions(ignore_date_modified=True)
)
with open(sidecar, "w") as file:
@ -100,7 +100,7 @@ def generate_sidecars(dbname, uuid_dict):
# keyword_template
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.json")
json_ = exporter._exiftool_json_sidecar(
json_ = exporter.exiftool_json_sidecar(
ExportOptions(keyword_template=["{folder_album}"])
)
with open(sidecar, "w") as file:
@ -108,7 +108,7 @@ def generate_sidecars(dbname, uuid_dict):
# persons_as_keywords
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_persons_as_keywords.json")
json_ = exporter._exiftool_json_sidecar(
json_ = exporter.exiftool_json_sidecar(
ExportOptions(use_persons_as_keywords=True)
)
with open(sidecar, "w") as file:
@ -116,7 +116,7 @@ def generate_sidecars(dbname, uuid_dict):
# albums_as_keywords
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_albums_as_keywords.json")
json_ = exporter._exiftool_json_sidecar(
json_ = exporter.exiftool_json_sidecar(
ExportOptions(use_albums_as_keywords=True)
)
with open(sidecar, "w") as file:

258
tests/test_cli_exiftool.py Normal file
View File

@ -0,0 +1,258 @@
"""Tests for `osxphotos exiftool` command."""
import glob
import json
import os
import pytest
from click.testing import CliRunner
from osxphotos.cli.exiftool_cli import exiftool
from osxphotos.cli.export import export
from osxphotos.exiftool import ExifTool, get_exiftool_path
from .test_cli import CLI_EXIFTOOL, PHOTOS_DB_15_7
# determine if exiftool installed so exiftool tests can be skipped
try:
exiftool_path = get_exiftool_path()
except FileNotFoundError:
exiftool_path = None
@pytest.mark.skipif(exiftool_path is None, reason="exiftool not installed")
def test_export_exiftool():
"""Test osxphotos exiftool"""
runner = CliRunner()
cwd = os.getcwd()
with runner.isolated_filesystem() as temp_dir:
uuid_option = []
for uuid in CLI_EXIFTOOL:
uuid_option.extend(("--uuid", uuid))
# first, export without --exiftool
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
*uuid_option,
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(
[CLI_EXIFTOOL[uuid]["File:FileName"] for uuid in CLI_EXIFTOOL]
)
# now, run exiftool command to update exiftool metadata
result = runner.invoke(
exiftool,
["--db", os.path.join(cwd, PHOTOS_DB_15_7), "-V", "--db-config", temp_dir],
)
assert result.exit_code == 0
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]
# now, export with --exiftool --update, no files should be updated
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--update",
*uuid_option,
],
)
assert result.exit_code == 0
assert f"exported: 0, updated: 0, skipped: {len(CLI_EXIFTOOL)}" in result.output
@pytest.mark.skipif(exiftool_path is None, reason="exiftool not installed")
def test_export_exiftool_album_keyword():
"""Test osxphotos exiftool with --album-template."""
runner = CliRunner()
cwd = os.getcwd()
with runner.isolated_filesystem() as temp_dir:
# first, export without --exiftool
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--album",
"Pumpkin Farm",
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert len(files) == 3
# now, run exiftool command to update exiftool metadata
result = runner.invoke(
exiftool,
[
"--db",
os.path.join(cwd, PHOTOS_DB_15_7),
"-V",
"--db-config",
"--report",
"exiftool.json",
"--album-keyword",
temp_dir,
],
)
assert result.exit_code == 0
report = json.load(open("exiftool.json", "r"))
assert len(report) == 3
# verify exiftool metadata was updated
for file in report:
exif = ExifTool(file["filename"]).asdict()
assert "Pumpkin Farm" in exif["IPTC:Keywords"]
# now, export with --exiftool --update, no files should be updated
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--update",
"--album",
"Pumpkin Farm",
"--album-keyword",
],
)
assert result.exit_code == 0
assert f"exported: 0, updated: 0, skipped: 3" in result.output
@pytest.mark.skipif(exiftool_path is None, reason="exiftool not installed")
def test_export_exiftool_keyword_template():
"""Test osxphotos exiftool with --keyword-template."""
runner = CliRunner()
cwd = os.getcwd()
with runner.isolated_filesystem() as temp_dir:
uuid_option = []
for uuid in CLI_EXIFTOOL:
uuid_option.extend(("--uuid", uuid))
# first, export without --exiftool
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
*uuid_option,
],
)
assert result.exit_code == 0
# now, run exiftool command to update exiftool metadata
result = runner.invoke(
exiftool,
[
"--db",
os.path.join(cwd, PHOTOS_DB_15_7),
"-V",
"--db-config",
"--keyword-template",
"FOO",
temp_dir,
"--report",
"exiftool.json",
],
)
assert result.exit_code == 0
report = json.load(open("exiftool.json", "r"))
for file in report:
exif = ExifTool(file["filename"]).asdict()
assert "FOO" in exif["IPTC:Keywords"]
# now, export with --exiftool --update, no files should be updated
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--keyword-template",
"FOO",
"--update",
*uuid_option,
],
)
assert result.exit_code == 0
assert f"exported: 0, updated: 0, skipped: {len(CLI_EXIFTOOL)}" in result.output
@pytest.mark.skipif(exiftool_path is None, reason="exiftool not installed")
def test_export_exiftool_load_config():
"""Test osxphotos exiftool with --load-config"""
runner = CliRunner()
cwd = os.getcwd()
with runner.isolated_filesystem() as temp_dir:
uuid_option = []
for uuid in CLI_EXIFTOOL:
uuid_option.extend(("--uuid", uuid))
# first, export without --exiftool
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--save-config",
"config.toml",
*uuid_option,
],
)
assert result.exit_code == 0
# now, run exiftool command to update exiftool metadata
result = runner.invoke(
exiftool,
["-V", "--load-config", "config.toml", temp_dir],
)
assert result.exit_code == 0
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]
# now, export with --exiftool --update, no files should be updated
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--update",
*uuid_option,
],
)
assert result.exit_code == 0
assert f"exported: 0, updated: 0, skipped: {len(CLI_EXIFTOOL)}" in result.output

View File

@ -1,6 +1,8 @@
""" test ConfigOptions class """
import pathlib
from io import StringIO
import pytest
import toml
@ -43,6 +45,15 @@ def test_write_to_file_load_from_file(tmpdir):
assert cfg2.bar
def test_load_from_str(tmpdir):
cfg = ConfigOptions("test", VARS)
cfg.bar = True
cfg_str = cfg.write_to_str()
cfg2 = ConfigOptions("test", VARS).load_from_str(cfg_str)
assert cfg2.foo == "bar"
assert cfg2.bar
def test_load_from_file_error(tmpdir):
cfg_file = pathlib.Path(str(tmpdir)) / "test.toml"
cfg = ConfigOptions("test", VARS)

View File

@ -404,7 +404,7 @@ def test_exiftool_json_sidecar(photosdb):
with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp:
json_expected = json.load(fp)[0]
json_got = PhotoExporter(photo)._exiftool_json_sidecar()
json_got = PhotoExporter(photo).exiftool_json_sidecar()
json_got = json.loads(json_got)[0]
assert json_got == json_expected
@ -420,7 +420,7 @@ def test_exiftool_json_sidecar_ignore_date_modified(photosdb):
) as fp:
json_expected = json.load(fp)[0]
json_got = PhotoExporter(photo)._exiftool_json_sidecar(
json_got = PhotoExporter(photo).exiftool_json_sidecar(
ExportOptions(ignore_date_modified=True)
)
json_got = json.loads(json_got)[0]
@ -453,7 +453,7 @@ def test_exiftool_json_sidecar_keyword_template_long(capsys, photosdb):
long_str = "x" * (_MAX_IPTC_KEYWORD_LEN + 1)
photos[0]._verbose = print
json_got = PhotoExporter(photos[0])._exiftool_json_sidecar(
json_got = PhotoExporter(photos[0]).exiftool_json_sidecar(
ExportOptions(keyword_template=[long_str])
)
json_got = json.loads(json_got)[0]
@ -479,7 +479,7 @@ def test_exiftool_json_sidecar_keyword_template(photosdb):
str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.json"), "r"
) as fp:
json_expected = json.load(fp)
json_got = PhotoExporter(photo)._exiftool_json_sidecar(
json_got = PhotoExporter(photo).exiftool_json_sidecar(
ExportOptions(keyword_template=["{folder_album}"])
)
json_got = json.loads(json_got)
@ -497,7 +497,7 @@ def test_exiftool_json_sidecar_use_persons_keyword(photosdb):
) as fp:
json_expected = json.load(fp)[0]
json_got = PhotoExporter(photo)._exiftool_json_sidecar(
json_got = PhotoExporter(photo).exiftool_json_sidecar(
ExportOptions(use_persons_as_keywords=True)
)
json_got = json.loads(json_got)[0]
@ -515,7 +515,7 @@ def test_exiftool_json_sidecar_use_albums_keywords(photosdb):
) as fp:
json_expected = json.load(fp)
json_got = PhotoExporter(photo)._exiftool_json_sidecar(
json_got = PhotoExporter(photo).exiftool_json_sidecar(
ExportOptions(use_albums_as_keywords=True)
)
json_got = json.loads(json_got)
@ -530,7 +530,7 @@ def test_exiftool_sidecar(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_no_tag_groups.json", "r") as fp:
json_expected = fp.read()
json_got = PhotoExporter(photo)._exiftool_json_sidecar(tag_groups=False)
json_got = PhotoExporter(photo).exiftool_json_sidecar(tag_groups=False)
assert json_got == json_expected

View File

@ -337,7 +337,7 @@ def test_exiftool_json_sidecar(photosdb):
with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp:
json_expected = json.load(fp)[0]
json_got = PhotoExporter(photo)._exiftool_json_sidecar()
json_got = PhotoExporter(photo).exiftool_json_sidecar()
json_got = json.loads(json_got)[0]
assert json_got == json_expected