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).
--only-photos Search only for photos/images (default
searches both images and movies).
--from-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]
Search by start item date, e.g.
2000-01-12T12:00:00 or 2000-12-31 (ISO 8601
w/o TZ).
--to-date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]
Search by end item date, e.g.
2000-01-12T12:00:00 or 2000-12-31 (ISO 8601
w/o TZ).
--from-date DATETIME Search by start item date, e.g.
2000-01-12T12:00:00,
2001-01-12T12:00:00-07:00, or 2000-12-31
(ISO 8601).
--to-date DATETIME Search by end item date, e.g.
2000-01-12T12:00:00,
2001-01-12T12:00:00-07:00, or 2000-12-31
(ISO 8601).
--deleted Include photos from the 'Recently Deleted'
folder.
--deleted-only Include only photos from the 'Recently
@@ -222,6 +222,8 @@ Options:
--export-as-hardlink Hardlink files instead of copying them.
Cannot be used with --exiftool which creates
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
is to add (1), (2), etc to filename if file
already exists. Use this with caution as it

View File

@@ -1124,7 +1124,7 @@ def query(
@click.option(
"--touch-file",
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(
"--overwrite",
@@ -1269,6 +1269,13 @@ def query(
"to a filesystem that doesn't support Mac OS extended attributes. Only use this if you get "
"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
@click.argument("dest", nargs=1, type=click.Path(exists=True))
@click.pass_obj
@@ -1350,6 +1357,7 @@ def export(
label,
deleted,
deleted_only,
use_photos_export,
):
""" Export photos from the Photos database.
Export path DEST is required.
@@ -1520,7 +1528,6 @@ def export(
deleted_only=deleted_only,
)
results_exported = []
if photos:
if export_bursts:
# add the burst_photos to the export set
@@ -1539,6 +1546,7 @@ 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 = []
@@ -1573,6 +1581,7 @@ def export(
dry_run=dry_run,
touch_file=touch_file,
edited_suffix=edited_suffix,
use_photos_export=use_photos_export,
)
results_exported.extend(results.exported)
results_new.extend(results.new)
@@ -1612,6 +1621,7 @@ def export(
dry_run=dry_run,
touch_file=touch_file,
edited_suffix=edited_suffix,
use_photos_export=use_photos_export,
)
results_exported.extend(results.exported)
results_new.extend(results.new)
@@ -1629,10 +1639,12 @@ def export(
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}"
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}"
)
if touch_file:
summary += f", touched date: {len(results_touched)} {photo_str_touched}"
click.echo(summary)
@@ -2092,6 +2104,7 @@ def export_photo(
dry_run=None,
touch_file=None,
edited_suffix="_edited",
use_photos_export=False,
):
""" 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
fileutil: file util class compatible with FileUtilABC
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:
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,
# try to download with Photos
use_photos_export = download_missing and (
photo.ismissing or not os.path.exists(photo.path)
use_photos_export = (
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
@@ -2225,7 +2241,6 @@ def export_photo(
if export_edited and photo.hasadjustments:
# if download_missing and the photo is missing or path doesn't exist,
# 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:
verbose(f"Skipping missing edited photo for {filename}")
else:
@@ -2266,7 +2281,7 @@ def export_photo(
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.touched)
results_touched.extend(export_results_edited.touched)
if verbose_:
for exported in export_results_edited.exported:
@@ -2277,6 +2292,8 @@ def export_photo(
verbose(f"Exported updated file {updated}")
for skipped in export_results_edited.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(
results_exported,
@@ -2284,7 +2301,7 @@ def export_photo(
results_updated,
results_skipped,
results_exif_updated,
results_touched
results_touched,
)

View File

@@ -189,7 +189,12 @@ class ExportDB(ExportDB_ABC):
(filename,),
)
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:
logging.warning(e)
stats = (None, None, None)
@@ -232,7 +237,12 @@ class ExportDB(ExportDB_ABC):
(filename,),
)
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:
logging.warning(e)
stats = (None, None, None)

View File

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

View File

@@ -34,7 +34,7 @@ class FileUtilABC(ABC):
@classmethod
@abstractmethod
def cmp(cls, file1, file2, mtime1 = None):
def cmp(cls, file1, file2, mtime1=None):
pass
@classmethod
@@ -119,7 +119,7 @@ class FileUtilMacOS(FileUtilABC):
os.utime(path, times)
@classmethod
def cmp(cls, f1, f2, mtime1 = None):
def cmp(cls, f1, f2, mtime1=None):
"""Does shallow compare (file signatures) of f1 to file f2.
Arguments:
f1 -- File name

View File

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

View File

@@ -2304,6 +2304,16 @@ def test_export_update_exiftool():
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():
""" 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():
"""Test osxphotos labels """
import json