* Initial implementation of exiftool command, #691 * updated comment [skip ci]
This commit is contained in:
@@ -149,6 +149,7 @@ Commands:
|
|||||||
diff Compare two Photos databases and print out differences
|
diff Compare two Photos databases and print out differences
|
||||||
docs Open osxphotos documentation in your browser.
|
docs Open osxphotos documentation in your browser.
|
||||||
dump Print list of all photos & associated info from the Photos...
|
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.
|
export Export photos from the Photos database.
|
||||||
exportdb Utilities for working with the osxphotos export database
|
exportdb Utilities for working with the osxphotos export database
|
||||||
help Print help; for help on commands: help <command>.
|
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
|
diff Compare two Photos databases and print out differences
|
||||||
docs Open osxphotos documentation in your browser.
|
docs Open osxphotos documentation in your browser.
|
||||||
dump Print list of all photos & associated info from the Photos...
|
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.
|
export Export photos from the Photos database.
|
||||||
exportdb Utilities for working with the osxphotos export database
|
exportdb Utilities for working with the osxphotos export database
|
||||||
help Print help; for help on commands: help <command>.
|
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 .common import get_photos_db, load_uuid_from_file
|
||||||
from .debug_dump import debug_dump
|
from .debug_dump import debug_dump
|
||||||
from .dump import dump
|
from .dump import dump
|
||||||
|
from .exiftool_cli import exiftool
|
||||||
from .export import export
|
from .export import export
|
||||||
from .exportdb import exportdb
|
from .exportdb import exportdb
|
||||||
from .grep import grep
|
from .grep import grep
|
||||||
@@ -74,6 +75,7 @@ __all__ = [
|
|||||||
"debug_dump",
|
"debug_dump",
|
||||||
"diff",
|
"diff",
|
||||||
"dump",
|
"dump",
|
||||||
|
"exiftool_cli",
|
||||||
"export",
|
"export",
|
||||||
"exportdb",
|
"exportdb",
|
||||||
"grep",
|
"grep",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .common import DB_OPTION, JSON_OPTION, OSXPHOTOS_HIDDEN
|
|||||||
from .debug_dump import debug_dump
|
from .debug_dump import debug_dump
|
||||||
from .docs import docs
|
from .docs import docs
|
||||||
from .dump import dump
|
from .dump import dump
|
||||||
|
from .exiftool_cli import exiftool
|
||||||
from .export import export
|
from .export import export
|
||||||
from .exportdb import exportdb
|
from .exportdb import exportdb
|
||||||
from .grep import grep
|
from .grep import grep
|
||||||
@@ -67,6 +68,7 @@ for command in [
|
|||||||
diff,
|
diff,
|
||||||
docs,
|
docs,
|
||||||
dump,
|
dump,
|
||||||
|
exiftool,
|
||||||
export,
|
export,
|
||||||
exportdb,
|
exportdb,
|
||||||
grep,
|
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
|
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])
|
setattr(self, attr, self._attrs[attr])
|
||||||
else:
|
else:
|
||||||
setattr(self, attr, arg)
|
setattr(self, attr, arg)
|
||||||
except KeyError:
|
except KeyError as e:
|
||||||
raise KeyError(f"Missing argument: {attr}")
|
raise KeyError(f"Missing argument: {attr}") from e
|
||||||
|
|
||||||
def validate(self, exclusive=None, inclusive=None, dependent=None, cli=False):
|
def validate(self, exclusive=None, inclusive=None, dependent=None, cli=False):
|
||||||
"""validate combinations of otions
|
"""validate combinations of otions
|
||||||
@@ -153,9 +153,26 @@ class ConfigOptions:
|
|||||||
ConfigOptionsLoadError if there are any errors during the parsing of the TOML file
|
ConfigOptionsLoadError if there are any errors during the parsing of the TOML file
|
||||||
"""
|
"""
|
||||||
loaded = toml.load(filename)
|
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
|
name = self._name
|
||||||
if name not in loaded:
|
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]:
|
for attr in loaded[name]:
|
||||||
if attr not in self._attrs:
|
if attr not in self._attrs:
|
||||||
|
|||||||
@@ -282,6 +282,20 @@ class ExportDB:
|
|||||||
results = None
|
results = None
|
||||||
return results
|
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):
|
def close(self):
|
||||||
"""close the database connection"""
|
"""close the database connection"""
|
||||||
try:
|
try:
|
||||||
@@ -299,7 +313,7 @@ class ExportDB:
|
|||||||
if not os.path.isfile(dbfile):
|
if not os.path.isfile(dbfile):
|
||||||
conn = self._get_db_connection(dbfile)
|
conn = self._get_db_connection(dbfile)
|
||||||
if not conn:
|
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._create_or_migrate_db_tables(conn)
|
||||||
self.was_created = True
|
self.was_created = True
|
||||||
self.was_upgraded = ()
|
self.was_upgraded = ()
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ from rich import print
|
|||||||
|
|
||||||
from ._constants import OSXPHOTOS_EXPORT_DB
|
from ._constants import OSXPHOTOS_EXPORT_DB
|
||||||
from ._version import __version__
|
from ._version import __version__
|
||||||
from .utils import noop
|
from .configoptions import ConfigOptions
|
||||||
from .export_db import OSXPHOTOS_EXPORTDB_VERSION, ExportDB
|
from .export_db import OSXPHOTOS_EXPORTDB_VERSION, ExportDB
|
||||||
from .fileutil import FileUtil
|
from .fileutil import FileUtil
|
||||||
from .photosdb import PhotosDB
|
from .photosdb import PhotosDB
|
||||||
|
from .utils import noop
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"export_db_check_signatures",
|
"export_db_check_signatures",
|
||||||
@@ -125,6 +126,23 @@ def export_db_save_config_to_file(
|
|||||||
f.write(row[0])
|
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(
|
def export_db_check_signatures(
|
||||||
dbfile: Union[str, pathlib.Path],
|
dbfile: Union[str, pathlib.Path],
|
||||||
export_dir: Union[str, pathlib.Path],
|
export_dir: Union[str, pathlib.Path],
|
||||||
|
|||||||
@@ -735,7 +735,7 @@ class PhotoExporter:
|
|||||||
return ShouldUpdate.EXPORT_OPTIONS_DIFFERENT
|
return ShouldUpdate.EXPORT_OPTIONS_DIFFERENT
|
||||||
|
|
||||||
if options.exiftool:
|
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
|
rv = current_exifdata != file_record.exifdata
|
||||||
# if using exiftool, don't need to continue checking edited below
|
# if using exiftool, don't need to continue checking edited below
|
||||||
# as exiftool will be used to update edited file
|
# 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
|
# point src to the tmp_file so that the original source is not modified
|
||||||
# and the export grabs the new file
|
# and the export grabs the new file
|
||||||
src = tmp_file
|
src = tmp_file
|
||||||
exif_results = self._write_exif_metadata_to_file(
|
exif_results = self.write_exiftool_metadata_to_file(
|
||||||
src, dest, options=options
|
src, dest, options=options
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1185,7 +1185,7 @@ class PhotoExporter:
|
|||||||
if not options.ignore_signature:
|
if not options.ignore_signature:
|
||||||
rec.dest_sig = fileutil.file_sig(dest)
|
rec.dest_sig = fileutil.file_sig(dest)
|
||||||
if options.exiftool:
|
if options.exiftool:
|
||||||
rec.exifdata = self._exiftool_json_sidecar(options)
|
rec.exifdata = self.exiftool_json_sidecar(options)
|
||||||
if self.photo.hexdigest != rec.digest:
|
if self.photo.hexdigest != rec.digest:
|
||||||
results.metadata_changed = [dest_str]
|
results.metadata_changed = [dest_str]
|
||||||
rec.digest = self.photo.hexdigest
|
rec.digest = self.photo.hexdigest
|
||||||
@@ -1320,7 +1320,7 @@ class PhotoExporter:
|
|||||||
sidecar_filename = dest.parent / pathlib.Path(
|
sidecar_filename = dest.parent / pathlib.Path(
|
||||||
f"{dest.stem}{dest_suffix}.json"
|
f"{dest.stem}{dest_suffix}.json"
|
||||||
)
|
)
|
||||||
sidecar_str = self._exiftool_json_sidecar(
|
sidecar_str = self.exiftool_json_sidecar(
|
||||||
filename=dest.name, options=options
|
filename=dest.name, options=options
|
||||||
)
|
)
|
||||||
sidecars.append(
|
sidecars.append(
|
||||||
@@ -1337,7 +1337,7 @@ class PhotoExporter:
|
|||||||
sidecar_filename = dest.parent / pathlib.Path(
|
sidecar_filename = dest.parent / pathlib.Path(
|
||||||
f"{dest.stem}{dest_suffix}.json"
|
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
|
tag_groups=False, filename=dest.name, options=options
|
||||||
)
|
)
|
||||||
sidecars.append(
|
sidecars.append(
|
||||||
@@ -1436,19 +1436,20 @@ class PhotoExporter:
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _write_exif_metadata_to_file(
|
def write_exiftool_metadata_to_file(
|
||||||
self,
|
self,
|
||||||
src,
|
src,
|
||||||
dest,
|
dest,
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
) -> ExportResults:
|
) -> 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
|
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
|
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
|
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
|
verbose = options.verbose or self._verbose
|
||||||
exiftool_results = ExportResults()
|
exiftool_results = ExportResults()
|
||||||
@@ -1491,7 +1492,7 @@ class PhotoExporter:
|
|||||||
old_data = exif_record.exifdata if exif_record else None
|
old_data = exif_record.exifdata if exif_record else None
|
||||||
if old_data is not None:
|
if old_data is not None:
|
||||||
old_data = json.loads(old_data)[0]
|
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]
|
current_data = current_data[0]
|
||||||
if old_data != current_data:
|
if old_data != current_data:
|
||||||
files_are_different = True
|
files_are_different = True
|
||||||
@@ -1831,7 +1832,7 @@ class PhotoExporter:
|
|||||||
pass
|
pass
|
||||||
return persons
|
return persons
|
||||||
|
|
||||||
def _exiftool_json_sidecar(
|
def exiftool_json_sidecar(
|
||||||
self,
|
self,
|
||||||
options: t.Optional[ExportOptions] = None,
|
options: t.Optional[ExportOptions] = None,
|
||||||
tag_groups: bool = True,
|
tag_groups: bool = True,
|
||||||
|
|||||||
@@ -80,19 +80,19 @@ def generate_sidecars(dbname, uuid_dict):
|
|||||||
|
|
||||||
# generate JSON files
|
# generate JSON files
|
||||||
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json")
|
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:
|
with open(sidecar, "w") as file:
|
||||||
file.write(json_)
|
file.write(json_)
|
||||||
|
|
||||||
# no tag groups
|
# no tag groups
|
||||||
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_no_tag_groups.json")
|
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:
|
with open(sidecar, "w") as file:
|
||||||
file.write(json_)
|
file.write(json_)
|
||||||
|
|
||||||
# ignore_date_modified
|
# ignore_date_modified
|
||||||
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_ignore_date_modified.json")
|
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)
|
ExportOptions(ignore_date_modified=True)
|
||||||
)
|
)
|
||||||
with open(sidecar, "w") as file:
|
with open(sidecar, "w") as file:
|
||||||
@@ -100,7 +100,7 @@ def generate_sidecars(dbname, uuid_dict):
|
|||||||
|
|
||||||
# keyword_template
|
# keyword_template
|
||||||
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.json")
|
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}"])
|
ExportOptions(keyword_template=["{folder_album}"])
|
||||||
)
|
)
|
||||||
with open(sidecar, "w") as file:
|
with open(sidecar, "w") as file:
|
||||||
@@ -108,7 +108,7 @@ def generate_sidecars(dbname, uuid_dict):
|
|||||||
|
|
||||||
# persons_as_keywords
|
# persons_as_keywords
|
||||||
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_persons_as_keywords.json")
|
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)
|
ExportOptions(use_persons_as_keywords=True)
|
||||||
)
|
)
|
||||||
with open(sidecar, "w") as file:
|
with open(sidecar, "w") as file:
|
||||||
@@ -116,7 +116,7 @@ def generate_sidecars(dbname, uuid_dict):
|
|||||||
|
|
||||||
# albums_as_keywords
|
# albums_as_keywords
|
||||||
sidecar = str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_albums_as_keywords.json")
|
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)
|
ExportOptions(use_albums_as_keywords=True)
|
||||||
)
|
)
|
||||||
with open(sidecar, "w") as file:
|
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 """
|
""" test ConfigOptions class """
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import toml
|
import toml
|
||||||
|
|
||||||
@@ -43,6 +45,15 @@ def test_write_to_file_load_from_file(tmpdir):
|
|||||||
assert cfg2.bar
|
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):
|
def test_load_from_file_error(tmpdir):
|
||||||
cfg_file = pathlib.Path(str(tmpdir)) / "test.toml"
|
cfg_file = pathlib.Path(str(tmpdir)) / "test.toml"
|
||||||
cfg = ConfigOptions("test", VARS)
|
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:
|
with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp:
|
||||||
json_expected = json.load(fp)[0]
|
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]
|
json_got = json.loads(json_got)[0]
|
||||||
|
|
||||||
assert json_got == json_expected
|
assert json_got == json_expected
|
||||||
@@ -420,7 +420,7 @@ def test_exiftool_json_sidecar_ignore_date_modified(photosdb):
|
|||||||
) as fp:
|
) as fp:
|
||||||
json_expected = json.load(fp)[0]
|
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)
|
ExportOptions(ignore_date_modified=True)
|
||||||
)
|
)
|
||||||
json_got = json.loads(json_got)[0]
|
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)
|
long_str = "x" * (_MAX_IPTC_KEYWORD_LEN + 1)
|
||||||
photos[0]._verbose = print
|
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])
|
ExportOptions(keyword_template=[long_str])
|
||||||
)
|
)
|
||||||
json_got = json.loads(json_got)[0]
|
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"
|
str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.json"), "r"
|
||||||
) as fp:
|
) as fp:
|
||||||
json_expected = json.load(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}"])
|
ExportOptions(keyword_template=["{folder_album}"])
|
||||||
)
|
)
|
||||||
json_got = json.loads(json_got)
|
json_got = json.loads(json_got)
|
||||||
@@ -497,7 +497,7 @@ def test_exiftool_json_sidecar_use_persons_keyword(photosdb):
|
|||||||
) as fp:
|
) as fp:
|
||||||
json_expected = json.load(fp)[0]
|
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)
|
ExportOptions(use_persons_as_keywords=True)
|
||||||
)
|
)
|
||||||
json_got = json.loads(json_got)[0]
|
json_got = json.loads(json_got)[0]
|
||||||
@@ -515,7 +515,7 @@ def test_exiftool_json_sidecar_use_albums_keywords(photosdb):
|
|||||||
) as fp:
|
) as fp:
|
||||||
json_expected = json.load(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)
|
ExportOptions(use_albums_as_keywords=True)
|
||||||
)
|
)
|
||||||
json_got = json.loads(json_got)
|
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:
|
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_no_tag_groups.json", "r") as fp:
|
||||||
json_expected = fp.read()
|
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
|
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:
|
with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp:
|
||||||
json_expected = json.load(fp)[0]
|
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]
|
json_got = json.loads(json_got)[0]
|
||||||
|
|
||||||
assert json_got == json_expected
|
assert json_got == json_expected
|
||||||
|
|||||||
Reference in New Issue
Block a user