* Initial implementation of exiftool command, #691 * updated comment [skip ci]
This commit is contained in:
parent
6d5af5c5e8
commit
79e4b333e9
@ -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>.
|
||||
|
||||
@ -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>.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
408
osxphotos/cli/exiftool_cli.py
Normal file
408
osxphotos/cli/exiftool_cli.py
Normal 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()
|
||||
@ -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"
|
||||
# )
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 = ()
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
258
tests/test_cli_exiftool.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user