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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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
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,
@ -366,6 +424,39 @@ def timewarp(
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.")

View File

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

View File

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

View File

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

View File

@ -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",
),
},
}

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
"""
# 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

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