From 70d3247e8caddfb6175f2fe5584fe2c7f1451ed8 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sun, 1 May 2022 22:22:01 -0700 Subject: [PATCH] Feature timewarp function (#678) * Added constraints, fixed themes for timewarp * Updated progress to use rich_progress * Added --function to timewarp, #676 --- examples/timewarp_function_example.py | 46 ++++++++ osxphotos/cli/timewarp.py | 160 ++++++++++++++++++++++---- osxphotos/cli/verbose.py | 9 +- osxphotos/exif_datetime_updater.py | 12 ++ osxphotos/phototz.py | 2 +- tests/config_timewarp_catalina.py | 29 ++++- tests/test_cli_timewarp.py | 34 +++++- tests/timewarp_function_example.py | 46 ++++++++ 8 files changed, 307 insertions(+), 31 deletions(-) create mode 100644 examples/timewarp_function_example.py create mode 100644 tests/timewarp_function_example.py diff --git a/examples/timewarp_function_example.py b/examples/timewarp_function_example.py new file mode 100644 index 00000000..af9e3d23 --- /dev/null +++ b/examples/timewarp_function_example.py @@ -0,0 +1,46 @@ +"""Example function for use with `osxphotos timewarp --function` + +Call this as: `osxphotos timewarp --function timewarp_function_example.py::get_date_time_timezone` +""" + +from datetime import datetime, timedelta +from typing import Callable, Optional, Tuple + +from photoscript import Photo + + +def get_date_time_timezone( + photo: Photo, path: Optional[str], tz_sec: int, tz_name: str, verbose: Callable +) -> Tuple[datetime, int]: + """Example function for use with `osxphotos timewarp --function` + + Args: + photo: Photo object + path: path to photo, which may be None if photo is not on disk + tz_sec: timezone offset from UTC in seconds + tz_name: timezone name + verbose: function to print verbose messages + + Returns: + tuple of (new date/time as datetime.datetime, and new timezone offset from UTC in seconds as int) + """ + + # this example adds 3 hours to the date/time and subtracts 1 hour from the timezone + + # the photo's date/time can be accessed as photo.date + # photo.date is a datetime.datetime object + # the date/time is naive (timezone unaware) as will be in local timezone + date = photo.date + + # add 3 hours + date = date + timedelta(hours=3) + + # subtract 1 hour from timezone + timezone = tz_sec - 3600 + + # verbose(msg) prints a message to the console if user used --verbose option + # otherwise it does nothing + # photo's filename can be access as photo.filename + verbose(f"Updating {photo.filename} date/time: {date} and timezone: {timezone}") + + return date, timezone diff --git a/osxphotos/cli/timewarp.py b/osxphotos/cli/timewarp.py index 6037729a..212a4d48 100644 --- a/osxphotos/cli/timewarp.py +++ b/osxphotos/cli/timewarp.py @@ -5,7 +5,7 @@ import os import sys from functools import partial from textwrap import dedent -from typing import Callable +from typing import Callable, Optional import click from photoscript import Photo, PhotosLibrary @@ -20,7 +20,7 @@ from osxphotos.photosalbum import PhotosAlbumPhotoScript from osxphotos.phototz import PhotoTimeZone, PhotoTimeZoneUpdater from osxphotos.timeutils import update_datetime from osxphotos.timezones import Timezone -from osxphotos.utils import pluralize +from osxphotos.utils import noop, pluralize from .click_rich_echo import ( rich_click_echo, @@ -34,7 +34,15 @@ from .color_themes import get_theme from .common import THEME_OPTION from .darkmode import is_dark_mode from .help import HELP_WIDTH, rich_text -from .param_types import DateOffset, DateTimeISO8601, TimeOffset, TimeString, UTCOffset +from .param_types import ( + DateOffset, + DateTimeISO8601, + FunctionCall, + TimeOffset, + TimeString, + UTCOffset, +) +from .rich_progress import rich_progress from .verbose import get_verbose_console, verbose_print # format for pretty printing date/times @@ -103,6 +111,48 @@ def update_photo_time_for_new_timezone( ) +def update_photo_from_function( + library_path: str, + function: Callable, + verbose_print: Callable, + photo: Photo, + path: Optional[str], +): + """Update photo from function call""" + photo_tz_sec, _, photo_tz_name = PhotoTimeZone( + library_path=library_path + ).get_timezone(photo) + dt_new, tz_new = function( + photo=photo, + path=path, + tz_sec=photo_tz_sec, + tz_name=photo_tz_name, + verbose=verbose_print, + ) + if dt_new != photo.date: + old_date = photo.date + photo.date = dt_new + verbose_print( + f"Updated date/time for photo [filename]{photo.filename}[/filename] " + f"([uuid]{photo.uuid}[/uuid]) from: [time]{old_date}[/time] to [time]{dt_new}[/time]" + ) + else: + verbose_print( + f"Skipped date/time update for photo [filename]{photo.filename}[/filename] " + f"([uuid]{photo.uuid}[/uuid]): nothing to do" + ) + if tz_new != photo_tz_sec: + tz_updater = PhotoTimeZoneUpdater( + timezone=Timezone(tz_new), verbose=verbose_print, library_path=library_path + ) + tz_updater.update_photo(photo) + else: + verbose_print( + f"Skipped timezone update for photo [filename]{photo.filename}[/filename] " + f"([uuid]{photo.uuid}[/uuid]): nothing to do" + ) + + class TimeWarpCommand(click.Command): """Custom click.Command that overrides get_help() to show additional help info for export""" @@ -267,9 +317,19 @@ For this to work, you'll need to install the third-party exiftool (https://exift "Requires the third-party exiftool utility be installed (see https://exiftool.org/). " "See also --push-exif.", ) -# constraint=RequireAtLeast(1), -# @constraint(mutually_exclusive, ["date", "date_delta"]) -# @constraint(mutually_exclusive, ["time", "time_delta"]) +@click.option( + "--function", + "-F", + metavar="filename.py::function", + nargs=1, + type=FunctionCall(), + multiple=False, + help="Run python function to determine the date/time/timezone to apply to a photo. " + "Use this in format: --function filename.py::function where filename.py is a python " + "file you've created and function is the name of the function in the python file you want to call. The function will be " + "passed information about the photo being processed and is expected to return " + "a naive datetime.datetime object with time in local time and UTC timezone offset in seconds. " +) @click.option( "--match-time", "-m", @@ -326,9 +386,6 @@ For this to work, you'll need to install the third-party exiftool (https://exift help="Terminal width in characters.", hidden=True, ) -# @constraint(mutually_exclusive, ["plain", "mono", "dark", "light"]) -# @constraint(If("match_time", then=requires_one), ["timezone"]) -# @constraint(If("add_to_album", then=requires_one), ["compare_exif"]) @click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output") @THEME_OPTION @click.option( @@ -346,6 +403,7 @@ def timewarp( compare_exif, push_exif, pull_exif, + function, match_time, use_file_time, add_to_album, @@ -362,10 +420,43 @@ def timewarp( Changes will be applied to all photos currently selected in Photos. timewarp cannot operate on photos selected in a Smart Album; - select photos in a regular album or in the 'All Photos' view. + select photos in a regular album or in the 'All Photos' view. See Timewarp Overview below for additional information. """ + # check constraints + if not any( + [ + date, + date_delta, + time, + time_delta, + timezone, + inspect, + compare_exif, + push_exif, + pull_exif, + function, + ] + ): + raise click.UsageError( + "At least one of --date, --date-delta, --time, --time-delta, " + "--timezone, --inspect, --compare-exif, --push-exif, --pull-exif, --function " + "must be specified." + ) + + if date and date_delta: + raise click.UsageError("--date and --date-delta are mutually exclusive.") + + if time and time_delta: + raise click.UsageError("--time and --time-delta are mutually exclusive.") + + if match_time and not timezone: + raise click.UsageError("--match-time must be used with --timezone.") + + if add_to_album and not compare_exif: + raise click.UsageError("--add-to-album must be used with --compare-exif.") + color_theme = get_theme(theme) verbose_ = verbose_print( verbose, @@ -376,15 +467,21 @@ def timewarp( file=output_file, ) # set console for rich_echo to be same as for verbose_ + # TODO: this is a hack, find a better way to do this terminal_width = terminal_width or (1000 if output_file else None) if output_file: set_rich_console(Console(file=output_file, width=terminal_width)) elif terminal_width: set_rich_console( - Console(file=sys.stdout, force_terminal=True, width=terminal_width) + Console( + file=sys.stdout, + theme=color_theme, + force_terminal=True, + width=terminal_width, + ) ) else: - set_rich_console(get_verbose_console()) + set_rich_console(get_verbose_console(theme=color_theme)) set_rich_theme(color_theme) if any([compare_exif, push_exif, pull_exif]): @@ -426,6 +523,16 @@ def timewarp( verbose_print=verbose_, ) + if function: + update_photo_from_function_ = partial( + update_photo_from_function, + library_path=library, + function=function[0], + verbose_print=verbose_, + ) + else: + update_photo_from_function_ = noop + if inspect: tzinfo = PhotoTimeZone(library_path=library) if photos: @@ -456,10 +563,11 @@ def timewarp( ) for photo in photos: diff_results = ( - photocomp.compare_exif_with_markup(photo) - if not plain - else photocomp.compare_exif_no_markup(photo) + photocomp.compare_exif_no_markup(photo) + if plain + else photocomp.compare_exif_with_markup(photo) ) + if not plain: filename = ( f"[change]{photo.filename}[/change]" @@ -497,7 +605,8 @@ def timewarp( timezone, verbose=verbose_, library_path=library ) - if any([push_exif, pull_exif]): + if any([push_exif, pull_exif, function]): + # ExifDateTimeUpdater used to get photo path for --function exif_updater = ExifDateTimeUpdater( library_path=library, verbose=verbose_, @@ -505,11 +614,13 @@ def timewarp( plain=plain, ) - rich_echo(f"Processing {len(photos)} {pluralize(len(photos), 'photo', 'photos')}") - # send progress bar output to /dev/null if verbose to hide the progress bar - fp = open(os.devnull, "w") if verbose else None - with click.progressbar(photos, file=fp) as bar: - for p in bar: + num_photos = len(photos) + with rich_progress(console=get_verbose_console(), mock=verbose) as progress: + task = progress.add_task( + f"Processing [num]{num_photos}[/] {pluralize(len(photos), 'photo', 'photos')}", + total=num_photos, + ) + for p in photos: if pull_exif: exif_updater.update_photos_from_exif( p, use_file_modify_date=use_file_time @@ -522,6 +633,10 @@ def timewarp( update_photo_time_for_new_timezone_(photo=p, new_timezone=timezone) if timezone: tz_updater.update_photo(p) + if function: + verbose_(f"Calling function [bold]{function[1]}") + photo_path = exif_updater.get_photo_path(p) + update_photo_from_function_(photo=p, path=photo_path) if push_exif: # this should be the last step in the if chain to ensure all Photos data is updated # before exiftool is run @@ -533,8 +648,7 @@ def timewarp( if exif_error: rich_echo_error(f"[error]Error running exiftool: {exif_error}[/]") - if fp is not None: - fp.close() + progress.advance(task) rich_echo("Done.") diff --git a/osxphotos/cli/verbose.py b/osxphotos/cli/verbose.py index ccacbd12..39181500 100644 --- a/osxphotos/cli/verbose.py +++ b/osxphotos/cli/verbose.py @@ -43,15 +43,18 @@ def noop(*args, **kwargs): pass -def get_verbose_console() -> Console: - """Get console object +def get_verbose_console(theme: t.Optional[Theme] = None) -> Console: + """Get console object or create one if not already created + + Args: + theme: optional rich.theme.Theme object to use for formatting Returns: Console object """ global _console if _console.console is None: - _console.console = Console(force_terminal=True) + _console.console = Console(force_terminal=True, theme=theme) return _console.console diff --git a/osxphotos/exif_datetime_updater.py b/osxphotos/exif_datetime_updater.py index 9cb0dd82..d00ee626 100644 --- a/osxphotos/exif_datetime_updater.py +++ b/osxphotos/exif_datetime_updater.py @@ -247,6 +247,18 @@ class ExifDateTimeUpdater: exif, use_file_modify_date=use_file_modify_date ) + def get_photo_path(self, photo: Photo) -> Optional[str]: + """Get the path to a photo + + Args: + photo: photoscript.Photo object to act on + + Returns: + str: path to photo or None if not found + """ + _photo = self.db.get_photo(photo.uuid) + return _photo.path if _photo else None + def get_exif_date_time_offset( exif: Dict, use_file_modify_date: bool = False diff --git a/osxphotos/phototz.py b/osxphotos/phototz.py index a71feddb..5d55488f 100644 --- a/osxphotos/phototz.py +++ b/osxphotos/phototz.py @@ -149,7 +149,7 @@ class PhotoTimeZoneUpdater: conn.commit() self.verbose( f"Updated timezone for photo [filename]{photo.filename}[/filename] ([uuid]{photo.uuid}[/uuid]) " - + f"from [tz]{[tz_name]}[/tz], offset=[tz]{tz_offset}[/tz] " + + f"from [tz]{tz_name}[/tz], offset=[tz]{tz_offset}[/tz] " + f"to [tz]{self.tz_name}[/tz], offset=[tz]{self.tz_offset}[/tz]" ) except Exception as e: diff --git a/tests/config_timewarp_catalina.py b/tests/config_timewarp_catalina.py index 2b3444bd..2bcf64d1 100644 --- a/tests/config_timewarp_catalina.py +++ b/tests/config_timewarp_catalina.py @@ -1,11 +1,20 @@ """ Test data for timewarp command on Catalina/Photos 5 """ import datetime +import pathlib from tests.parse_timewarp_output import CompareValues, InspectValues TEST_LIBRARY_TIMEWARP = "tests/TestTimeWarp-10.15.7.photoslibrary" + +def get_file_timestamp(file: str) -> str: + """Get timestamp of file""" + return datetime.datetime.fromtimestamp(pathlib.Path(file).stat().st_mtime).strftime( + "%Y-%m-%d %H:%M:%S" + ) + + CATALINA_PHOTOS_5 = { "filenames": { "pumpkins": "IMG_6522.jpeg", @@ -258,7 +267,9 @@ CATALINA_PHOTOS_5 = { "post": CompareValues( "IMG_6506.jpeg", "7E9DF2EE-A5B0-4077-80EC-30565221A3B9", - "2021-10-08 16:11:09", + get_file_timestamp( + f"{TEST_LIBRARY_TIMEWARP}/originals/7/7E9DF2EE-A5B0-4077-80EC-30565221A3B9.jpeg" + ), "", "-0700", "", @@ -302,7 +313,7 @@ CATALINA_PHOTOS_5 = { "parameters": [("-0200", "2021-10-04 23:00:00-0200")] }, "video_push_exif": { - # IMG_6501.jpeg + # IMG_6551.mov "pre": CompareValues( "IMG_6551.mov", "16BEC0BE-4188-44F1-A8F1-7250E978AD12", @@ -321,7 +332,7 @@ CATALINA_PHOTOS_5 = { ), }, "video_pull_exif": { - # IMG_6501.jpeg + # IMG_6551.jpeg "pre": CompareValues( "IMG_6551.mov", "16BEC0BE-4188-44F1-A8F1-7250E978AD12", @@ -339,4 +350,16 @@ CATALINA_PHOTOS_5 = { "-0200", ), }, + "function": { + # IMG_6501.jpeg + "uuid": "2F00448D-3C0D-477A-9B10-5F21DCAB405A", + "expected": InspectValues( + "IMG_6501.jpeg", + "2F00448D-3C0D-477A-9B10-5F21DCAB405A", + "2020-09-01 18:53:02-0700", + "2020-09-01 18:53:02-0700", + "-0700", + "GMT-0700", + ), + }, } diff --git a/tests/test_cli_timewarp.py b/tests/test_cli_timewarp.py index b050ed1e..ebfbabc2 100644 --- a/tests/test_cli_timewarp.py +++ b/tests/test_cli_timewarp.py @@ -46,7 +46,7 @@ def ask_user_to_make_selection( video: set to True if asking for a video instead of a photo """ # needs to be called with a suspend_capture fixture - photo_or_video = "photo" if not video else "video" + photo_or_video = "video" if video else "photo" tries = 0 while tries < retry: with suspend_capture: @@ -935,3 +935,35 @@ def test_video_pull_exif(photoslib, suspend_capture, output_file): ) output_values = parse_compare_exif(output_file) assert output_values[0] == post_test + + +@pytest.mark.timewarp +def test_select_pears_3(photoslib, suspend_capture): + """Force user to select the right photo for following tests""" + assert ask_user_to_make_selection(photoslib, suspend_capture, "pears") + + +@pytest.mark.timewarp +def test_function(photoslib, suspend_capture, output_file): + """Test timewarp function""" + from osxphotos.cli.timewarp import timewarp + + expected = TEST_DATA["function"]["expected"] + + runner = CliRunner() + result = runner.invoke( + timewarp, + [ + "--function", + "tests/timewarp_function_example.py::get_date_time_timezone", + ], + terminal_width=TERMINAL_WIDTH, + ) + assert result.exit_code == 0 + result = runner.invoke( + timewarp, + ["--inspect", "--plain", "-o", output_file], + terminal_width=TERMINAL_WIDTH, + ) + output_values = parse_inspect_output(output_file) + assert output_values[0] == expected diff --git a/tests/timewarp_function_example.py b/tests/timewarp_function_example.py new file mode 100644 index 00000000..af9e3d23 --- /dev/null +++ b/tests/timewarp_function_example.py @@ -0,0 +1,46 @@ +"""Example function for use with `osxphotos timewarp --function` + +Call this as: `osxphotos timewarp --function timewarp_function_example.py::get_date_time_timezone` +""" + +from datetime import datetime, timedelta +from typing import Callable, Optional, Tuple + +from photoscript import Photo + + +def get_date_time_timezone( + photo: Photo, path: Optional[str], tz_sec: int, tz_name: str, verbose: Callable +) -> Tuple[datetime, int]: + """Example function for use with `osxphotos timewarp --function` + + Args: + photo: Photo object + path: path to photo, which may be None if photo is not on disk + tz_sec: timezone offset from UTC in seconds + tz_name: timezone name + verbose: function to print verbose messages + + Returns: + tuple of (new date/time as datetime.datetime, and new timezone offset from UTC in seconds as int) + """ + + # this example adds 3 hours to the date/time and subtracts 1 hour from the timezone + + # the photo's date/time can be accessed as photo.date + # photo.date is a datetime.datetime object + # the date/time is naive (timezone unaware) as will be in local timezone + date = photo.date + + # add 3 hours + date = date + timedelta(hours=3) + + # subtract 1 hour from timezone + timezone = tz_sec - 3600 + + # verbose(msg) prints a message to the console if user used --verbose option + # otherwise it does nothing + # photo's filename can be access as photo.filename + verbose(f"Updating {photo.filename} date/time: {date} and timezone: {timezone}") + + return date, timezone