Feature timewarp function (#678)

* Added constraints, fixed themes for timewarp

* Updated progress to use rich_progress

* Added --function to timewarp, #676
This commit is contained in:
Rhet Turnbull
2022-05-01 22:22:01 -07:00
committed by GitHub
parent 25f35699d8
commit 70d3247e8c
8 changed files with 307 additions and 31 deletions

View File

@@ -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

View File

@@ -5,7 +5,7 @@ import os
import sys import sys
from functools import partial from functools import partial
from textwrap import dedent from textwrap import dedent
from typing import Callable from typing import Callable, Optional
import click import click
from photoscript import Photo, PhotosLibrary from photoscript import Photo, PhotosLibrary
@@ -20,7 +20,7 @@ from osxphotos.photosalbum import PhotosAlbumPhotoScript
from osxphotos.phototz import PhotoTimeZone, PhotoTimeZoneUpdater from osxphotos.phototz import PhotoTimeZone, PhotoTimeZoneUpdater
from osxphotos.timeutils import update_datetime from osxphotos.timeutils import update_datetime
from osxphotos.timezones import Timezone from osxphotos.timezones import Timezone
from osxphotos.utils import pluralize from osxphotos.utils import noop, pluralize
from .click_rich_echo import ( from .click_rich_echo import (
rich_click_echo, rich_click_echo,
@@ -34,7 +34,15 @@ from .color_themes import get_theme
from .common import THEME_OPTION from .common import THEME_OPTION
from .darkmode import is_dark_mode from .darkmode import is_dark_mode
from .help import HELP_WIDTH, rich_text 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 from .verbose import get_verbose_console, verbose_print
# format for pretty printing date/times # 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): class TimeWarpCommand(click.Command):
"""Custom click.Command that overrides get_help() to show additional help info for export""" """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/). " "Requires the third-party exiftool utility be installed (see https://exiftool.org/). "
"See also --push-exif.", "See also --push-exif.",
) )
# constraint=RequireAtLeast(1), @click.option(
# @constraint(mutually_exclusive, ["date", "date_delta"]) "--function",
# @constraint(mutually_exclusive, ["time", "time_delta"]) "-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( @click.option(
"--match-time", "--match-time",
"-m", "-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.", help="Terminal width in characters.",
hidden=True, 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") @click.option("--timestamp", is_flag=True, help="Add time stamp to verbose output")
@THEME_OPTION @THEME_OPTION
@click.option( @click.option(
@@ -346,6 +403,7 @@ def timewarp(
compare_exif, compare_exif,
push_exif, push_exif,
pull_exif, pull_exif,
function,
match_time, match_time,
use_file_time, use_file_time,
add_to_album, add_to_album,
@@ -362,10 +420,43 @@ def timewarp(
Changes will be applied to all photos currently selected in Photos. Changes will be applied to all photos currently selected in Photos.
timewarp cannot operate on photos selected in a Smart Album; 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. 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) color_theme = get_theme(theme)
verbose_ = verbose_print( verbose_ = verbose_print(
verbose, verbose,
@@ -376,15 +467,21 @@ def timewarp(
file=output_file, file=output_file,
) )
# set console for rich_echo to be same as for verbose_ # 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) terminal_width = terminal_width or (1000 if output_file else None)
if output_file: if output_file:
set_rich_console(Console(file=output_file, width=terminal_width)) set_rich_console(Console(file=output_file, width=terminal_width))
elif terminal_width: elif terminal_width:
set_rich_console( 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: else:
set_rich_console(get_verbose_console()) set_rich_console(get_verbose_console(theme=color_theme))
set_rich_theme(color_theme) set_rich_theme(color_theme)
if any([compare_exif, push_exif, pull_exif]): if any([compare_exif, push_exif, pull_exif]):
@@ -426,6 +523,16 @@ def timewarp(
verbose_print=verbose_, 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: if inspect:
tzinfo = PhotoTimeZone(library_path=library) tzinfo = PhotoTimeZone(library_path=library)
if photos: if photos:
@@ -456,10 +563,11 @@ def timewarp(
) )
for photo in photos: for photo in photos:
diff_results = ( diff_results = (
photocomp.compare_exif_with_markup(photo) photocomp.compare_exif_no_markup(photo)
if not plain if plain
else photocomp.compare_exif_no_markup(photo) else photocomp.compare_exif_with_markup(photo)
) )
if not plain: if not plain:
filename = ( filename = (
f"[change]{photo.filename}[/change]" f"[change]{photo.filename}[/change]"
@@ -497,7 +605,8 @@ def timewarp(
timezone, verbose=verbose_, library_path=library 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( exif_updater = ExifDateTimeUpdater(
library_path=library, library_path=library,
verbose=verbose_, verbose=verbose_,
@@ -505,11 +614,13 @@ def timewarp(
plain=plain, plain=plain,
) )
rich_echo(f"Processing {len(photos)} {pluralize(len(photos), 'photo', 'photos')}") num_photos = len(photos)
# send progress bar output to /dev/null if verbose to hide the progress bar with rich_progress(console=get_verbose_console(), mock=verbose) as progress:
fp = open(os.devnull, "w") if verbose else None task = progress.add_task(
with click.progressbar(photos, file=fp) as bar: f"Processing [num]{num_photos}[/] {pluralize(len(photos), 'photo', 'photos')}",
for p in bar: total=num_photos,
)
for p in photos:
if pull_exif: if pull_exif:
exif_updater.update_photos_from_exif( exif_updater.update_photos_from_exif(
p, use_file_modify_date=use_file_time 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) update_photo_time_for_new_timezone_(photo=p, new_timezone=timezone)
if timezone: if timezone:
tz_updater.update_photo(p) 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: if push_exif:
# this should be the last step in the if chain to ensure all Photos data is updated # this should be the last step in the if chain to ensure all Photos data is updated
# before exiftool is run # before exiftool is run
@@ -533,8 +648,7 @@ def timewarp(
if exif_error: if exif_error:
rich_echo_error(f"[error]Error running exiftool: {exif_error}[/]") rich_echo_error(f"[error]Error running exiftool: {exif_error}[/]")
if fp is not None: progress.advance(task)
fp.close()
rich_echo("Done.") rich_echo("Done.")

View File

@@ -43,15 +43,18 @@ def noop(*args, **kwargs):
pass pass
def get_verbose_console() -> Console: def get_verbose_console(theme: t.Optional[Theme] = None) -> Console:
"""Get console object """Get console object or create one if not already created
Args:
theme: optional rich.theme.Theme object to use for formatting
Returns: Returns:
Console object Console object
""" """
global _console global _console
if _console.console is None: if _console.console is None:
_console.console = Console(force_terminal=True) _console.console = Console(force_terminal=True, theme=theme)
return _console.console return _console.console

View File

@@ -247,6 +247,18 @@ class ExifDateTimeUpdater:
exif, use_file_modify_date=use_file_modify_date 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( def get_exif_date_time_offset(
exif: Dict, use_file_modify_date: bool = False exif: Dict, use_file_modify_date: bool = False

View File

@@ -149,7 +149,7 @@ class PhotoTimeZoneUpdater:
conn.commit() conn.commit()
self.verbose( self.verbose(
f"Updated timezone for photo [filename]{photo.filename}[/filename] ([uuid]{photo.uuid}[/uuid]) " 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]" + f"to [tz]{self.tz_name}[/tz], offset=[tz]{self.tz_offset}[/tz]"
) )
except Exception as e: except Exception as e:

View File

@@ -1,11 +1,20 @@
""" Test data for timewarp command on Catalina/Photos 5 """ """ Test data for timewarp command on Catalina/Photos 5 """
import datetime import datetime
import pathlib
from tests.parse_timewarp_output import CompareValues, InspectValues from tests.parse_timewarp_output import CompareValues, InspectValues
TEST_LIBRARY_TIMEWARP = "tests/TestTimeWarp-10.15.7.photoslibrary" 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 = { CATALINA_PHOTOS_5 = {
"filenames": { "filenames": {
"pumpkins": "IMG_6522.jpeg", "pumpkins": "IMG_6522.jpeg",
@@ -258,7 +267,9 @@ CATALINA_PHOTOS_5 = {
"post": CompareValues( "post": CompareValues(
"IMG_6506.jpeg", "IMG_6506.jpeg",
"7E9DF2EE-A5B0-4077-80EC-30565221A3B9", "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", "-0700",
"", "",
@@ -302,7 +313,7 @@ CATALINA_PHOTOS_5 = {
"parameters": [("-0200", "2021-10-04 23:00:00-0200")] "parameters": [("-0200", "2021-10-04 23:00:00-0200")]
}, },
"video_push_exif": { "video_push_exif": {
# IMG_6501.jpeg # IMG_6551.mov
"pre": CompareValues( "pre": CompareValues(
"IMG_6551.mov", "IMG_6551.mov",
"16BEC0BE-4188-44F1-A8F1-7250E978AD12", "16BEC0BE-4188-44F1-A8F1-7250E978AD12",
@@ -321,7 +332,7 @@ CATALINA_PHOTOS_5 = {
), ),
}, },
"video_pull_exif": { "video_pull_exif": {
# IMG_6501.jpeg # IMG_6551.jpeg
"pre": CompareValues( "pre": CompareValues(
"IMG_6551.mov", "IMG_6551.mov",
"16BEC0BE-4188-44F1-A8F1-7250E978AD12", "16BEC0BE-4188-44F1-A8F1-7250E978AD12",
@@ -339,4 +350,16 @@ CATALINA_PHOTOS_5 = {
"-0200", "-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",
),
},
} }

View File

@@ -46,7 +46,7 @@ def ask_user_to_make_selection(
video: set to True if asking for a video instead of a photo video: set to True if asking for a video instead of a photo
""" """
# needs to be called with a suspend_capture fixture # 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 tries = 0
while tries < retry: while tries < retry:
with suspend_capture: with suspend_capture:
@@ -935,3 +935,35 @@ def test_video_pull_exif(photoslib, suspend_capture, output_file):
) )
output_values = parse_compare_exif(output_file) output_values = parse_compare_exif(output_file)
assert output_values[0] == post_test 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

View File

@@ -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