diff --git a/osxphotos/fileutil.py b/osxphotos/fileutil.py index 187d7ac8..9b6487d3 100644 --- a/osxphotos/fileutil.py +++ b/osxphotos/fileutil.py @@ -34,7 +34,12 @@ class FileUtilABC(ABC): @classmethod @abstractmethod - def cmp_sig(cls, file1, file2): + def cmp(cls, file1, file2, mtime1 = None): + pass + + @classmethod + @abstractmethod + def cmp_file_sig(cls, file1, file2): pass @classmethod @@ -114,11 +119,32 @@ class FileUtilMacOS(FileUtilABC): os.utime(path, times) @classmethod - def cmp_sig(cls, f1, s2): + def cmp(cls, f1, f2, mtime1 = None): + """Does shallow compare (file signatures) of f1 to file f2. + Arguments: + f1 -- File name + f2 -- File name + mtime1 -- optional, pass alternate file modification timestamp for f1; will be converted to int + + Return value: + True if the file signatures as returned by stat are the same, False otherwise. + Does not do a byte-by-byte comparison. + """ + + s1 = cls._sig(os.stat(f1)) + if mtime1 is not None: + s1 = (s1[0], s1[1], int(mtime1)) + s2 = cls._sig(os.stat(f2)) + if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG: + return False + return s1 == s2 + + @classmethod + def cmp_file_sig(cls, f1, s2): """Compare file f1 to signature s2. Arguments: f1 -- File name - s2 -- stats as returned by sig + s2 -- stats as returned by _sig Return value: True if the files are the same, False otherwise. @@ -140,7 +166,12 @@ class FileUtilMacOS(FileUtilABC): @staticmethod def _sig(st): - return (stat.S_IFMT(st.st_mode), st.st_size, st.st_mtime) + """ return tuple of (mode, size, mtime) of file based on os.stat + Args: + st: os.stat signature + """ + # use int(st.st_mtime) because ditto does not copy fractional portion of mtime + return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime)) class FileUtil(FileUtilMacOS): @@ -151,8 +182,8 @@ class FileUtil(FileUtilMacOS): class FileUtilNoOp(FileUtil): """ No-Op implementation of FileUtil for testing / dry-run mode - all methods with exception of cmp_sig and file_cmp are no-op - cmp_sig functions as FileUtil.cmp_sig does + all methods with exception of cmp, cmp_file_sig and file_cmp are no-op + cmp and cmp_file_sig functions as FileUtil methods do file_cmp returns mock data """ diff --git a/osxphotos/photoinfo/_photoinfo_export.py b/osxphotos/photoinfo/_photoinfo_export.py index 2bb7660b..875c462f 100644 --- a/osxphotos/photoinfo/_photoinfo_export.py +++ b/osxphotos/photoinfo/_photoinfo_export.py @@ -11,7 +11,6 @@ # TODO: should this be its own PhotoExporter class? -import filecmp import glob import json import logging @@ -484,7 +483,7 @@ def export2( if update and dest.exists(): # destination exists, check to see if destination is the right UUID dest_uuid = export_db.get_uuid_for_file(dest) - if dest_uuid is None and filecmp.cmp(src, dest): + if dest_uuid is None and fileutil.cmp(src, dest): # might be exporting into a pre-ExportDB folder or the DB got deleted logging.debug( f"Found matching file with blank uuid: {self.uuid}, {dest}" @@ -516,7 +515,7 @@ def export2( dest = pathlib.Path(file_) found_match = True break - elif dest_uuid is None and filecmp.cmp(src, file_): + elif dest_uuid is None and fileutil.cmp(src, file_): # files match, update the UUID logging.debug( f"Found matching file with blank uuid: {self.uuid}, {file_}" @@ -836,21 +835,33 @@ def _export_photo( op_desc = "export_by_copying" if not update: - # not update, do the the hardlink - logging.debug(f"Not update: {op_desc} linking file {src} {dest}") + # not update, export the file + logging.debug(f"Exporting file with {op_desc} {src} {dest}") exported_files.append(dest_str) - else: #updating + else: # updating if not dest_exists: # update, destination doesn't exist (new file) logging.debug(f"Update: exporting new file with {op_desc} {src} {dest}") update_new_files.append(dest_str) else: # update, destination exists, but we might not need to replace it... - if (( export_as_hardlink and dest.samefile(src)) or - (not export_as_hardlink and not dest.samefile(src) and ( - ( exiftool and fileutil.cmp_sig(dest_str, export_db.get_stat_exif_for_file(dest_str))) or - (not exiftool and filecmp.cmp(src, dest))) - )): + if touch_file: + sig_cmp = fileutil.cmp(src, dest, mtime1=self.date.timestamp()) + else: + sig_cmp = fileutil.cmp(src, dest) + if (export_as_hardlink and dest.samefile(src)) or ( + not export_as_hardlink + and not dest.samefile(src) + and ( + ( + exiftool + and fileutil.cmp_file_sig( + dest_str, export_db.get_stat_exif_for_file(dest_str) + ) + ) + or (not exiftool and sig_cmp) + ) + ): # destination exists but its signature is "identical" logging.debug(f"Update: skipping identical original files {src} {dest}") # call set_stat because code can reach this spot if no export DB but exporting a RAW or live photo @@ -859,13 +870,17 @@ def _export_photo( update_skipped_files.append(dest_str) else: # destination exists but is different - logging.debug(f"Update: removing existing file prior to {op_desc} {src} {dest}") + logging.debug( + f"Update: removing existing file prior to {op_desc} {src} {dest}" + ) update_updated_files.append(dest_str) if not update_skipped_files: if dest_exists and (update or overwrite): # need to remove the destination first - logging.debug(f"Update: removing existing file prior to export_as_hardlink {src} {dest}") + logging.debug( + f"Update: removing existing file prior to export_as_hardlink {src} {dest}" + ) # dest.unlink() fileutil.unlink(dest) if export_as_hardlink: @@ -873,7 +888,7 @@ def _export_photo( else: fileutil.copy(src, dest_str, norsrc=no_xattr) if touch_file: - ts=self.date.timestamp() + ts = self.date.timestamp() fileutil.utime(dest, (ts, ts)) export_db.set_data( @@ -886,7 +901,11 @@ def _export_photo( ) return ExportResults( - exported_files + update_new_files + update_updated_files, update_new_files, update_updated_files, update_skipped_files, [] + exported_files + update_new_files + update_updated_files, + update_new_files, + update_updated_files, + update_skipped_files, + [], )