diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 7258a7b7..2b03ed64 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -19,6 +19,8 @@ from ._constants import ( _EXIF_TOOL_URL, _PHOTOS_4_VERSION, _UNKNOWN_PLACE, + CLI_COLOR_ERROR, + CLI_COLOR_WARNING, DEFAULT_JPEG_QUALITY, DEFAULT_EDITED_SUFFIX, DEFAULT_ORIGINAL_SUFFIX, @@ -50,7 +52,15 @@ OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db" def verbose_(*args, **kwargs): """ print output if verbose flag set """ if VERBOSE: - click.echo(*args, **kwargs) + styled_args = [] + for arg in args: + if type(arg) == str: + if "error" in arg.lower(): + arg = click.style(arg, fg=CLI_COLOR_ERROR) + elif "warning" in arg.lower(): + arg = click.style(arg, fg=CLI_COLOR_WARNING) + styled_args.append(arg) + click.echo(*styled_args, **kwargs) def normalize_unicode(value): @@ -1605,7 +1615,11 @@ def export( cfg.load_from_file(load_config) except ConfigOptionsLoadError as e: click.echo( - f"Error parsing {load_config} config file: {e.message}", err=True + click.style( + f"Error parsing {load_config} config file: {e.message}", + fg=CLI_COLOR_ERROR, + ), + err=True, ) raise click.Abort() @@ -1737,7 +1751,12 @@ def export( try: cfg.validate(exclusive=exclusive_options, dependent=dependent_options, cli=True) except ConfigOptionsInvalidError as e: - click.echo(f"Incompatible export options: {e.message}", err=True) + click.echo( + click.style( + f"Incompatible export options: {e.message}", fg=CLI_COLOR_ERROR + ), + err=True, + ) raise click.Abort() if save_config: @@ -1752,13 +1771,20 @@ def export( ) if not os.path.isdir(dest): - click.echo(f"DEST {dest} must be valid path", err=True) + click.echo( + click.style(f"DEST {dest} must be valid path", fg=CLI_COLOR_ERROR), err=True + ) raise click.Abort() dest = str(pathlib.Path(dest).resolve()) if report and os.path.isdir(report): - click.echo(f"report is a directory, must be file name", err=True) + click.echo( + click.style( + f"report is a directory, must be file name", fg=CLI_COLOR_ERROR + ), + err=True, + ) raise click.Abort() # if use_photokit and not check_photokit_authorization(): @@ -1785,8 +1811,11 @@ def export( _ = get_exiftool_path() except FileNotFoundError: click.echo( - "Could not find exiftool. Please download and install" - " from https://exiftool.org/", + click.style( + "Could not find exiftool. Please download and install" + " from https://exiftool.org/", + fg=CLI_COLOR_ERROR, + ), err=True, ) ctx.exit(2) @@ -1819,10 +1848,13 @@ def export( other_db_files = find_files_in_branch(dest, OSXPHOTOS_EXPORT_DB) if other_db_files: click.echo( - "WARNING: found other export database files in this destination directory branch. " - + "This likely means you are attempting to export files into a directory " - + "that is either the parent or a child directory of a previous export. " - + "Proceeding may cause your exported files to be overwritten.", + click.style( + "WARNING: found other export database files in this destination directory branch. " + + "This likely means you are attempting to export files into a directory " + + "that is either the parent or a child directory of a previous export. " + + "Proceeding may cause your exported files to be overwritten.", + fg=CLI_COLOR_WARNING, + ), err=True, ) click.echo( @@ -1929,22 +1961,10 @@ def export( # because the original code used --original-name as an option original_name = not current_name - 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 = [] - results_missing = [] - results_error = [] + results = ExportResults() if verbose: for p in photos: - results = export_photo( + export_results = export_photo( photo=p, dest=dest, verbose=verbose, @@ -1979,19 +1999,7 @@ def export( ignore_date_modified=ignore_date_modified, use_photokit=use_photokit, ) - results_exported.extend(results.exported) - results_new.extend(results.new) - results_updated.extend(results.updated) - results_skipped.extend(results.skipped) - results_exif_updated.extend(results.exif_updated) - results_touched.extend(results.touched) - 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) - results_missing.extend(results.missing) - results_error.extend(results.error) + results += export_results # if convert_to_jpeg and p.isphoto and p.uti != "public.jpeg": # for photo_file in set( @@ -2003,7 +2011,7 @@ def export( # show progress bar with click.progressbar(photos) as bar: for p in bar: - results = export_photo( + export_results = export_photo( photo=p, dest=dest, verbose=verbose, @@ -2038,19 +2046,7 @@ def export( ignore_date_modified=ignore_date_modified, use_photokit=use_photokit, ) - results_exported.extend(results.exported) - results_new.extend(results.new) - results_updated.extend(results.updated) - results_skipped.extend(results.skipped) - results_exif_updated.extend(results.exif_updated) - results_touched.extend(results.touched) - 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) - results_missing.extend(results.missing) - results_error.extend(results.error) + results += export_results # print summary results # print(f"results_exported: {results_exported}") @@ -2069,19 +2065,19 @@ def export( if cleanup: all_files = ( - results_exported - + 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 + results.exported + + results.skipped + + results.exif_updated + + results.touched + + results.converted_to_jpeg + + results.sidecar_json_written + + results.sidecar_json_skipped + + results.sidecar_xmp_written + + results.sidecar_xmp_skipped # include missing so a file that was already in export directory # but was missing on --update doesn't get deleted # (better to have old version than none) - + results_missing + + results.missing + [str(pathlib.Path(export_db_path).resolve())] ) click.echo(f"Cleaning up {dest}") @@ -2092,41 +2088,26 @@ def export( 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, - results_missing=results_missing, - results_error=results_error, - ) + write_export_report(report, results) photo_str_total = "photos" if len(photos) != 1 else "photo" if update: summary = ( 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)}, " + f"exported: {len(results.new)}, " + f"updated: {len(results.updated)}, " + f"skipped: {len(results.skipped)}, " + f"updated EXIF data: {len(results.exif_updated)}, " ) else: summary = ( f"Processed: {len(photos)} {photo_str_total}, " - f"exported: {len(results_exported)}, " + f"exported: {len(results.exported)}, " ) - summary += f"missing: {len(results_missing)}, " - summary += f"error: {len(results_error)}" + summary += f"missing: {len(results.missing)}, " + summary += f"error: {len(results.error)}" if touch_file: - summary += f", touched date: {len(results_touched)}" + summary += f", touched date: {len(results.touched)}" click.echo(summary) stop_time = time.perf_counter() click.echo(f"Elapsed time: {(stop_time-start_time):.3f} seconds") @@ -2651,19 +2632,7 @@ def export_photo( global VERBOSE VERBOSE = bool(verbose) - 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 = [] - results_error = [] - results_missing = [] + results = ExportResults() export_original = not (skip_original_if_edited and photo.hasadjustments) @@ -2767,7 +2736,7 @@ def export_photo( if missing_original: space = " " if not verbose else "" verbose_(f"{space}Skipping missing photo {photo.original_filename}") - results_missing.append( + results.missing.append( str(pathlib.Path(dest_path) / original_filename) ) else: @@ -2799,33 +2768,21 @@ def export_photo( use_photokit=use_photokit, verbose=verbose_, ) + results += export_results + for warning_ in export_results.exiftool_warning: + verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}") + for error_ in export_results.exiftool_error: + click.echo(click.style(f"exiftool error for file {error_[0]}: {error_[1]}", fg=CLI_COLOR_ERROR),err=True) - results_exported.extend(export_results.exported) - results_new.extend(export_results.new) - results_updated.extend(export_results.updated) - results_skipped.extend(export_results.skipped) - results_exif_updated.extend(export_results.exif_updated) - results_touched.extend(export_results.touched) - 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 - ) - - except Exception: + except Exception as e: click.echo( - f"Error exporting photo {photo.original_filename} ({photo.filename}) as {original_filename}", + click.style( + f"Error exporting photo {photo.original_filename} ({photo.filename}) as {original_filename}: {e}", + fg=CLI_COLOR_ERROR, + ), err=True, ) - results_error.extend( + results.error.append( str(pathlib.Path(dest) / original_filename) ) else: @@ -2875,7 +2832,7 @@ def export_photo( if missing_edited: space = " " if not verbose else "" verbose_(f"{space}Skipping missing edited photo for {filename}") - results_missing.append( + results.missing.append( str(pathlib.Path(dest_path) / edited_filename) ) else: @@ -2906,65 +2863,36 @@ def export_photo( use_photokit=use_photokit, verbose=verbose_, ) - - results_exported.extend(export_results_edited.exported) - results_new.extend(export_results_edited.new) - results_updated.extend(export_results_edited.updated) - results_skipped.extend(export_results_edited.skipped) - results_exif_updated.extend(export_results_edited.exif_updated) - results_touched.extend(export_results_edited.touched) - 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 - ) - - except Exception: + results += export_results_edited + for warning_ in export_results_edited.exiftool_warning: + verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}") + for error_ in export_results_edited.exiftool_error: + click.echo(click.style(f"exiftool error for file {error_[0]}: {error_[1]}", fg=CLI_COLOR_ERROR),err=True) + except Exception as e: click.echo( - f"Error exporting photo {filename} as {edited_filename}", + click.style( + f"Error exporting photo {filename} as {edited_filename}", + fg=CLI_COLOR_ERROR, + ), err=True, ) - results_error.extend(str(pathlib.Path(dest) / edited_filename)) + results.error.append(str(pathlib.Path(dest) / edited_filename)) if verbose: if update: - for new in results_new: + for new in results.new: verbose_(f"Exported new file {new}") - for updated in results_updated: + for updated in results.updated: verbose_(f"Exported updated file {updated}") - for skipped in results_skipped: + for skipped in results.skipped: verbose_(f"Skipped up to date file {skipped}") else: - for exported in results_exported: + for exported in results.exported: verbose_(f"Exported {exported}") - for touched in results_touched: + for touched in results.touched: verbose_(f"Touched date on file {touched}") - return ExportResults( - 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=results_missing, - error=results_error, - ) + return results def get_filenames_from_template(photo, filename_template, original_name): @@ -3115,24 +3043,14 @@ 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, - results_missing, - results_error, -): +def write_export_report(report_file, results): - """ write CSV report with results from export """ + """ write CSV report with results from export + + Args: + report_file: path to report file + results: ExportResults object + """ # Collect results for reporting # TODO: pull this in a separate write_report function @@ -3150,65 +3068,61 @@ def write_export_report( "sidecar_json": 0, "missing": 0, "error": 0, + "exiftool_warning": "", + "exiftool_error": "", } - 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 - + results_missing - + results_error + for result in results.all_files() } - for result in results_exported: + for result in results.exported: all_results[result]["exported"] = 1 - for result in results_new: + for result in results.new: all_results[result]["new"] = 1 - for result in results_updated: + for result in results.updated: all_results[result]["updated"] = 1 - for result in results_skipped: + for result in results.skipped: all_results[result]["skipped"] = 1 - for result in results_exif_updated: + for result in results.exif_updated: all_results[result]["exif_updated"] = 1 - for result in results_touched: + for result in results.touched: all_results[result]["touched"] = 1 - for result in results_converted: + for result in results.converted_to_jpeg: all_results[result]["converted_to_jpeg"] = 1 - for result in results_sidecar_xmp_written: + 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: + 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: + 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: + for result in results.sidecar_json_skipped: all_results[result]["sidecar_json"] = 1 all_results[result]["skipped"] = 1 - for result in results_missing: + for result in results.missing: all_results[result]["missing"] = 1 - for result in results_error: + for result in results.error: all_results[result]["error"] = 1 + for result in results.exiftool_warning: + all_results[result[0]]["exiftool_warning"] = result[1] + + for result in results.exiftool_error: + all_results[result[0]]["exiftool_error"] = result[1] + report_columns = [ "filename", "exported", @@ -3222,6 +3136,8 @@ def write_export_report( "sidecar_json", "missing", "error", + "exiftool_warning", + "exiftool_error", ] try: @@ -3231,7 +3147,10 @@ def write_export_report( 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) + click.echo( + click.style("Could not open output file for writing", fg=CLI_COLOR_ERROR), + err=True, + ) raise click.Abort() diff --git a/osxphotos/_constants.py b/osxphotos/_constants.py index 1efa667b..e72876b9 100644 --- a/osxphotos/_constants.py +++ b/osxphotos/_constants.py @@ -118,4 +118,6 @@ DEFAULT_EDITED_SUFFIX = "_edited" # Default suffix to add to original images DEFAULT_ORIGINAL_SUFFIX = "" - +# Colors for print CLI messages +CLI_COLOR_ERROR = 'red' +CLI_COLOR_WARNING = 'yellow' diff --git a/osxphotos/exiftool.py b/osxphotos/exiftool.py index 2c36343e..bcb764cd 100644 --- a/osxphotos/exiftool.py +++ b/osxphotos/exiftool.py @@ -145,6 +145,7 @@ class ExifTool: self.file = filepath self.overwrite = overwrite self.data = {} + self.warning = None self.error = None # if running as a context manager, self._context_mgr will be True self._context_mgr = False @@ -163,6 +164,7 @@ class ExifTool: True if success otherwise False If error generated by exiftool, returns False and sets self.error to error string + If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string If called in context manager, returns True (execution is delayed until exiting context manager) """ @@ -175,8 +177,8 @@ class ExifTool: self._commands.extend(command) return True else: - _, self.error = self.run_commands(*command) - return self.error is None + _, _, error = self.run_commands(*command) + return error is None def addvalues(self, tag, *values): """ Add one or more value(s) to tag @@ -190,6 +192,7 @@ class ExifTool: True if success otherwise False If error generated by exiftool, returns False and sets self.error to error string + If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string If called in context manager, returns True (execution is delayed until exiting context manager) Notes: exiftool may add duplicate values for some tags so the caller must ensure @@ -216,8 +219,8 @@ class ExifTool: self._commands.extend(command) return True else: - _, self.error = self.run_commands(*command) - return self.error is None + _, _, error = self.run_commands(*command) + return error is None def run_commands(self, *commands, no_file=False): """ Run commands in the exiftool process and return result. @@ -228,11 +231,12 @@ class ExifTool: by default, all commands will be run against self.file use no_file=True to run a command without passing the filename Returns: - (output, errror) + (output, warning, errror) output: bytes is containing output of exiftool commands - error: if exiftool generated an error, bytes containing error string otherwise None + warning: if exiftool generated warnings, string containing warning otherwise empty string + error: if exiftool generated errors, string containing otherwise empty string - Note: Also sets self.error if error generated. + Note: Also sets self.warning and self.error if warning or error generated. """ if not (hasattr(self, "_process") and self._process): raise ValueError("exiftool process is not running") @@ -259,16 +263,21 @@ class ExifTool: # read the output output = b"" + warning = b"" error = b"" while EXIFTOOL_STAYOPEN_EOF not in str(output): line = self._process.stdout.readline() if line.startswith(b"Warning"): - error += line + warning += line.strip() + elif line.startswith(b"Error"): + error += line.strip() else: output += line.strip() - error = None if error == b"" else error + warning = "" if warning == b"" else warning.decode("utf-8") + error = "" if error == b"" else error.decode("utf-8") + self.warning = warning self.error = error - return output[:-EXIFTOOL_STAYOPEN_EOF_LEN], error + return output[:-EXIFTOOL_STAYOPEN_EOF_LEN], warning, error @property def pid(self): @@ -278,14 +287,14 @@ class ExifTool: @property def version(self): """ returns exiftool version """ - ver, _ = self.run_commands("-ver", no_file=True) + ver, _, _ = self.run_commands("-ver", no_file=True) return ver.decode("utf-8") def asdict(self): """ return dictionary of all EXIF tags and values from exiftool returns empty dict if no tags """ - json_str, _ = self.run_commands("-json") + json_str, _, _ = self.run_commands("-json") if json_str: exifdict = json.loads(json_str) return exifdict[0] @@ -294,7 +303,7 @@ class ExifTool: def json(self): """ returns JSON string containing all EXIF tags and values from exiftool """ - json, _ = self.run_commands("-json") + json, _, _ = self.run_commands("-json") return json def _read_exif(self): @@ -314,4 +323,5 @@ class ExifTool: if exc_type: return False elif self._commands: - _, self.error = self.run_commands(*self._commands) + # run_commands sets self.warning and self.error as needed + self.run_commands(*self._commands) diff --git a/osxphotos/photoinfo/_photoinfo_export.py b/osxphotos/photoinfo/_photoinfo_export.py index da58fbd3..ae4eba45 100644 --- a/osxphotos/photoinfo/_photoinfo_export.py +++ b/osxphotos/photoinfo/_photoinfo_export.py @@ -45,24 +45,105 @@ from ..photokit import ( ) from ..utils import dd_to_dms_str, findfiles, noop -ExportResults = namedtuple( - "ExportResults", - [ - "exported", - "new", - "updated", - "skipped", - "exif_updated", - "touched", - "converted_to_jpeg", - "sidecar_json_written", - "sidecar_json_skipped", - "sidecar_xmp_written", - "sidecar_xmp_skipped", - "missing", - "error", - ], -) + +class ExportResults: + """ holds export results for export2 """ + + def __init__( + self, + exported=None, + new=None, + updated=None, + skipped=None, + exif_updated=None, + touched=None, + converted_to_jpeg=None, + sidecar_json_written=None, + sidecar_json_skipped=None, + sidecar_xmp_written=None, + sidecar_xmp_skipped=None, + missing=None, + error=None, + exiftool_warning=None, + exiftool_error=None, + ): + self.exported = exported or [] + self.new = new or [] + self.updated = updated or [] + self.skipped = skipped or [] + self.exif_updated = exif_updated or [] + self.touched = touched or [] + self.converted_to_jpeg = converted_to_jpeg or [] + self.sidecar_json_written = sidecar_json_written or [] + self.sidecar_json_skipped = sidecar_json_skipped or [] + self.sidecar_xmp_written = sidecar_xmp_written or [] + self.sidecar_xmp_skipped = sidecar_xmp_skipped or [] + self.missing = missing or [] + self.error = error or [] + self.exiftool_warning = exiftool_warning or [] + self.exiftool_error = exiftool_error or [] + + def all_files(self): + """ return all filenames contained in results """ + files = ( + self.exported + + self.new + + self.updated + + self.skipped + + self.exif_updated + + self.touched + + self.converted_to_jpeg + + self.sidecar_json_written + + self.sidecar_json_skipped + + self.sidecar_xmp_written + + self.sidecar_xmp_skipped + + self.missing + + self.error + ) + files += [x[0] for x in self.exiftool_warning] + files += [x[0] for x in self.exiftool_error] + + files = list(set(files)) + return files + + def __iadd__(self, other): + self.exported += other.exported + self.new += other.new + self.updated += other.updated + self.skipped += other.skipped + self.exif_updated += other.exif_updated + self.touched += other.touched + self.converted_to_jpeg += other.converted_to_jpeg + self.sidecar_json_written += other.sidecar_json_written + self.sidecar_json_skipped += other.sidecar_json_skipped + self.sidecar_xmp_written += other.sidecar_xmp_written + self.sidecar_xmp_skipped += other.sidecar_xmp_skipped + self.missing += other.missing + self.error += other.error + self.exiftool_warning += other.exiftool_warning + self.exiftool_error += other.exiftool_error + return self + + def __str__(self): + return ( + "ExportResults(" + + f"exported={self.exported}" + + f",new={self.new}" + + f",updated={self.updated}" + + f",skipped={self.skipped}" + + f",exif_updated={self.exif_updated}" + + f",touched={self.touched}" + + f",converted_to_jpeg={self.converted_to_jpeg}" + + f",sidecar_json_written={self.sidecar_json_written}" + + f",sidecar_json_skipped={self.sidecar_json_skipped}" + + f",sidecar_xmp_written={self.sidecar_xmp_written}" + + f",sidecar_xmp_skipped={self.sidecar_xmp_skipped}" + + f",missing={self.missing}" + + f",error={self.error}" + + f",exiftool_warning={self.exiftool_warning}" + + f",exiftool_error={self.exiftool_error}" + + ")" + ) # hexdigest is not a class method, don't import this into PhotoInfo @@ -389,7 +470,8 @@ 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: + Returns: ExportResults class + ExportResults has attributes: "exported", "new", "updated", @@ -402,7 +484,10 @@ def export2( "sidecar_xmp_written", "sidecar_xmp_skipped", "missing", - "error" + "error", + "exiftool_warning", + "exiftool_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) @@ -853,6 +938,10 @@ def export2( exif_files = exported_files exif_files_updated = [] + exiftool_warning = [] + exiftool_error = [] + errors = [] + # TODO: remove duplicative code from below if exiftool and update and exif_files: for exported_file in exif_files: files_are_different = False @@ -876,7 +965,7 @@ def export2( # or files were different verbose(f"Writing metadata with exiftool for {exported_file}") if not dry_run: - self._write_exif_data( + warning_, error_ = self._write_exif_data( exported_file, use_albums_as_keywords=use_albums_as_keywords, use_persons_as_keywords=use_persons_as_keywords, @@ -884,6 +973,12 @@ def export2( description_template=description_template, ignore_date_modified=ignore_date_modified, ) + if warning_: + exiftool_warning.append((exported_file, warning_)) + if error_: + exiftool_error.append((exported_file, error_)) + errors.append(exported_file) + export_db.set_exifdata_for_file( exported_file, self._exiftool_json_sidecar( @@ -904,7 +999,7 @@ def export2( for exported_file in exif_files: verbose(f"Writing metadata with exiftool for {exported_file}") if not dry_run: - self._write_exif_data( + warning_, error_ = self._write_exif_data( exported_file, use_albums_as_keywords=use_albums_as_keywords, use_persons_as_keywords=use_persons_as_keywords, @@ -912,6 +1007,11 @@ def export2( description_template=description_template, ignore_date_modified=ignore_date_modified, ) + if warning_: + exiftool_warning.append((exported_file, warning_)) + if error_: + exiftool_error.append((exported_file, error_)) + errors.append(exported_file) export_db.set_exifdata_for_file( exported_file, @@ -949,8 +1049,9 @@ def export2( sidecar_json_skipped=sidecar_json_files_skipped, sidecar_xmp_written=sidecar_xmp_files_written, sidecar_xmp_skipped=sidecar_xmp_files_skipped, - missing=[], - error=[], + error=errors, + exiftool_error=exiftool_error, + exiftool_warning=exiftool_warning, ) return results @@ -1152,6 +1253,9 @@ def _write_exif_data( use_persons_as_keywords: treat person names as keywords keyword_template: (list of strings); list of template strings to render as keywords ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set + + Returns: + (warning, error) of warning and error strings if exiftool produces warnings or errors """ if not os.path.exists(filepath): raise FileNotFoundError(f"Could not find file {filepath}") @@ -1170,6 +1274,7 @@ def _write_exif_data( exiftool.setvalue(exiftag, v) else: exiftool.setvalue(exiftag, val) + return exiftool.warning, exiftool.error def _exiftool_dict( diff --git a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-shm b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-shm index a51adeee..c640b0d9 100644 Binary files a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-shm and b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-shm differ diff --git a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-wal b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-wal index e8e8707e..fcddc203 100644 Binary files a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-wal and b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-wal differ diff --git a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite.lock b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite.lock index 0bdc3788..2c98250f 100644 --- a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite.lock +++ b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite.lock @@ -7,7 +7,7 @@ hostuuid 9575E48B-8D5F-5654-ABAC-4431B1167324 pid - 485 + 19275 processname photolibraryd uid diff --git a/tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite b/tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite index c1637828..a8d06033 100644 Binary files a/tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite and b/tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite differ diff --git a/tests/Test-10.15.7.photoslibrary/database/search/synonymsProcess.plist b/tests/Test-10.15.7.photoslibrary/database/search/synonymsProcess.plist index fa046867..9ab2085c 100644 Binary files a/tests/Test-10.15.7.photoslibrary/database/search/synonymsProcess.plist and b/tests/Test-10.15.7.photoslibrary/database/search/synonymsProcess.plist differ diff --git a/tests/Test-10.15.7.photoslibrary/database/search/zeroKeywords.data b/tests/Test-10.15.7.photoslibrary/database/search/zeroKeywords.data index f22dd681..5d03620c 100644 Binary files a/tests/Test-10.15.7.photoslibrary/database/search/zeroKeywords.data and b/tests/Test-10.15.7.photoslibrary/database/search/zeroKeywords.data differ diff --git a/tests/Test-10.15.7.photoslibrary/originals/E/E2078879-A29C-4D6F-BACB-E3BBE6C3EB91.jpeg b/tests/Test-10.15.7.photoslibrary/originals/E/E2078879-A29C-4D6F-BACB-E3BBE6C3EB91.jpeg new file mode 100644 index 00000000..5846efe8 Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/originals/E/E2078879-A29C-4D6F-BACB-E3BBE6C3EB91.jpeg differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm index 67ccc9bf..5b258157 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal index d9e5a097..fc1a45c0 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm index b851287e..60b593d1 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal index bc43af9d..c57856bb 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb index 34f3fc41..998acab3 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist index 1a02b725..0423769d 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/E/E2078879-A29C-4D6F-BACB-E3BBE6C3EB91_1_105_c.jpeg b/tests/Test-10.15.7.photoslibrary/resources/derivatives/E/E2078879-A29C-4D6F-BACB-E3BBE6C3EB91_1_105_c.jpeg new file mode 100644 index 00000000..7378564d Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/E/E2078879-A29C-4D6F-BACB-E3BBE6C3EB91_1_105_c.jpeg differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/masters/E/E2078879-A29C-4D6F-BACB-E3BBE6C3EB91_4_5005_c.jpeg b/tests/Test-10.15.7.photoslibrary/resources/derivatives/masters/E/E2078879-A29C-4D6F-BACB-E3BBE6C3EB91_4_5005_c.jpeg new file mode 100644 index 00000000..454559a1 Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/masters/E/E2078879-A29C-4D6F-BACB-E3BBE6C3EB91_4_5005_c.jpeg differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/3305.ithmb b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/3305.ithmb index 9cb4e8aa..a31a03c1 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/3305.ithmb and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/3305.ithmb differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4031.ithmb b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4031.ithmb index 9facb58f..6b6cd711 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4031.ithmb and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4031.ithmb differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4132.ithmb b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4132.ithmb index 9063f25e..bb6dd6a4 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4132.ithmb and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4132.ithmb differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/journals/Asset-change.plj b/tests/Test-10.15.7.photoslibrary/resources/journals/Asset-change.plj index a199b705..620d8b22 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/journals/Asset-change.plj and b/tests/Test-10.15.7.photoslibrary/resources/journals/Asset-change.plj differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/journals/HistoryToken.plist b/tests/Test-10.15.7.photoslibrary/resources/journals/HistoryToken.plist index 683ebaa7..d28f2997 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/journals/HistoryToken.plist and b/tests/Test-10.15.7.photoslibrary/resources/journals/HistoryToken.plist differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/journals/ImportSession-change.plj b/tests/Test-10.15.7.photoslibrary/resources/journals/ImportSession-change.plj index fc0ca6b4..9771db5e 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/journals/ImportSession-change.plj and b/tests/Test-10.15.7.photoslibrary/resources/journals/ImportSession-change.plj differ diff --git a/tests/test-images/badimage.jpeg b/tests/test-images/badimage.jpeg new file mode 100644 index 00000000..62899a88 Binary files /dev/null and b/tests/test-images/badimage.jpeg differ diff --git a/tests/test-images/screenshot-really-a-png.jpeg b/tests/test-images/screenshot-really-a-png.jpeg new file mode 100644 index 00000000..5846efe8 Binary files /dev/null and b/tests/test-images/screenshot-really-a-png.jpeg differ diff --git a/tests/test_catalina_10_15_7.py b/tests/test_catalina_10_15_7.py index f9b57017..7f1b7364 100644 --- a/tests/test_catalina_10_15_7.py +++ b/tests/test_catalina_10_15_7.py @@ -18,10 +18,10 @@ PHOTOS_DB = "tests/Test-10.15.7.photoslibrary/database/photos.db" PHOTOS_DB_PATH = "/Test-10.15.7.photoslibrary/database/photos.db" PHOTOS_LIBRARY_PATH = "/Test-10.15.7.photoslibrary" -PHOTOS_DB_LEN = 18 -PHOTOS_NOT_IN_TRASH_LEN = 16 +PHOTOS_DB_LEN = 19 +PHOTOS_NOT_IN_TRASH_LEN = 17 PHOTOS_IN_TRASH_LEN = 2 -PHOTOS_DB_IMPORT_SESSIONS = 13 +PHOTOS_DB_IMPORT_SESSIONS = 14 KEYWORDS = [ "Kids", @@ -971,7 +971,7 @@ def test_from_to_date(photosdb): time.tzset() photos = photosdb.photos(from_date=datetime.datetime(2018, 10, 28)) - assert len(photos) == 9 + assert len(photos) == 10 photos = photosdb.photos(to_date=datetime.datetime(2018, 10, 28)) assert len(photos) == 7 diff --git a/tests/test_cli.py b/tests/test_cli.py index adde10ee..7c07ed75 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -405,6 +405,8 @@ CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = { } } +CLI_EXIFTOOL_ERROR = ["E2078879-A29C-4D6F-BACB-E3BBE6C3EB91"] + LABELS_JSON = { "labels": { "Plant": 7, @@ -1091,6 +1093,34 @@ def test_export_exiftool_quicktime(): for filename in files: os.unlink(filename) +@pytest.mark.skipif(exiftool is None, reason="exiftool not installed") +def test_export_exiftool_error(): + """" test --exiftool catching error """ + import glob + import os + import os.path + from osxphotos.__main__ import export + from osxphotos.exiftool import ExifTool + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + for uuid in CLI_EXIFTOOL_ERROR: + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_7), + ".", + "-V", + "--exiftool", + "--uuid", + f"{uuid}", + ], + ) + assert result.exit_code == 0 + assert "exiftool error" in result.output + def test_export_edited_suffix(): """ test export with --edited-suffix """ diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index 09ecf02c..59c2a838 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -2,6 +2,7 @@ import pytest from osxphotos.exiftool import get_exiftool_path TEST_FILE_ONE_KEYWORD = "tests/test-images/wedding.jpg" +TEST_FILE_BAD_IMAGE = "tests/test-images/badimage.jpeg" TEST_FILE_MULTI_KEYWORD = "tests/test-images/Tulips.jpg" TEST_MULTI_KEYWORDS = [ "Top Shot", @@ -109,8 +110,8 @@ def test_setvalue_1(): assert exif.data["IPTC:Keywords"] == "test" -def test_setvalue_error(): - # test setting illegal tag value generates error +def test_setvalue_warning(): + # test setting illegal tag value generates warning import os.path import tempfile import osxphotos.exiftool @@ -122,6 +123,22 @@ def test_setvalue_error(): exif = osxphotos.exiftool.ExifTool(tempfile) exif.setvalue("IPTC:Foo", "test") + assert exif.warning + + +def test_setvalue_error(): + # test setting tag on bad image generates error + import os.path + import tempfile + import osxphotos.exiftool + from osxphotos.fileutil import FileUtil + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_BAD_IMAGE)) + FileUtil.copy(TEST_FILE_BAD_IMAGE, tempfile) + + exif = osxphotos.exiftool.ExifTool(tempfile) + exif.setvalue("IPTC:Keywords", "test") assert exif.error @@ -142,7 +159,7 @@ def test_setvalue_context_manager(): exif.setvalue("XMP:Title", "title") exif.setvalue("XMP:Subject", "subject") - assert exif.error is None + assert not exif.error exif2 = osxphotos.exiftool.ExifTool(tempfile) exif2._read_exif() @@ -151,8 +168,8 @@ def test_setvalue_context_manager(): assert exif2.data["XMP:Subject"] == "subject" -def test_setvalue_context_manager_error(): - # test setting a tag value as context manager when error generated +def test_setvalue_context_manager_warning(): + # test setting a tag value as context manager when warning generated import os.path import tempfile import osxphotos.exiftool @@ -164,6 +181,22 @@ def test_setvalue_context_manager_error(): with osxphotos.exiftool.ExifTool(tempfile) as exif: exif.setvalue("Foo:Bar", "test1") + assert exif.warning + + +def test_setvalue_context_manager_error(): + # test setting a tag value as context manager when error generated + import os.path + import tempfile + import osxphotos.exiftool + from osxphotos.fileutil import FileUtil + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_BAD_IMAGE)) + FileUtil.copy(TEST_FILE_BAD_IMAGE, tempfile) + + with osxphotos.exiftool.ExifTool(tempfile) as exif: + exif.setvalue("IPTC:Keywords", "test1") assert exif.error diff --git a/tests/test_export_catalina_10_15_7.py b/tests/test_export_catalina_10_15_7.py index 267121bc..3bdd7426 100644 --- a/tests/test_export_catalina_10_15_7.py +++ b/tests/test_export_catalina_10_15_7.py @@ -716,7 +716,7 @@ def test_xmp_sidecar_is_valid(tmp_path): xmp_file = tmp_path / XMP_FILENAME assert xmp_file.is_file() exiftool = ExifTool(str(xmp_file)) - output, _ = exiftool.run_commands("-validate", "-warning") + output, _, _ = exiftool.run_commands("-validate", "-warning") assert output == b"[ExifTool] Validate : 0 0 0" diff --git a/tests/test_exportresults.py b/tests/test_exportresults.py new file mode 100644 index 00000000..fa3e2f84 --- /dev/null +++ b/tests/test_exportresults.py @@ -0,0 +1,95 @@ +""" test ExportResults class """ + +import pytest +from osxphotos.photoinfo import ExportResults + +EXPORT_RESULT_ATTRIBUTES = [ + "exported", + "new", + "updated", + "skipped", + "exif_updated", + "touched", + "converted_to_jpeg", + "sidecar_json_written", + "sidecar_json_skipped", + "sidecar_xmp_written", + "sidecar_xmp_skipped", + "missing", + "error", + "exiftool_warning", + "exiftool_error", +] + + +def test_exportresults_init(): + results = ExportResults() + assert results.exported == [] + assert results.new == [] + assert results.updated == [] + assert results.skipped == [] + assert results.exif_updated == [] + assert results.touched == [] + assert results.converted_to_jpeg == [] + assert results.sidecar_json_written == [] + assert results.sidecar_json_skipped == [] + assert results.sidecar_xmp_written == [] + assert results.sidecar_xmp_skipped == [] + assert results.missing == [] + assert results.error == [] + assert results.exiftool_warning == [] + assert results.exiftool_error == [] + + +def test_exportresults_iadd(): + results1 = ExportResults() + results2 = ExportResults() + for x in EXPORT_RESULT_ATTRIBUTES: + setattr(results1, x, [f"{x}1"]) + setattr(results2, x, [f"{x}2"]) + + results1 += results2 + for x in EXPORT_RESULT_ATTRIBUTES: + assert getattr(results1, x) == [f"{x}1", f"{x}2"] + + # exiftool_warning and exiftool_error are lists of tuples + results1 = ExportResults() + results2 = ExportResults() + results1.exiftool_warning = [("exiftool_warning1", "foo")] + results2.exiftool_warning = [("exiftool_warning2", "bar")] + results1.exiftool_error = [("exiftool_error1", "foo")] + results2.exiftool_error = [("exiftool_error2", "bar")] + + results1 += results2 + + assert results1.exiftool_warning == [ + ("exiftool_warning1", "foo"), + ("exiftool_warning2", "bar"), + ] + assert results1.exiftool_error == [ + ("exiftool_error1", "foo"), + ("exiftool_error2", "bar"), + ] + + +def test_all_files(): + """ test ExportResults.all_files() """ + results = ExportResults() + for x in EXPORT_RESULT_ATTRIBUTES: + setattr(results, x, [f"{x}1"]) + results.exiftool_warning = [("exiftool_warning1", "foo")] + results.exiftool_error = [("exiftool_error1", "foo")] + + assert sorted(results.all_files()) == sorted( + [f"{x}1" for x in EXPORT_RESULT_ATTRIBUTES] + ) + + +def test_str(): + """ test ExportResults.__str__ """ + results = ExportResults() + assert ( + str(results) + == "ExportResults(exported=[],new=[],updated=[],skipped=[],exif_updated=[],touched=[],converted_to_jpeg=[],sidecar_json_written=[],sidecar_json_skipped=[],sidecar_xmp_written=[],sidecar_xmp_skipped=[],missing=[],error=[],exiftool_warning=[],exiftool_error=[])" + ) +