diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 791c7a03..83af960b 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -1778,6 +1778,8 @@ def export( results_sidecar_json_skipped = [] results_sidecar_xmp_written = [] results_sidecar_xmp_skipped = [] + results_missing = [] + results_error = [] if verbose_: for p in photos: results = export_photo( @@ -1826,6 +1828,8 @@ def export( 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) + results_missing.extend(results.missing) + results_error.extend(results.error) # if convert_to_jpeg and p.isphoto and p.uti != "public.jpeg": # for photo_file in set( @@ -1883,6 +1887,8 @@ def export( 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) + results_missing.extend(results.missing) + results_error.extend(results.error) stop_time = time.perf_counter() # print summary results @@ -1895,6 +1901,8 @@ def export( # print(f"results_converted: {results_converted}") # print(f"results_sidecar_json: {results_sidecar_json}") # print(f"results_sidecar_xmp: {results_sidecar_xmp}") + # print(f"results_missing: {results_missing}") + # print(f"results_error: {results_error}") if report: verbose(f"Writing export report to {report}") @@ -1911,27 +1919,28 @@ def export( results_sidecar_json_skipped=results_sidecar_json_skipped, results_sidecar_xmp_written=results_sidecar_xmp_written, results_sidecar_xmp_skipped=results_sidecar_xmp_skipped, + results_missing=results_missing, + results_error=results_error, ) + photo_str_total = "photos" if len(photos) != 1 else "photo" if update: - photo_str_new = "photos" if len(results_new) != 1 else "photo" - photo_str_updated = "photos" if len(results_updated) != 1 else "photo" - photo_str_skipped = "photos" if len(results_skipped) != 1 else "photo" - photo_str_exif_updated = ( - "photos" if len(results_exif_updated) != 1 else "photo" - ) summary = ( - f"Exported: {len(results_new)} {photo_str_new}, " - f"updated: {len(results_updated)} {photo_str_updated}, " - f"skipped: {len(results_skipped)} {photo_str_skipped}, " - f"updated EXIF data: {len(results_exif_updated)} {photo_str_exif_updated}" + f"Processed: {len(photos)} {photo_str_total}, " + f"exported: {len(results_new)}, " + f"updated: {len(results_updated)}, " + f"skipped: {len(results_skipped)}, " + f"updated EXIF data: {len(results_exif_updated)}, " ) else: - photo_str = "photos" if len(results_exported) != 1 else "photo" - summary = f"Exported: {len(results_exported)} {photo_str}" - photo_str_touched = "photos" if len(results_touched) != 1 else "photo" + summary = ( + f"Processed: {len(photos)} {photo_str_total}, " + f"exported: {len(results_exported)}, " + ) + summary += f"missing: {len(results_missing)}, " + summary += f"error: {len(results_error)}" if touch_file: - summary += f", touched date: {len(results_touched)} {photo_str_touched}" + summary += f", touched date: {len(results_touched)}" click.echo(summary) click.echo(f"Elapsed time: {(stop_time-start_time):.3f} seconds") else: @@ -2462,19 +2471,61 @@ def export_photo( if photo.ismissing: space = " " if not verbose_ else "" verbose(f"{space}Skipping missing photo {photo.original_filename}") - return ExportResults([], [], [], [], [], [], [], [], [], [], []) + return ExportResults( + exported=[], + new=[], + updated=[], + skipped=[], + exif_updated=[], + touched=[], + converted_to_jpeg=[], + sidecar_json_written=[], + sidecar_json_skipped=[], + sidecar_xmp_written=[], + sidecar_xmp_skipped=[], + missing=[f"{photo.original_filename} ({photo.uuid})"], + error=[], + ) 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( + exported=[], + new=[], + updated=[], + skipped=[], + exif_updated=[], + touched=[], + converted_to_jpeg=[], + sidecar_json_written=[], + sidecar_json_skipped=[], + sidecar_xmp_written=[], + sidecar_xmp_skipped=[], + missing=[f"{photo.original_filename} ({photo.uuid})"], + error=[], + ) 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( + exported=[], + new=[], + updated=[], + skipped=[], + exif_updated=[], + touched=[], + converted_to_jpeg=[], + sidecar_json_written=[], + sidecar_json_skipped=[], + sidecar_xmp_written=[], + sidecar_xmp_skipped=[], + missing=[f"{photo.original_filename} ({photo.uuid})"], + error=[], + ) results_exported = [] results_new = [] @@ -2487,6 +2538,7 @@ def export_photo( results_sidecar_json_skipped = [] results_sidecar_xmp_written = [] results_sidecar_xmp_skipped = [] + results_error = [] export_original = not (skip_original_if_edited and photo.hasadjustments) @@ -2612,6 +2664,7 @@ def export_photo( f"Error exporting photo {photo.original_filename} ({photo.filename}) as {original_filename}", err=True, ) + results_error.extend(dest) else: verbose(f"Skipping original version of {photo.original_filename}") @@ -2703,19 +2756,22 @@ def export_photo( f"Error exporting photo {filename} as {edited_filename}", err=True, ) + results_error.extend(dest) return ExportResults( - 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, + exported=results_exported, + new=results_new, + updated=results_updated, + skipped=results_skipped, + exif_updated=results_exif_updated, + touched=results_touched, + converted_to_jpeg=results_converted, + sidecar_json_written=results_sidecar_json_written, + sidecar_json_skipped=results_sidecar_json_skipped, + sidecar_xmp_written=results_sidecar_xmp_written, + sidecar_xmp_skipped=results_sidecar_xmp_skipped, + missing=[], + error=results_error, ) @@ -2880,6 +2936,8 @@ def write_export_report( results_sidecar_json_skipped, results_sidecar_xmp_written, results_sidecar_xmp_skipped, + results_missing, + results_error, ): """ write CSV report with results from export """ @@ -2898,6 +2956,8 @@ def write_export_report( "converted_to_jpeg": 0, "sidecar_xmp": 0, "sidecar_json": 0, + "missing": 0, + "error": 0, } for result in results_exported + results_new @@ -2910,6 +2970,8 @@ def write_export_report( + results_sidecar_json_skipped + results_sidecar_xmp_written + results_sidecar_xmp_skipped + + results_missing + + results_error } for result in results_exported: @@ -2949,6 +3011,12 @@ def write_export_report( all_results[result]["sidecar_json"] = 1 all_results[result]["skipped"] = 1 + for result in results_missing: + all_results[result]["missing"] = 1 + + for result in results_error: + all_results[result]["error"] = 1 + report_columns = [ "filename", "exported", @@ -2960,6 +3028,8 @@ def write_export_report( "converted_to_jpeg", "sidecar_xmp", "sidecar_json", + "missing", + "error", ] try: diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 933a1378..ea1d6bde 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,4 +1,4 @@ """ version info """ -__version__ = "0.37.4" +__version__ = "0.37.5" diff --git a/osxphotos/photoinfo/_photoinfo_export.py b/osxphotos/photoinfo/_photoinfo_export.py index 232df492..76b49e76 100644 --- a/osxphotos/photoinfo/_photoinfo_export.py +++ b/osxphotos/photoinfo/_photoinfo_export.py @@ -59,6 +59,8 @@ ExportResults = namedtuple( "sidecar_json_skipped", "sidecar_xmp_written", "sidecar_xmp_skipped", + "missing", + "error", ], ) @@ -389,9 +391,21 @@ def export2( ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output. - Returns: ExportResults namedtuple with fields: exported, new, updated, skipped - where each field is a list of file paths - + Returns: ExportResults namedtuple with fields: + "exported", + "new", + "updated", + "skipped", + "exif_updated", + "touched", + "converted_to_jpeg", + "sidecar_json_written", + "sidecar_json_skipped", + "sidecar_xmp_written", + "sidecar_xmp_skipped", + "missing", + "error" + Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db, and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp) """ @@ -926,17 +940,19 @@ def export2( touched_files = list(set(touched_files)) results = ExportResults( - exported_files, - update_new_files, - update_updated_files, - update_skipped_files, - exif_files_updated, - touched_files, - converted_to_jpeg_files, - sidecar_json_files_written, - sidecar_json_files_skipped, - sidecar_xmp_files_written, - sidecar_xmp_files_skipped, + exported=exported_files, + new=update_new_files, + updated=update_updated_files, + skipped=update_skipped_files, + exif_updated=exif_files_updated, + touched=touched_files, + converted_to_jpeg=converted_to_jpeg_files, + sidecar_json_written=sidecar_json_files_written, + sidecar_json_skipped=sidecar_json_files_skipped, + sidecar_xmp_written=sidecar_xmp_files_written, + sidecar_xmp_skipped=sidecar_xmp_files_skipped, + missing=[], + error=[], ) return results @@ -997,7 +1013,6 @@ def _export_photo( dest_str = str(dest) dest_exists = dest.exists() - op_desc = "export_as_hardlink" if export_as_hardlink else "export_by_copying" if update: # updating cmp_touch, cmp_orig = False, False @@ -1103,17 +1118,19 @@ def _export_photo( fileutil.utime(dest, (ts, ts)) return ExportResults( - exported_files + update_new_files + update_updated_files, - update_new_files, - update_updated_files, - update_skipped_files, - [], - touched_files, - converted_to_jpeg_files, - [], - [], - [], - [], + exported=exported_files + update_new_files + update_updated_files, + new=update_new_files, + updated=update_updated_files, + skipped=update_skipped_files, + exif_updated=[], + touched=touched_files, + converted_to_jpeg=converted_to_jpeg_files, + sidecar_json_written=[], + sidecar_json_skipped=[], + sidecar_xmp_written=[], + sidecar_xmp_skipped=[], + missing=[], + error=[], ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 10bb27fd..3d9a2aa3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1054,7 +1054,7 @@ def test_export_exiftool_quicktime(): exif = ExifTool(CLI_EXIFTOOL_QUICKTIME[uuid]["File:FileName"]).asdict() for key in CLI_EXIFTOOL_QUICKTIME[uuid]: assert exif[key] == CLI_EXIFTOOL_QUICKTIME[uuid][key] - + # clean up exported files to avoid name conflicts for filename in files: os.unlink(filename) @@ -2983,7 +2983,7 @@ def test_export_update_basic(): ) assert result.exit_code == 0 assert ( - "Exported: 0 photos, updated: 0 photos, skipped: 8 photos, updated EXIF data: 0 photos" + "Processed: 7 photos, exported: 0, updated: 0, skipped: 8, updated EXIF data: 0, missing: 1, error: 0" in result.output ) @@ -3067,7 +3067,7 @@ def test_export_update_exiftool(): ) assert result.exit_code == 0 assert ( - "Exported: 0 photos, updated: 8 photos, skipped: 0 photos, updated EXIF data: 8 photos" + "Processed: 7 photos, exported: 0, updated: 8, skipped: 0, updated EXIF data: 8, missing: 1, error: 0" in result.output ) @@ -3077,7 +3077,7 @@ def test_export_update_exiftool(): ) assert result.exit_code == 0 assert ( - "Exported: 0 photos, updated: 0 photos, skipped: 8 photos, updated EXIF data: 0 photos" + "Processed: 7 photos, exported: 0, updated: 0, skipped: 8, updated EXIF data: 0, missing: 1, error: 0" in result.output ) @@ -3114,7 +3114,7 @@ def test_export_update_hardlink(): ) assert result.exit_code == 0 assert ( - "Exported: 0 photos, updated: 8 photos, skipped: 0 photos, updated EXIF data: 0 photos" + "Processed: 7 photos, exported: 0, updated: 8, skipped: 0, updated EXIF data: 0, missing: 1, error: 0" in result.output ) assert not os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path) @@ -3153,7 +3153,7 @@ def test_export_update_hardlink_exiftool(): ) assert result.exit_code == 0 assert ( - "Exported: 0 photos, updated: 8 photos, skipped: 0 photos, updated EXIF data: 8 photos" + "Processed: 7 photos, exported: 0, updated: 8, skipped: 0, updated EXIF data: 8, missing: 1, error: 0" in result.output ) assert not os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path) @@ -3191,7 +3191,7 @@ def test_export_update_edits(): ) assert result.exit_code == 0 assert ( - "Exported: 1 photo, updated: 1 photo, skipped: 6 photos, updated EXIF data: 0 photos" + "Processed: 7 photos, exported: 1, updated: 1, skipped: 6, updated EXIF data: 0, missing: 1, error: 0" in result.output ) @@ -3227,7 +3227,7 @@ def test_export_update_no_db(): # edited files will be re-exported because there won't be an edited signature # in the database assert ( - "Exported: 0 photos, updated: 2 photos, skipped: 6 photos, updated EXIF data: 0 photos" + "Processed: 7 photos, exported: 0, updated: 2, skipped: 6, updated EXIF data: 0, missing: 1, error: 0" in result.output ) assert os.path.isfile(OSXPHOTOS_EXPORT_DB) @@ -3266,7 +3266,7 @@ def test_export_then_hardlink(): ], ) assert result.exit_code == 0 - assert "Exported: 8 photos" in result.output + assert "Processed: 7 photos, exported: 8, missing: 1, error: 0" in result.output assert os.path.samefile(CLI_EXPORT_UUID_FILENAME, photo.path) @@ -3286,7 +3286,7 @@ def test_export_dry_run(): export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--dry-run"] ) assert result.exit_code == 0 - assert "Exported: 8 photos" in result.output + assert "Processed: 7 photos, exported: 8, missing: 1, error: 0" in result.output for filepath in CLI_EXPORT_FILENAMES: assert f"Exported {filepath}" in result.output assert not os.path.isfile(filepath) @@ -3330,7 +3330,7 @@ def test_export_update_edits_dry_run(): ) assert result.exit_code == 0 assert ( - "Exported: 1 photo, updated: 1 photo, skipped: 6 photos, updated EXIF data: 0 photos" + "Processed: 7 photos, exported: 1, updated: 1, skipped: 6, updated EXIF data: 0, missing: 1, error: 0" in result.output ) @@ -3365,7 +3365,7 @@ def test_export_directory_template_1_dry_run(): ], ) assert result.exit_code == 0 - assert "Exported: 8 photos" in result.output + assert "exported: 8" in result.output workdir = os.getcwd() for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1: assert f"Exported {filepath}" in result.output @@ -3401,7 +3401,8 @@ def test_export_touch_files(): ) assert result.exit_code == 0 - assert "Exported: 18 photos, touched date: 16 photos" in result.output + assert "exported: 18" in result.output + assert "touched date: 16" in result.output for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): st = os.stat(fname) @@ -3433,7 +3434,7 @@ def test_export_touch_files_update(): ) assert result.exit_code == 0 - assert "Exported: 18 photos" in result.output + assert "exported: 18" in result.output assert not pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file() @@ -3443,7 +3444,7 @@ def test_export_touch_files_update(): ) assert result.exit_code == 0 - assert "Exported: 18 photos" in result.output + assert "exported: 18" in result.output assert pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file() @@ -3454,10 +3455,7 @@ def test_export_touch_files_update(): ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos" - in result.output - ) + assert "skipped: 18" in result.output # --update --touch-file --dry-run result = runner.invoke( @@ -3472,10 +3470,8 @@ def test_export_touch_files_update(): ], ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 16 photos" - in result.output - ) + assert "skipped: 18" in result.output + assert "touched date: 16" in result.output for fname, mtime in zip( CLI_EXPORT_BY_DATE_NEED_TOUCH, CLI_EXPORT_BY_DATE_NEED_TOUCH_TIMES @@ -3495,10 +3491,8 @@ def test_export_touch_files_update(): ], ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 16 photos" - in result.output - ) + assert "skipped: 18" in result.output + assert "touched date: 16" in result.output for fname, mtime in zip( CLI_EXPORT_BY_DATE_NEED_TOUCH, CLI_EXPORT_BY_DATE_NEED_TOUCH_TIMES @@ -3521,10 +3515,8 @@ def test_export_touch_files_update(): ], ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 1 photo, skipped: 17 photos, updated EXIF data: 0 photos, touched date: 1 photo" - in result.output - ) + assert "updated: 1, skipped: 17" in result.output + assert "touched date: 1" in result.output for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): st = os.stat(fname) @@ -3537,10 +3529,7 @@ def test_export_touch_files_update(): ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos" - in result.output - ) + assert "skipped: 18" in result.output @pytest.mark.skip("TODO: This fails on some machines but not all") @@ -3570,7 +3559,7 @@ def test_export_touch_files_exiftool_update(): ) assert result.exit_code == 0 - assert "Exported: 18 photos" in result.output + assert "exported: 18" in result.output assert not pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file() @@ -3580,7 +3569,7 @@ def test_export_touch_files_exiftool_update(): ) assert result.exit_code == 0 - assert "Exported: 18 photos" in result.output + assert "exported: 18" in result.output assert pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file() @@ -3591,10 +3580,7 @@ def test_export_touch_files_exiftool_update(): ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos" - in result.output - ) + assert "skipped: 18" in result.output # --update --exiftool --dry-run result = runner.invoke( @@ -3610,10 +3596,8 @@ def test_export_touch_files_exiftool_update(): ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 18 photos, skipped: 0 photos, updated EXIF data: 18 photos" - in result.output - ) + assert "updated: 18" in result.output + assert "updated EXIF data: 18" in result.output # --update --exiftool result = runner.invoke( @@ -3627,11 +3611,8 @@ def test_export_touch_files_exiftool_update(): ], ) assert result.exit_code == 0 - - assert ( - "Exported: 0 photos, updated: 18 photos, skipped: 0 photos, updated EXIF data: 18 photos" - in result.output - ) + assert "updated: 18" in result.output + assert "updated EXIF data: 18" in result.output # --update --touch-file --exiftool --dry-run result = runner.invoke( @@ -3647,10 +3628,8 @@ def test_export_touch_files_exiftool_update(): ], ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 18 photos" - in result.output - ) + assert "skipped: 18" in result.output + assert "touched date: 18" in result.output # --update --touch-file --exiftool result = runner.invoke( @@ -3665,10 +3644,8 @@ def test_export_touch_files_exiftool_update(): ], ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 18 photos" - in result.output - ) + assert "skipped: 18" in result.output + assert "touched date: 18" in result.output for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): st = os.stat(fname) @@ -3690,10 +3667,10 @@ def test_export_touch_files_exiftool_update(): ], ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 1 photo, skipped: 17 photos, updated EXIF data: 1 photo, touched date: 1 photo" - in result.output - ) + assert "updated: 1" in result.output + assert "skipped: 17" in result.output + assert "updated EXIF data: 1" in result.output + assert "touched date: 1" in result.output for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES): st = os.stat(fname) @@ -3712,10 +3689,8 @@ def test_export_touch_files_exiftool_update(): ], ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos, touched date: 0 photos" - in result.output - ) + assert "exported: 0" in result.output + assert "skipped: 18" in result.output # run update without --touch-file result = runner.invoke( @@ -3730,10 +3705,8 @@ def test_export_touch_files_exiftool_update(): ) assert result.exit_code == 0 - assert ( - "Exported: 0 photos, updated: 0 photos, skipped: 18 photos, updated EXIF data: 0 photos" - in result.output - ) + assert "exported: 0" in result.output + assert "skipped: 18" in result.output def test_labels():