Finished --touch-file, closes #206

This commit is contained in:
Rhet Turnbull
2020-08-23 08:27:21 -07:00
parent 6c11e3fa5b
commit 9f64262757
7 changed files with 300 additions and 55 deletions

View File

@@ -203,14 +203,14 @@ Options:
both images and movies). both images and movies).
--only-photos Search only for photos/images (default --only-photos Search only for photos/images (default
searches both images and movies). searches both images and movies).
--from-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S] --from-date DATETIME Search by start item date, e.g.
Search by start item date, e.g. 2000-01-12T12:00:00,
2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 2001-01-12T12:00:00-07:00, or 2000-12-31
w/o TZ). (ISO 8601).
--to-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S] --to-date DATETIME Search by end item date, e.g.
Search by end item date, e.g. 2000-01-12T12:00:00,
2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 2001-01-12T12:00:00-07:00, or 2000-12-31
w/o TZ). (ISO 8601).
--deleted Include photos from the 'Recently Deleted' --deleted Include photos from the 'Recently Deleted'
folder. folder.
--deleted-only Include only photos from the 'Recently --deleted-only Include only photos from the 'Recently
@@ -222,6 +222,8 @@ Options:
--export-as-hardlink Hardlink files instead of copying them. --export-as-hardlink Hardlink files instead of copying them.
Cannot be used with --exiftool which creates Cannot be used with --exiftool which creates
copies of the files with embedded EXIF data. copies of the files with embedded EXIF data.
--touch-file Sets the file's modification time to match
photo date.
--overwrite Overwrite existing files. Default behavior --overwrite Overwrite existing files. Default behavior
is to add (1), (2), etc to filename if file is to add (1), (2), etc to filename if file
already exists. Use this with caution as it already exists. Use this with caution as it

View File

@@ -1124,7 +1124,7 @@ def query(
@click.option( @click.option(
"--touch-file", "--touch-file",
is_flag=True, is_flag=True,
help="Sets the file's modification time upon photo date.", help="Sets the file's modification time to match photo date.",
) )
@click.option( @click.option(
"--overwrite", "--overwrite",
@@ -1269,6 +1269,13 @@ def query(
"to a filesystem that doesn't support Mac OS extended attributes. Only use this if you get " "to a filesystem that doesn't support Mac OS extended attributes. Only use this if you get "
"an error while exporting.", "an error while exporting.",
) )
@click.option(
"--use-photos-export",
is_flag=True,
default=False,
hidden=True,
help="Force the use of AppleScript to export even if not missing (see also --download-missing).",
)
@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
@@ -1350,6 +1357,7 @@ def export(
label, label,
deleted, deleted,
deleted_only, deleted_only,
use_photos_export,
): ):
""" Export photos from the Photos database. """ Export photos from the Photos database.
Export path DEST is required. Export path DEST is required.
@@ -1520,7 +1528,6 @@ def export(
deleted_only=deleted_only, deleted_only=deleted_only,
) )
results_exported = []
if photos: if photos:
if export_bursts: if export_bursts:
# add the burst_photos to the export set # add the burst_photos to the export set
@@ -1539,6 +1546,7 @@ def export(
# because the original code used --original-name as an option # because the original code used --original-name as an option
original_name = not current_name original_name = not current_name
results_exported = []
results_new = [] results_new = []
results_updated = [] results_updated = []
results_skipped = [] results_skipped = []
@@ -1573,6 +1581,7 @@ def export(
dry_run=dry_run, dry_run=dry_run,
touch_file=touch_file, touch_file=touch_file,
edited_suffix=edited_suffix, edited_suffix=edited_suffix,
use_photos_export=use_photos_export,
) )
results_exported.extend(results.exported) results_exported.extend(results.exported)
results_new.extend(results.new) results_new.extend(results.new)
@@ -1612,6 +1621,7 @@ def export(
dry_run=dry_run, dry_run=dry_run,
touch_file=touch_file, touch_file=touch_file,
edited_suffix=edited_suffix, edited_suffix=edited_suffix,
use_photos_export=use_photos_export,
) )
results_exported.extend(results.exported) results_exported.extend(results.exported)
results_new.extend(results.new) results_new.extend(results.new)
@@ -1629,10 +1639,12 @@ def export(
photo_str_exif_updated = ( photo_str_exif_updated = (
"photos" if len(results_exif_updated) != 1 else "photo" "photos" if len(results_exif_updated) != 1 else "photo"
) )
summary = f"Exported: {len(results_new)} {photo_str_new}, " \ summary = (
f"updated: {len(results_updated)} {photo_str_updated}, " \ f"Exported: {len(results_new)} {photo_str_new}, "
f"skipped: {len(results_skipped)} {photo_str_skipped}, " \ 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"updated EXIF data: {len(results_exif_updated)} {photo_str_exif_updated}"
)
if touch_file: if touch_file:
summary += f", touched date: {len(results_touched)} {photo_str_touched}" summary += f", touched date: {len(results_touched)} {photo_str_touched}"
click.echo(summary) click.echo(summary)
@@ -2092,6 +2104,7 @@ def export_photo(
dry_run=None, dry_run=None,
touch_file=None, touch_file=None,
edited_suffix="_edited", edited_suffix="_edited",
use_photos_export=False,
): ):
""" Helper function for export that does the actual export """ Helper function for export that does the actual export
@@ -2119,7 +2132,8 @@ def export_photo(
export_db: export database instance compatible with ExportDB_ABC export_db: export database instance compatible with ExportDB_ABC
fileutil: file util class compatible with FileUtilABC fileutil: file util class compatible with FileUtilABC
dry_run: boolean; if True, doesn't actually export or update any files dry_run: boolean; if True, doesn't actually export or update any files
touch_file: boolean; sets file's modification time upon photo date touch_file: boolean; sets file's modification time to match photo date
use_photos_export: boolean; if True forces the use of AppleScript to export even if photo not missing
Returns: Returns:
list of path(s) of exported photo or None if photo was missing list of path(s) of exported photo or None if photo was missing
@@ -2172,8 +2186,10 @@ def export_photo(
# if download_missing and the photo is missing or path doesn't exist, # if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos # try to download with Photos
use_photos_export = download_missing and ( use_photos_export = (
photo.ismissing or not os.path.exists(photo.path) download_missing and (photo.ismissing or not os.path.exists(photo.path))
if not use_photos_export
else True
) )
# export the photo to each path in dest_paths # export the photo to each path in dest_paths
@@ -2225,7 +2241,6 @@ def export_photo(
if export_edited and photo.hasadjustments: if export_edited and photo.hasadjustments:
# if download_missing and the photo is missing or path doesn't exist, # if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos # try to download with Photos
use_photos_export = download_missing and photo.path_edited is None
if not download_missing and photo.path_edited is None: if not download_missing and photo.path_edited is None:
verbose(f"Skipping missing edited photo for {filename}") verbose(f"Skipping missing edited photo for {filename}")
else: else:
@@ -2266,7 +2281,7 @@ def export_photo(
results_updated.extend(export_results_edited.updated) results_updated.extend(export_results_edited.updated)
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.touched) results_touched.extend(export_results_edited.touched)
if verbose_: if verbose_:
for exported in export_results_edited.exported: for exported in export_results_edited.exported:
@@ -2277,6 +2292,8 @@ def export_photo(
verbose(f"Exported updated file {updated}") verbose(f"Exported updated file {updated}")
for skipped in export_results_edited.skipped: for skipped in export_results_edited.skipped:
verbose(f"Skipped up to date file {skipped}") verbose(f"Skipped up to date file {skipped}")
for touched in export_results_edited.touched:
verbose(f"Touched date on file {touched}")
return ExportResults( return ExportResults(
results_exported, results_exported,
@@ -2284,7 +2301,7 @@ def export_photo(
results_updated, results_updated,
results_skipped, results_skipped,
results_exif_updated, results_exif_updated,
results_touched results_touched,
) )

View File

@@ -189,7 +189,12 @@ class ExportDB(ExportDB_ABC):
(filename,), (filename,),
) )
results = c.fetchone() results = c.fetchone()
stats = results[0:3] if results else None if results:
stats = results[0:3]
mtime = int(stats[2]) if stats[2] is not None else None
stats = (stats[0], stats[1], mtime)
else:
stats = (None, None, None)
except Error as e: except Error as e:
logging.warning(e) logging.warning(e)
stats = (None, None, None) stats = (None, None, None)
@@ -232,7 +237,12 @@ class ExportDB(ExportDB_ABC):
(filename,), (filename,),
) )
results = c.fetchone() results = c.fetchone()
stats = results[0:3] if results else None if results:
stats = results[0:3]
mtime = int(stats[2]) if stats[2] is not None else None
stats = (stats[0], stats[1], mtime)
else:
stats = (None, None, None)
except Error as e: except Error as e:
logging.warning(e) logging.warning(e)
stats = (None, None, None) stats = (None, None, None)

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.33.1" __version__ = "0.33.2"

View File

@@ -629,7 +629,7 @@ def export2(
logging.debug(f"Skipping missing RAW photo for {filename}") logging.debug(f"Skipping missing RAW photo for {filename}")
else: else:
# use_photo_export # use_photo_export
exported = None exported = []
# export live_photo .mov file? # export live_photo .mov file?
live_photo = True if live_photo and self.live_photo else False live_photo = True if live_photo and self.live_photo else False
if edited: if edited:
@@ -639,7 +639,7 @@ def export2(
filestem = dest.stem filestem = dest.stem
else: else:
# didn't get passed a filename, add _edited # didn't get passed a filename, add _edited
filestem = f"{dest.stem}_edited" filestem = f"{dest.stem}{edited_identifier}"
dest = dest.parent / f"{filestem}.jpeg" dest = dest.parent / f"{filestem}.jpeg"
exported = _export_photo_uuid_applescript( exported = _export_photo_uuid_applescript(
@@ -668,9 +668,16 @@ def export2(
dry_run=dry_run, dry_run=dry_run,
) )
if exported is not None: if exported:
# TODO: add touch_file code if touch_file:
for exported_file in exported:
touched_files.append(exported_file)
ts = int(self.date.timestamp())
fileutil.utime(exported_file, (ts, ts))
exported_files.extend(exported) exported_files.extend(exported)
if update:
update_new_files.extend(exported)
else: else:
logging.warning( logging.warning(
f"Error exporting photo {self.uuid} to {dest} with use_photos_export" f"Error exporting photo {self.uuid} to {dest} with use_photos_export"
@@ -712,7 +719,6 @@ def export2(
raise e raise e
# if exiftool, write the metadata # if exiftool, write the metadata
# TODO: add touch_file code to update touch time
if update: if update:
exif_files = update_new_files + update_updated_files + update_skipped_files exif_files = update_new_files + update_updated_files + update_skipped_files
else: else:
@@ -773,6 +779,7 @@ def export2(
keyword_template=keyword_template, keyword_template=keyword_template,
description_template=description_template, description_template=description_template,
) )
export_db.set_exifdata_for_file( export_db.set_exifdata_for_file(
exported_file, exported_file,
self._exiftool_json_sidecar( self._exiftool_json_sidecar(
@@ -787,7 +794,15 @@ def export2(
) )
exif_files_updated.append(exported_file) exif_files_updated.append(exported_file)
return ExportResults( if touch_file:
for exif_file in exif_files_updated:
touched_files.append(exported_file)
ts = int(self.date.timestamp())
fileutil.utime(exported_file, (ts, ts))
touched_files = list(set(touched_files))
results = ExportResults(
exported_files, exported_files,
update_new_files, update_new_files,
update_updated_files, update_updated_files,
@@ -795,6 +810,7 @@ def export2(
exif_files_updated, exif_files_updated,
touched_files, touched_files,
) )
return results
def _export_photo( def _export_photo(
@@ -863,22 +879,21 @@ def _export_photo(
touched_files.append(dest_str) touched_files.append(dest_str)
else: else:
# update, destination exists, but we might not need to replace it... # update, destination exists, but we might not need to replace it...
if exiftool:
sig_exif = export_db.get_stat_exif_for_file(dest_str)
cmp_orig = fileutil.cmp_file_sig(dest_str, sig_exif)
sig_exif = (sig_exif[0], sig_exif[1], int(self.date.timestamp()))
cmp_touch = fileutil.cmp_file_sig(dest_str, sig_exif)
else:
cmp_orig = fileutil.cmp(src, dest) cmp_orig = fileutil.cmp(src, dest)
cmp_touch = fileutil.cmp(src, dest, mtime1=self.date.timestamp()) cmp_touch = fileutil.cmp(src, dest, mtime1=self.date.timestamp())
sig_cmp = cmp_touch if touch_file else cmp_orig sig_cmp = cmp_touch if touch_file else cmp_orig
if (export_as_hardlink and dest.samefile(src)) or ( if (export_as_hardlink and dest.samefile(src)) or (
not export_as_hardlink not export_as_hardlink and not dest.samefile(src) and sig_cmp
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 and signatures match, skip it
update_skipped_files.append(dest_str) update_skipped_files.append(dest_str)
else: else:
# destination exists but signature is different # destination exists but signature is different
@@ -910,10 +925,6 @@ def _export_photo(
else: else:
fileutil.copy(src, dest_str, norsrc=no_xattr) fileutil.copy(src, dest_str, norsrc=no_xattr)
if touched_files:
ts = self.date.timestamp()
fileutil.utime(dest, (ts, ts))
export_db.set_data( export_db.set_data(
dest_str, dest_str,
self.uuid, self.uuid,
@@ -923,6 +934,10 @@ def _export_photo(
None, None,
) )
if touched_files:
ts = int(self.date.timestamp())
fileutil.utime(dest, (ts, ts))
return ExportResults( return ExportResults(
exported_files + update_new_files + update_updated_files, exported_files + update_new_files + update_updated_files,
update_new_files, update_new_files,

View File

@@ -2304,6 +2304,16 @@ def test_export_update_exiftool():
in result.output in result.output
) )
# update with exiftool again, should be no changes
result = runner.invoke(
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update", "--exiftool"]
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 8 photos, updated EXIF data: 0 photos"
in result.output
)
def test_export_update_hardlink(): def test_export_update_hardlink():
""" test export with hardlink then update """ """ test export with hardlink then update """
@@ -2747,6 +2757,197 @@ def test_export_touch_files_update():
) )
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_touch_files_exiftool_update():
""" test complex export scenario with --update, --exiftol, and --touch-files """
import os
import pathlib
import time
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# basic export with dry-run
result = runner.invoke(
export,
[os.path.join(cwd, PHOTOS_DB_15_6), ".", "--export-by-date", "--dry-run"],
)
assert result.exit_code == 0
assert "Exported: 16 photos" in result.output
assert not pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file()
# without dry-run
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_6), ".", "--export-by-date"]
)
assert result.exit_code == 0
assert "Exported: 16 photos" in result.output
assert pathlib.Path(CLI_EXPORT_BY_DATE[0]).is_file()
# --update
result = runner.invoke(
export,
[os.path.join(cwd, PHOTOS_DB_15_6), ".", "--export-by-date", "--update"],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 16 photos, updated EXIF data: 0 photos"
in result.output
)
# --update --exiftool --dry-run
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"--export-by-date",
"--update",
"--exiftool",
"--dry-run",
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 16 photos, skipped: 0 photos, updated EXIF data: 16 photos"
in result.output
)
# --update --exiftool
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"--export-by-date",
"--update",
"--exiftool",
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 16 photos, skipped: 0 photos, updated EXIF data: 16 photos"
in result.output
)
# --update --touch-file --exiftool --dry-run
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"--export-by-date",
"--update",
"--exiftool",
"--touch-file",
"--dry-run",
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 16 photos, updated EXIF data: 0 photos, touched date: 16 photos"
in result.output
)
for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES):
st = os.stat(fname)
assert int(st.st_mtime) != mtime
# --update --touch-file --exiftool
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"--export-by-date",
"--update",
"--exiftool",
"--touch-file",
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 16 photos, updated EXIF data: 0 photos, touched date: 16 photos"
in result.output
)
for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES):
st = os.stat(fname)
assert int(st.st_mtime) == mtime
# touch one file and run update again
ts = time.time()
os.utime(CLI_EXPORT_BY_DATE[0], (ts, ts))
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"--export-by-date",
"--update",
"--exiftool",
"--touch-file",
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 1 photo, skipped: 15 photos, updated EXIF data: 1 photo, touched date: 1 photo"
in result.output
)
for fname, mtime in zip(CLI_EXPORT_BY_DATE, CLI_EXPORT_BY_DATE_TOUCH_TIMES):
st = os.stat(fname)
assert int(st.st_mtime) == mtime
# run --update --exiftool --touch-file again
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"--export-by-date",
"--update",
"--exiftool",
"--touch-file",
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 16 photos, updated EXIF data: 0 photos, touched date: 0 photos"
in result.output
)
# run update without --touch-file
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"--export-by-date",
"--exiftool",
"--update",
],
)
assert result.exit_code == 0
assert (
"Exported: 0 photos, updated: 0 photos, skipped: 16 photos, updated EXIF data: 0 photos"
in result.output
)
def test_labels(): def test_labels():
"""Test osxphotos labels """ """Test osxphotos labels """
import json import json