Added --report option to CLI, implements #253
This commit is contained in:
@@ -373,6 +373,8 @@ Options:
|
||||
work with iTerm2 (use with Terminal.app).
|
||||
This is faster and more reliable than the
|
||||
default AppleScript interface.
|
||||
--report REPORTNAME.CSV Write a CSV formatted report of all files
|
||||
that were exported.
|
||||
-h, --help Show this message and exit.
|
||||
|
||||
** Export **
|
||||
|
||||
@@ -1413,6 +1413,12 @@ def query(
|
||||
"Highly experimental alpha feature; does not work with iTerm2 (use with Terminal.app). "
|
||||
"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
|
||||
@click.argument("dest", nargs=1, type=click.Path(exists=True))
|
||||
@click.pass_obj
|
||||
@@ -1505,6 +1511,7 @@ def export(
|
||||
deleted_only,
|
||||
use_photos_export,
|
||||
use_photokit,
|
||||
report,
|
||||
):
|
||||
""" Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -1522,7 +1529,12 @@ def export(
|
||||
VERBOSE = bool(verbose_)
|
||||
|
||||
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
|
||||
exclusive = [
|
||||
@@ -1729,8 +1741,11 @@ def export(
|
||||
results_skipped = []
|
||||
results_exif_updated = []
|
||||
results_touched = []
|
||||
results_sidecar_json = []
|
||||
results_sidecar_xmp = []
|
||||
results_converted = []
|
||||
results_sidecar_json_written = []
|
||||
results_sidecar_json_skipped = []
|
||||
results_sidecar_xmp_written = []
|
||||
results_sidecar_xmp_skipped = []
|
||||
if verbose_:
|
||||
for p in photos:
|
||||
results = export_photo(
|
||||
@@ -1774,8 +1789,11 @@ def export(
|
||||
results_skipped.extend(results.skipped)
|
||||
results_exif_updated.extend(results.exif_updated)
|
||||
results_touched.extend(results.touched)
|
||||
results_sidecar_json.extend(results.sidecar_json)
|
||||
results_sidecar_xmp.extend(results.sidecar_xmp)
|
||||
results_converted.extend(results.converted_to_jpeg)
|
||||
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":
|
||||
# for photo_file in set(
|
||||
@@ -1828,8 +1846,11 @@ def export(
|
||||
results_skipped.extend(results.skipped)
|
||||
results_exif_updated.extend(results.exif_updated)
|
||||
results_touched.extend(results.touched)
|
||||
results_sidecar_json.extend(results.sidecar_json)
|
||||
results_sidecar_xmp.extend(results.sidecar_xmp)
|
||||
results_converted.extend(results.converted_to_jpeg)
|
||||
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()
|
||||
# print summary results
|
||||
@@ -1839,9 +1860,27 @@ def export(
|
||||
# print(f"results_skipped: {results_skipped}")
|
||||
# print(f"results_exif_updated: {results_exif_updated}")
|
||||
# print(f"results_touched: {results_touched}")
|
||||
# print(f"results_converted: {results_converted}")
|
||||
# print(f"results_sidecar_json: {results_sidecar_json}")
|
||||
# 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:
|
||||
photo_str_new = "photos" if len(results_new) != 1 else "photo"
|
||||
photo_str_updated = "photos" if len(results_updated) != 1 else "photo"
|
||||
@@ -2391,19 +2430,19 @@ def export_photo(
|
||||
if photo.ismissing:
|
||||
space = " " if not verbose_ else ""
|
||||
verbose(f"{space}Skipping missing photo {photo.original_filename}")
|
||||
return ExportResults([], [], [], [], [], [], [], [])
|
||||
return ExportResults([], [], [], [], [], [], [], [], [], [], [])
|
||||
elif photo.path is None:
|
||||
space = " " if not verbose_ else ""
|
||||
verbose(
|
||||
f"{space}WARNING: photo {photo.original_filename} ({photo.uuid}) is missing but ismissing=False, "
|
||||
f"skipping {photo.original_filename}"
|
||||
)
|
||||
return ExportResults([], [], [], [], [], [], [], [])
|
||||
return ExportResults([], [], [], [], [], [], [], [], [], [], [])
|
||||
elif photo.ismissing and not photo.iscloudasset and not photo.incloud:
|
||||
verbose(
|
||||
f"Skipping missing {photo.original_filename}: not iCloud asset or missing from cloud"
|
||||
)
|
||||
return ExportResults([], [], [], [], [], [], [], [])
|
||||
return ExportResults([], [], [], [], [], [], [], [], [], [], [])
|
||||
|
||||
results_exported = []
|
||||
results_new = []
|
||||
@@ -2411,8 +2450,11 @@ def export_photo(
|
||||
results_skipped = []
|
||||
results_exif_updated = []
|
||||
results_touched = []
|
||||
results_sidecar_json = []
|
||||
results_sidecar_xmp = []
|
||||
results_converted = []
|
||||
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)
|
||||
|
||||
@@ -2507,8 +2549,11 @@ def export_photo(
|
||||
results_skipped.extend(export_results.skipped)
|
||||
results_exif_updated.extend(export_results.exif_updated)
|
||||
results_touched.extend(export_results.touched)
|
||||
results_sidecar_json.extend(export_results.sidecar_json)
|
||||
results_sidecar_xmp.extend(export_results.sidecar_xmp)
|
||||
results_converted.extend(export_results.converted_to_jpeg)
|
||||
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_:
|
||||
for exported in export_results.exported:
|
||||
@@ -2580,8 +2625,19 @@ def export_photo(
|
||||
results_skipped.extend(export_results_edited.skipped)
|
||||
results_exif_updated.extend(export_results_edited.exif_updated)
|
||||
results_touched.extend(export_results_edited.touched)
|
||||
results_sidecar_json.extend(export_results_edited.sidecar_json)
|
||||
results_sidecar_xmp.extend(export_results_edited.sidecar_xmp)
|
||||
results_converted.extend(export_results_edited.converted_to_jpeg)
|
||||
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_:
|
||||
for exported in export_results_edited.exported:
|
||||
@@ -2602,8 +2658,11 @@ def export_photo(
|
||||
results_skipped,
|
||||
results_exif_updated,
|
||||
results_touched,
|
||||
results_sidecar_json,
|
||||
results_sidecar_xmp,
|
||||
results_converted,
|
||||
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
|
||||
|
||||
|
||||
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__":
|
||||
cli() # pylint: disable=no-value-for-parameter
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.37.0"
|
||||
__version__ = "0.37.1"
|
||||
|
||||
|
||||
@@ -53,8 +53,11 @@ ExportResults = namedtuple(
|
||||
"skipped",
|
||||
"exif_updated",
|
||||
"touched",
|
||||
"sidecar_json",
|
||||
"sidecar_xmp",
|
||||
"converted_to_jpeg",
|
||||
"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)
|
||||
touched_files = []
|
||||
|
||||
# list of all files convereted to jpeg
|
||||
converted_to_jpeg_files = []
|
||||
|
||||
# check edited and raise exception trying to export edited version of
|
||||
# photo that hasn't been edited
|
||||
if edited and not self.hasadjustments:
|
||||
@@ -597,6 +603,7 @@ def export2(
|
||||
update_updated_files = results.updated
|
||||
update_skipped_files = results.skipped
|
||||
touched_files = results.touched
|
||||
converted_to_jpeg_files = results.converted_to_jpeg
|
||||
|
||||
# copy live photo associated .mov if requested
|
||||
if live_photo and self.live_photo:
|
||||
@@ -622,6 +629,7 @@ def export2(
|
||||
update_updated_files.extend(results.updated)
|
||||
update_skipped_files.extend(results.skipped)
|
||||
touched_files.extend(results.touched)
|
||||
converted_to_jpeg_files.extend(results.converted_to_jpeg)
|
||||
|
||||
# copy associated RAW image if requested
|
||||
if raw_photo and self.has_raw:
|
||||
@@ -648,6 +656,7 @@ def export2(
|
||||
update_updated_files.extend(results.updated)
|
||||
update_skipped_files.extend(results.skipped)
|
||||
touched_files.extend(results.touched)
|
||||
converted_to_jpeg_files.extend(results.converted_to_jpeg)
|
||||
else:
|
||||
# use_photo_export
|
||||
exported = []
|
||||
@@ -748,10 +757,10 @@ def export2(
|
||||
)
|
||||
|
||||
# export metadata
|
||||
sidecar_json_files = []
|
||||
sidecar_json_files_skipped = []
|
||||
sidecar_json_files_written = []
|
||||
if sidecar_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(
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
@@ -774,6 +783,7 @@ def export2(
|
||||
)
|
||||
if write_sidecar:
|
||||
verbose(f"Writing exiftool JSON sidecar {sidecar_filename}")
|
||||
sidecar_json_files_written.append(str(sidecar_filename))
|
||||
if not dry_run:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
export_db.set_sidecar_for_file(
|
||||
@@ -783,11 +793,12 @@ def export2(
|
||||
)
|
||||
else:
|
||||
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:
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp")
|
||||
sidecar_xmp_files.append(str(sidecar_filename))
|
||||
sidecar_str = self._xmp_sidecar(
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
@@ -810,6 +821,7 @@ def export2(
|
||||
)
|
||||
if write_sidecar:
|
||||
verbose(f"Writing XMP sidecar {sidecar_filename}")
|
||||
sidecar_xmp_files_written.append(str(sidecar_filename))
|
||||
if not dry_run:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
export_db.set_sidecar_for_file(
|
||||
@@ -819,6 +831,7 @@ def export2(
|
||||
)
|
||||
else:
|
||||
verbose(f"Skipped up to date XMP sidecar {sidecar_filename}")
|
||||
sidecar_xmp_files_skipped.append(str(sidecar_filename))
|
||||
|
||||
# if exiftool, write the metadata
|
||||
if update:
|
||||
@@ -918,8 +931,11 @@ def export2(
|
||||
update_skipped_files,
|
||||
exif_files_updated,
|
||||
touched_files,
|
||||
sidecar_json_files,
|
||||
sidecar_xmp_files,
|
||||
converted_to_jpeg_files,
|
||||
sidecar_json_files_written,
|
||||
sidecar_json_files_skipped,
|
||||
sidecar_xmp_files_written,
|
||||
sidecar_xmp_files_skipped,
|
||||
)
|
||||
return results
|
||||
|
||||
@@ -976,6 +992,7 @@ def _export_photo(
|
||||
update_new_files = []
|
||||
update_skipped_files = []
|
||||
touched_files = []
|
||||
converted_to_jpeg_files = []
|
||||
|
||||
dest_str = str(dest)
|
||||
dest_exists = dest.exists()
|
||||
@@ -1065,6 +1082,7 @@ def _export_photo(
|
||||
# use convert_to_jpeg to export the file
|
||||
fileutil.convert_to_jpeg(src, dest_str, compression_quality=jpeg_quality)
|
||||
converted_stat = fileutil.file_sig(dest_str)
|
||||
converted_to_jpeg_files.append(dest_str)
|
||||
else:
|
||||
fileutil.copy(src, dest_str, norsrc=no_xattr)
|
||||
|
||||
@@ -1090,6 +1108,9 @@ def _export_photo(
|
||||
update_skipped_files,
|
||||
[],
|
||||
touched_files,
|
||||
converted_to_jpeg_files,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -3763,3 +3763,44 @@ def test_persons():
|
||||
|
||||
json_got = json.loads(result.output)
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user