Added --report option to CLI, implements #253

This commit is contained in:
Rhet Turnbull
2020-11-28 09:24:16 -08:00
parent adf2ba7678
commit d22eaf39ed
5 changed files with 256 additions and 27 deletions

View File

@@ -373,6 +373,8 @@ Options:
work with iTerm2 (use with Terminal.app). work with iTerm2 (use with Terminal.app).
This is faster and more reliable than the This is faster and more reliable than the
default AppleScript interface. default AppleScript interface.
--report REPORTNAME.CSV Write a CSV formatted report of all files
that were exported.
-h, --help Show this message and exit. -h, --help Show this message and exit.
** Export ** ** Export **

View File

@@ -1413,6 +1413,12 @@ def query(
"Highly experimental alpha feature; does not work with iTerm2 (use with Terminal.app). " "Highly experimental alpha feature; does not work with iTerm2 (use with Terminal.app). "
"This is faster and more reliable than the default AppleScript interface.", "This is faster and more reliable than the default AppleScript interface.",
) )
@click.option(
"--report",
metavar="REPORTNAME.CSV",
help="Write a CSV formatted report of all files that were exported.",
type=click.Path(),
)
@DB_ARGUMENT @DB_ARGUMENT
@click.argument("dest", nargs=1, type=click.Path(exists=True)) @click.argument("dest", nargs=1, type=click.Path(exists=True))
@click.pass_obj @click.pass_obj
@@ -1505,6 +1511,7 @@ def export(
deleted_only, deleted_only,
use_photos_export, use_photos_export,
use_photokit, use_photokit,
report,
): ):
""" Export photos from the Photos database. """ Export photos from the Photos database.
Export path DEST is required. Export path DEST is required.
@@ -1522,7 +1529,12 @@ def export(
VERBOSE = bool(verbose_) VERBOSE = bool(verbose_)
if not os.path.isdir(dest): if not os.path.isdir(dest):
sys.exit(f"DEST {dest} must be valid path") click.echo(f"DEST {dest} must be valid path", err=True)
raise click.Abort()
if report and os.path.isdir(report):
click.echo(f"report is a directory, must be file name", err=True)
raise click.Abort()
# sanity check input args # sanity check input args
exclusive = [ exclusive = [
@@ -1729,8 +1741,11 @@ def export(
results_skipped = [] results_skipped = []
results_exif_updated = [] results_exif_updated = []
results_touched = [] results_touched = []
results_sidecar_json = [] results_converted = []
results_sidecar_xmp = [] results_sidecar_json_written = []
results_sidecar_json_skipped = []
results_sidecar_xmp_written = []
results_sidecar_xmp_skipped = []
if verbose_: if verbose_:
for p in photos: for p in photos:
results = export_photo( results = export_photo(
@@ -1774,8 +1789,11 @@ def export(
results_skipped.extend(results.skipped) results_skipped.extend(results.skipped)
results_exif_updated.extend(results.exif_updated) results_exif_updated.extend(results.exif_updated)
results_touched.extend(results.touched) results_touched.extend(results.touched)
results_sidecar_json.extend(results.sidecar_json) results_converted.extend(results.converted_to_jpeg)
results_sidecar_xmp.extend(results.sidecar_xmp) results_sidecar_json_written.extend(results.sidecar_json_written)
results_sidecar_json_skipped.extend(results.sidecar_json_skipped)
results_sidecar_xmp_written.extend(results.sidecar_xmp_written)
results_sidecar_xmp_skipped.extend(results.sidecar_xmp_skipped)
# if convert_to_jpeg and p.isphoto and p.uti != "public.jpeg": # if convert_to_jpeg and p.isphoto and p.uti != "public.jpeg":
# for photo_file in set( # for photo_file in set(
@@ -1828,8 +1846,11 @@ def export(
results_skipped.extend(results.skipped) results_skipped.extend(results.skipped)
results_exif_updated.extend(results.exif_updated) results_exif_updated.extend(results.exif_updated)
results_touched.extend(results.touched) results_touched.extend(results.touched)
results_sidecar_json.extend(results.sidecar_json) results_converted.extend(results.converted_to_jpeg)
results_sidecar_xmp.extend(results.sidecar_xmp) results_sidecar_json_written.extend(results.sidecar_json_written)
results_sidecar_json_skipped.extend(results.sidecar_json_skipped)
results_sidecar_xmp_written.extend(results.sidecar_xmp_written)
results_sidecar_xmp_skipped.extend(results.sidecar_xmp_skipped)
stop_time = time.perf_counter() stop_time = time.perf_counter()
# print summary results # print summary results
@@ -1839,9 +1860,27 @@ def export(
# print(f"results_skipped: {results_skipped}") # print(f"results_skipped: {results_skipped}")
# print(f"results_exif_updated: {results_exif_updated}") # print(f"results_exif_updated: {results_exif_updated}")
# print(f"results_touched: {results_touched}") # print(f"results_touched: {results_touched}")
# print(f"results_converted: {results_converted}")
# print(f"results_sidecar_json: {results_sidecar_json}") # print(f"results_sidecar_json: {results_sidecar_json}")
# print(f"results_sidecar_xmp: {results_sidecar_xmp}") # print(f"results_sidecar_xmp: {results_sidecar_xmp}")
if report:
verbose(f"Writing export report to {report}")
write_export_report(
report,
results_exported=results_exported,
results_new=results_new,
results_updated=results_updated,
results_skipped=results_skipped,
results_exif_updated=results_exif_updated,
results_touched=results_touched,
results_converted=results_converted,
results_sidecar_json_written=results_sidecar_json_written,
results_sidecar_json_skipped=results_sidecar_json_skipped,
results_sidecar_xmp_written=results_sidecar_xmp_written,
results_sidecar_xmp_skipped=results_sidecar_xmp_skipped,
)
if update: if update:
photo_str_new = "photos" if len(results_new) != 1 else "photo" photo_str_new = "photos" if len(results_new) != 1 else "photo"
photo_str_updated = "photos" if len(results_updated) != 1 else "photo" photo_str_updated = "photos" if len(results_updated) != 1 else "photo"
@@ -2391,19 +2430,19 @@ def export_photo(
if photo.ismissing: if photo.ismissing:
space = " " if not verbose_ else "" space = " " if not verbose_ else ""
verbose(f"{space}Skipping missing photo {photo.original_filename}") verbose(f"{space}Skipping missing photo {photo.original_filename}")
return ExportResults([], [], [], [], [], [], [], []) return ExportResults([], [], [], [], [], [], [], [], [], [], [])
elif photo.path is None: elif photo.path is None:
space = " " if not verbose_ else "" space = " " if not verbose_ else ""
verbose( verbose(
f"{space}WARNING: photo {photo.original_filename} ({photo.uuid}) is missing but ismissing=False, " f"{space}WARNING: photo {photo.original_filename} ({photo.uuid}) is missing but ismissing=False, "
f"skipping {photo.original_filename}" f"skipping {photo.original_filename}"
) )
return ExportResults([], [], [], [], [], [], [], []) return ExportResults([], [], [], [], [], [], [], [], [], [], [])
elif photo.ismissing and not photo.iscloudasset and not photo.incloud: elif photo.ismissing and not photo.iscloudasset and not photo.incloud:
verbose( verbose(
f"Skipping missing {photo.original_filename}: not iCloud asset or missing from cloud" f"Skipping missing {photo.original_filename}: not iCloud asset or missing from cloud"
) )
return ExportResults([], [], [], [], [], [], [], []) return ExportResults([], [], [], [], [], [], [], [], [], [], [])
results_exported = [] results_exported = []
results_new = [] results_new = []
@@ -2411,8 +2450,11 @@ def export_photo(
results_skipped = [] results_skipped = []
results_exif_updated = [] results_exif_updated = []
results_touched = [] results_touched = []
results_sidecar_json = [] results_converted = []
results_sidecar_xmp = [] results_sidecar_json_written = []
results_sidecar_json_skipped = []
results_sidecar_xmp_written = []
results_sidecar_xmp_skipped = []
export_original = not (skip_original_if_edited and photo.hasadjustments) export_original = not (skip_original_if_edited and photo.hasadjustments)
@@ -2507,8 +2549,11 @@ def export_photo(
results_skipped.extend(export_results.skipped) results_skipped.extend(export_results.skipped)
results_exif_updated.extend(export_results.exif_updated) results_exif_updated.extend(export_results.exif_updated)
results_touched.extend(export_results.touched) results_touched.extend(export_results.touched)
results_sidecar_json.extend(export_results.sidecar_json) results_converted.extend(export_results.converted_to_jpeg)
results_sidecar_xmp.extend(export_results.sidecar_xmp) results_sidecar_json_written.extend(export_results.sidecar_json_written)
results_sidecar_json_skipped.extend(export_results.sidecar_json_skipped)
results_sidecar_xmp_written.extend(export_results.sidecar_xmp_written)
results_sidecar_xmp_skipped.extend(export_results.sidecar_xmp_skipped)
if verbose_: if verbose_:
for exported in export_results.exported: for exported in export_results.exported:
@@ -2580,8 +2625,19 @@ def export_photo(
results_skipped.extend(export_results_edited.skipped) results_skipped.extend(export_results_edited.skipped)
results_exif_updated.extend(export_results_edited.exif_updated) results_exif_updated.extend(export_results_edited.exif_updated)
results_touched.extend(export_results_edited.touched) results_touched.extend(export_results_edited.touched)
results_sidecar_json.extend(export_results_edited.sidecar_json) results_converted.extend(export_results_edited.converted_to_jpeg)
results_sidecar_xmp.extend(export_results_edited.sidecar_xmp) results_sidecar_json_written.extend(
export_results_edited.sidecar_json_written
)
results_sidecar_json_skipped.extend(
export_results_edited.sidecar_json_skipped
)
results_sidecar_xmp_written.extend(
export_results_edited.sidecar_xmp_written
)
results_sidecar_xmp_skipped.extend(
export_results_edited.sidecar_xmp_skipped
)
if verbose_: if verbose_:
for exported in export_results_edited.exported: for exported in export_results_edited.exported:
@@ -2602,8 +2658,11 @@ def export_photo(
results_skipped, results_skipped,
results_exif_updated, results_exif_updated,
results_touched, results_touched,
results_sidecar_json, results_converted,
results_sidecar_xmp, results_sidecar_json_written,
results_sidecar_json_skipped,
results_sidecar_xmp_written,
results_sidecar_xmp_skipped,
) )
@@ -2755,5 +2814,111 @@ def load_uuid_from_file(filename):
return uuid return uuid
def write_export_report(
report_file,
results_exported,
results_new,
results_updated,
results_skipped,
results_exif_updated,
results_touched,
results_converted,
results_sidecar_json_written,
results_sidecar_json_skipped,
results_sidecar_xmp_written,
results_sidecar_xmp_skipped,
):
""" write CSV report with results from export """
# Collect results for reporting
# TODO: pull this in a separate write_report function
all_results = {
result: {
"filename": result,
"exported": 0,
"new": 0,
"updated": 0,
"skipped": 0,
"exif_updated": 0,
"touched": 0,
"converted_to_jpeg": 0,
"sidecar_xmp": 0,
"sidecar_json": 0,
}
for result in results_exported
+ results_new
+ results_updated
+ results_skipped
+ results_exif_updated
+ results_touched
+ results_converted
+ results_sidecar_json_written
+ results_sidecar_json_skipped
+ results_sidecar_xmp_written
+ results_sidecar_xmp_skipped
}
for result in results_exported:
all_results[result]["exported"] = 1
for result in results_new:
all_results[result]["new"] = 1
for result in results_updated:
all_results[result]["updated"] = 1
for result in results_skipped:
all_results[result]["skipped"] = 1
for result in results_exif_updated:
all_results[result]["exif_updated"] = 1
for result in results_touched:
all_results[result]["touched"] = 1
for result in results_converted:
all_results[result]["converted_to_jpeg"] = 1
for result in results_sidecar_xmp_written:
all_results[result]["sidecar_xmp"] = 1
all_results[result]["exported"] = 1
for result in results_sidecar_xmp_skipped:
all_results[result]["sidecar_xmp"] = 1
all_results[result]["skipped"] = 1
for result in results_sidecar_json_written:
all_results[result]["sidecar_json"] = 1
all_results[result]["exported"] = 1
for result in results_sidecar_json_skipped:
all_results[result]["sidecar_json"] = 1
all_results[result]["skipped"] = 1
report_columns = [
"filename",
"exported",
"new",
"updated",
"skipped",
"exif_updated",
"touched",
"converted_to_jpeg",
"sidecar_xmp",
"sidecar_json",
]
try:
with open(report_file, "w") as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=report_columns)
writer.writeheader()
for data in [result for result in all_results.values()]:
writer.writerow(data)
except IOError:
click.echo("Could not open output file for writing", err=True)
raise click.Abort()
if __name__ == "__main__": if __name__ == "__main__":
cli() # pylint: disable=no-value-for-parameter cli() # pylint: disable=no-value-for-parameter

View File

@@ -1,4 +1,4 @@
""" version info """ """ version info """
__version__ = "0.37.0" __version__ = "0.37.1"

View File

@@ -53,8 +53,11 @@ ExportResults = namedtuple(
"skipped", "skipped",
"exif_updated", "exif_updated",
"touched", "touched",
"sidecar_json", "converted_to_jpeg",
"sidecar_xmp", "sidecar_json_written",
"sidecar_json_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
], ],
) )
@@ -425,6 +428,9 @@ def export2(
# list of all files with utime touched (touch_file = True) # list of all files with utime touched (touch_file = True)
touched_files = [] touched_files = []
# list of all files convereted to jpeg
converted_to_jpeg_files = []
# check edited and raise exception trying to export edited version of # check edited and raise exception trying to export edited version of
# photo that hasn't been edited # photo that hasn't been edited
if edited and not self.hasadjustments: if edited and not self.hasadjustments:
@@ -597,6 +603,7 @@ def export2(
update_updated_files = results.updated update_updated_files = results.updated
update_skipped_files = results.skipped update_skipped_files = results.skipped
touched_files = results.touched touched_files = results.touched
converted_to_jpeg_files = results.converted_to_jpeg
# copy live photo associated .mov if requested # copy live photo associated .mov if requested
if live_photo and self.live_photo: if live_photo and self.live_photo:
@@ -622,6 +629,7 @@ def export2(
update_updated_files.extend(results.updated) update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped) update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched) touched_files.extend(results.touched)
converted_to_jpeg_files.extend(results.converted_to_jpeg)
# copy associated RAW image if requested # copy associated RAW image if requested
if raw_photo and self.has_raw: if raw_photo and self.has_raw:
@@ -648,6 +656,7 @@ def export2(
update_updated_files.extend(results.updated) update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped) update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched) touched_files.extend(results.touched)
converted_to_jpeg_files.extend(results.converted_to_jpeg)
else: else:
# use_photo_export # use_photo_export
exported = [] exported = []
@@ -748,10 +757,10 @@ def export2(
) )
# export metadata # export metadata
sidecar_json_files = [] sidecar_json_files_skipped = []
sidecar_json_files_written = []
if sidecar_json: if sidecar_json:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.json") sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.json")
sidecar_json_files.append(str(sidecar_filename))
sidecar_str = self._exiftool_json_sidecar( sidecar_str = self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords, use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords, use_persons_as_keywords=use_persons_as_keywords,
@@ -774,6 +783,7 @@ def export2(
) )
if write_sidecar: if write_sidecar:
verbose(f"Writing exiftool JSON sidecar {sidecar_filename}") verbose(f"Writing exiftool JSON sidecar {sidecar_filename}")
sidecar_json_files_written.append(str(sidecar_filename))
if not dry_run: if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str) self._write_sidecar(sidecar_filename, sidecar_str)
export_db.set_sidecar_for_file( export_db.set_sidecar_for_file(
@@ -783,11 +793,12 @@ def export2(
) )
else: else:
verbose(f"Skipped up to date exiftool JSON sidecar {sidecar_filename}") verbose(f"Skipped up to date exiftool JSON sidecar {sidecar_filename}")
sidecar_json_files_skipped.append(str(sidecar_filename))
sidecar_xmp_files = [] sidecar_xmp_files_skipped = []
sidecar_xmp_files_written = []
if sidecar_xmp: if sidecar_xmp:
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp") sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp")
sidecar_xmp_files.append(str(sidecar_filename))
sidecar_str = self._xmp_sidecar( sidecar_str = self._xmp_sidecar(
use_albums_as_keywords=use_albums_as_keywords, use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords, use_persons_as_keywords=use_persons_as_keywords,
@@ -810,6 +821,7 @@ def export2(
) )
if write_sidecar: if write_sidecar:
verbose(f"Writing XMP sidecar {sidecar_filename}") verbose(f"Writing XMP sidecar {sidecar_filename}")
sidecar_xmp_files_written.append(str(sidecar_filename))
if not dry_run: if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str) self._write_sidecar(sidecar_filename, sidecar_str)
export_db.set_sidecar_for_file( export_db.set_sidecar_for_file(
@@ -819,6 +831,7 @@ def export2(
) )
else: else:
verbose(f"Skipped up to date XMP sidecar {sidecar_filename}") verbose(f"Skipped up to date XMP sidecar {sidecar_filename}")
sidecar_xmp_files_skipped.append(str(sidecar_filename))
# if exiftool, write the metadata # if exiftool, write the metadata
if update: if update:
@@ -918,8 +931,11 @@ def export2(
update_skipped_files, update_skipped_files,
exif_files_updated, exif_files_updated,
touched_files, touched_files,
sidecar_json_files, converted_to_jpeg_files,
sidecar_xmp_files, sidecar_json_files_written,
sidecar_json_files_skipped,
sidecar_xmp_files_written,
sidecar_xmp_files_skipped,
) )
return results return results
@@ -976,6 +992,7 @@ def _export_photo(
update_new_files = [] update_new_files = []
update_skipped_files = [] update_skipped_files = []
touched_files = [] touched_files = []
converted_to_jpeg_files = []
dest_str = str(dest) dest_str = str(dest)
dest_exists = dest.exists() dest_exists = dest.exists()
@@ -1065,6 +1082,7 @@ def _export_photo(
# use convert_to_jpeg to export the file # use convert_to_jpeg to export the file
fileutil.convert_to_jpeg(src, dest_str, compression_quality=jpeg_quality) fileutil.convert_to_jpeg(src, dest_str, compression_quality=jpeg_quality)
converted_stat = fileutil.file_sig(dest_str) converted_stat = fileutil.file_sig(dest_str)
converted_to_jpeg_files.append(dest_str)
else: else:
fileutil.copy(src, dest_str, norsrc=no_xattr) fileutil.copy(src, dest_str, norsrc=no_xattr)
@@ -1090,6 +1108,9 @@ def _export_photo(
update_skipped_files, update_skipped_files,
[], [],
touched_files, touched_files,
converted_to_jpeg_files,
[],
[],
[], [],
[], [],
) )

View File

@@ -3763,3 +3763,44 @@ def test_persons():
json_got = json.loads(result.output) json_got = json.loads(result.output)
assert json_got == PERSONS_JSON assert json_got == PERSONS_JSON
def test_export_report():
""" test export with --report option """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "report.csv"],
)
assert result.exit_code == 0
assert "Writing export report" in result.output
assert os.path.exists("report.csv")
def test_export_report_not_a_file():
""" test export with --report option and bad report value """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "."]
)
assert result.exit_code != 0
assert "Aborted!" in result.output