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:
parent
25f35699d8
commit
70d3247e8c
46
examples/timewarp_function_example.py
Normal file
46
examples/timewarp_function_example.py
Normal 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
|
||||
@ -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.")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
46
tests/timewarp_function_example.py
Normal file
46
tests/timewarp_function_example.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user