Feature batch edit 949 (#1001)

* Initial implementation of batch-edit, #949

* Added tests for batch-edit, #949
This commit is contained in:
Rhet Turnbull 2023-02-25 14:37:26 -08:00 committed by GitHub
parent 1981340108
commit 1661cc9f0b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 522 additions and 13 deletions

View File

@ -46,6 +46,7 @@ if args.get("--debug", False):
from .about import about
from .add_locations import add_locations
from .albums import albums
from .batch_edit import batch_edit
from .cli import cli_main
from .cli_commands import (
abort,
@ -95,6 +96,7 @@ __all__ = [
"about",
"add_locations",
"albums",
"batch_edit",
"cli_main",
"debug_dump",
"diff",

323
osxphotos/cli/batch_edit.py Normal file
View File

@ -0,0 +1,323 @@
"""
batch-edit command for osxphotos CLI
"""
from __future__ import annotations
import functools
import json
import sys
import click
import photoscript
import osxphotos
from osxphotos.phototemplate import RenderOptions
from osxphotos.sqlitekvstore import SQLiteKVStore
from .cli_commands import echo, echo_error, selection_command, verbose
from .kvstore import kvstore
from .param_types import TemplateString
class Latitude(click.ParamType):
name = "Latitude"
def convert(self, value, param, ctx):
try:
latitude = float(value)
if latitude < -90 or latitude > 90:
raise ValueError
return latitude
except Exception:
self.fail(
f"Invalid latitude {value}. Must be a floating point number between -90 and 90."
)
class Longitude(click.ParamType):
name = "Longitude"
def convert(self, value, param, ctx):
try:
longitude = float(value)
if longitude < -180 or longitude > 180:
raise ValueError
return longitude
except Exception:
self.fail(
f"Invalid longitude {value}. Must be a floating point number between -180 and 180."
)
@selection_command(name="batch-edit")
@click.option(
"--title",
metavar="TITLE_TEMPLATE",
type=TemplateString(),
help="Set title of photo.",
)
@click.option(
"--description",
metavar="DESCRIPTION_TEMPLATE",
type=TemplateString(),
help="Set description of photo.",
)
@click.option(
"--keyword",
metavar="KEYWORD_TEMPLATE",
type=TemplateString(),
multiple=True,
help="Set keywords of photo. May be specified multiple times.",
)
@click.option(
"--location",
metavar="LATITUDE LONGITUDE",
type=click.Tuple([Latitude(), Longitude()]),
help="Set location of photo. "
"Must be specified as a pair of numbers with latitude in the range -90 to 90 and longitude in the range -180 to 180.",
)
@click.option("--dry-run", is_flag=True, help="Don't actually change anything.")
@click.option(
"--undo",
is_flag=True,
help="Restores photo metadata to what it was prior to the last batch edit. "
"May be combined with --dry-run.",
)
def batch_edit(
photos: list[osxphotos.PhotoInfo],
title,
description,
keyword,
location,
dry_run,
undo,
**kwargs,
):
"""
Batch edit photo metadata such as title, description, keywords, etc.
Operates on currently selected photos.
Select one or more photos in Photos then run this command to edit the metadata.
For example:
\b
osxphotos run batch_edit.py \\
--verbose \\
--title "California vacation 2023 {created.year}-{created.dd}-{created.mm} {counter:03d}" \\
--description "{place.name}" \\
--keyword "Family" --keyword "Travel" --keyword "{keyword}"
This will set the title to "California vacation 2023 2023-02-20 001", and so on,
the description to the reverse geolocation place name,
and the keywords to "Family", "Travel", and any existing keywords of the photo.
--title, --description, and --keyword may be any valid template string.
See https://rhettbull.github.io/osxphotos/template_help.html
or `osxphotos docs` for more information on the osxphotos template system.
"""
if not title and not description and not keyword and not location and not undo:
echo_error(
"[error] Must specify at least one of: "
" --title, --description, --keyword, --location, --undo. "
"Use --help for more information."
)
sys.exit(1)
if undo and (title or description or keyword or location):
echo_error(
"[error] Cannot specify --undo and any options other than --dry-run. "
"Use --help for more information."
)
sys.exit(1)
if not photos:
echo_error("[error] No photos selected")
sys.exit(1)
# sort photos by date so that {counter} order is correct
photos.sort(key=lambda p: p.date)
undo_store = kvstore("batch_edit")
verbose(f"Undo database stored in [filepath]{undo_store.path}", level=2)
echo(f"Processing [num]{len(photos)}[/] photos...")
for photo in photos:
verbose(
f"Processing [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/])"
)
if undo:
undo_photo_edits(photo, undo_store, dry_run)
continue
save_photo_undo_info(undo_store, photo)
set_photo_title_from_template(photo, title, dry_run)
set_photo_description_from_template(photo, description, dry_run)
set_photo_keywords_from_template(photo, keyword, dry_run)
set_photo_location(photo, location, dry_run)
# cache photoscript Photo object to avoid re-creating it for each photo
# maxsize=1 as this function is called repeatedly for each photo then
# the next photo is processed
@functools.lru_cache(maxsize=1)
def photoscript_photo(photo: osxphotos.PhotoInfo) -> photoscript.Photo:
"""Return photoscript Photo object for photo"""
return photoscript.Photo(photo.uuid)
def save_photo_undo_info(undo_store: SQLiteKVStore, photo: osxphotos.PhotoInfo):
"""Save undo information to undo store"""
undo_store[photo.uuid] = photo.json()
def undo_photo_edits(
photo: osxphotos.PhotoInfo, undo_store: SQLiteKVStore, dry_run: bool
):
"""Undo edits for photo"""
if not (undo_info := undo_store.get(photo.uuid)):
verbose(
f"[warning] No undo information for photo [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/])"
)
return
undo_info = json.loads(undo_info)
ps_photo = photoscript_photo(photo)
exiting_title, exiting_description, exiting_keywords, exiting_location = (
photo.title,
photo.description,
sorted(photo.keywords),
photo.location,
)
previous_title, previous_description, previous_keywords, previous_location = (
undo_info.get("title"),
undo_info.get("description"),
sorted(undo_info.get("keywords")),
(undo_info.get("latitude"), undo_info.get("longitude")),
)
verbose(
f"Undoing edits for [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/])"
)
for name, existing, previous in (
("title", exiting_title, previous_title),
("description", exiting_description, previous_description),
("keywords", exiting_keywords, previous_keywords),
("location", exiting_location, previous_location),
):
if existing != previous:
verbose(
f" [i]{name}[/]: [change]{existing}[/] -> [no_change]{previous}[/]"
)
if not dry_run:
setattr(ps_photo, name, previous)
else:
verbose(f" [i]{name} (no change)[/]: [no_change]{existing}[/]", level=2)
def set_photo_title_from_template(
photo: osxphotos.PhotoInfo, title_template: str, dry_run: bool
):
"""Set photo title from template"""
if not title_template:
return
# don't render None values
render_options = RenderOptions(none_str="")
title_string, _ = photo.render_template(title_template, render_options)
title_string = [ts for ts in title_string if ts]
if not title_string:
verbose(
f"No title returned from template, nothing to do: [bold]{title_template}"
)
return
if len(title_string) > 1:
echo_error(
f"[error] Title template must return a single string: [bold]{title_string}"
)
sys.exit(1)
verbose(f"Setting [i]title[/i] to [bold]{title_string[0]}")
if not dry_run:
ps_photo = photoscript_photo(photo)
ps_photo.title = title_string[0]
def set_photo_description_from_template(
photo: osxphotos.PhotoInfo, description_template: str, dry_run: bool
):
"""Set photo description from template"""
if not description_template:
return
# don't render None values
render_options = RenderOptions(none_str="")
description_string, _ = photo.render_template(description_template, render_options)
description_string = [ds for ds in description_string if ds]
if not description_string:
verbose(
f"No description returned from template, nothing to do: [bold]{description_template}"
)
return
if len(description_string) > 1:
echo_error(
f"[error] Description template must return a single string: [bold]{description_string}"
)
sys.exit(1)
verbose(f"Setting [i]description[/] to [bold]{description_string[0]}")
if not dry_run:
ps_photo = photoscript_photo(photo)
ps_photo.description = description_string[0]
def set_photo_keywords_from_template(
photo: osxphotos.PhotoInfo, keyword_template: list[str], dry_run: bool
):
"""Set photo keywords from template"""
if not keyword_template:
return
# don't render None values
render_options = RenderOptions(none_str="")
keywords = set()
for kw in keyword_template:
kw_string, _ = photo.render_template(kw, render_options)
if kw_string:
# filter out empty strings
keywords.update([k for k in kw_string if k])
if not keywords:
verbose(
f"No keywords returned from template, nothing to do: [bold]{keyword_template}"
)
return
verbose(
f"Setting [i]keywords[/] to {', '.join(f'[bold]{kw}[/]' for kw in keywords)}"
)
if not dry_run:
ps_photo = photoscript_photo(photo)
ps_photo.keywords = list(keywords)
def set_photo_location(
photo: osxphotos.PhotoInfo, location: tuple[float, float], dry_run: bool
):
"""Set photo location"""
if not location or location[0] is None or location[1] is None:
return
latitude, longitude = location
verbose(
f"Setting [i]location[/] to [num]{latitude:.6f}[/], [num]{longitude:.6f}[/]"
)
if not dry_run:
ps_photo = photoscript_photo(photo)
ps_photo.location = (latitude, longitude)

View File

@ -13,6 +13,7 @@ from osxphotos._version import __version__
from .about import about
from .add_locations import add_locations
from .albums import albums
from .batch_edit import batch_edit
from .cli_params import DB_OPTION, DEBUG_OPTIONS, JSON_OPTION
from .common import OSXPHOTOS_HIDDEN
from .debug_dump import debug_dump
@ -109,6 +110,7 @@ for command in [
about,
add_locations,
albums,
batch_edit,
debug_dump,
diff,
docs_command,

View File

@ -26,6 +26,9 @@ A couple of tests require interaction with Photos and configuring a specific tes
--addalbum: test --add-to-album options
--timewarp: test `osxphotos timewarp`
--test-import: test `osxphotos import`
--test-sync: test `osxphotos sync`
--test-add-locations: test `osxphotos add-locations`
--test-batch-edit: test `osxphotos batch-edit`
## Test Photo Libraries
**Important**: The test code uses several test photo libraries created on various version of MacOS. If you need to inspect one of these or modify one for a test, make a copy of the library (for example, copy it to your ~/Pictures folder) then open the copy in Photos. Once done, copy the revised library back to the tests/ folder. If you do not do this, the Photos background process photoanalysisd will forever try to process the library resulting in updates to the database which will cause git to see changes to the file you didn't intend. I'm not aware of any way to disassociate photoanalysisd from the library once you've opened it in Photos.

View File

@ -26,6 +26,9 @@ TEST_SYNC = False
# run add-locations tests (configured with --test-add-locations)
TEST_ADD_LOCATIONS = False
# run batch-edit tests (configured with --test-batch-edit)
TEST_BATCH_EDIT = False
# don't clean up crash logs (configured with --no-cleanup)
NO_CLEANUP = False
@ -124,6 +127,7 @@ def pytest_addoption(parser):
default=False,
help="run `osxphotos import` tests",
)
parser.addoption("--test-batch-edit", action="store_true", default=False)
parser.addoption(
"--test-sync",
action="store_true",
@ -153,12 +157,14 @@ def pytest_configure(config):
config.getoption("--timewarp"),
config.getoption("--test-import"),
config.getoption("--test-sync"),
config.getoption("--test-batch-edit"),
0,
]
)
> 1
):
pytest.exit(
"--addalbum, --timewarp, --test-import, --test-sync are mutually exclusive"
"--addalbum, --timewarp, --test-import, --test-sync, --test-batch-edit are mutually exclusive"
)
config.addinivalue_line(
@ -177,6 +183,9 @@ def pytest_configure(config):
"markers",
"test_add_locations: mark test as requiring --test-add-locations to run",
)
config.addinivalue_line(
"markers", "test_batch_edit: mark test as requiring --test-batch-edit to run"
)
# this is hacky but I can't figure out how to check config options in other fixtures
if config.getoption("--timewarp"):
@ -199,40 +208,44 @@ def pytest_configure(config):
global NO_CLEANUP
NO_CLEANUP = True
if config.getoption("--test-batch-edit"):
global TEST_BATCH_EDIT
TEST_BATCH_EDIT = True
def pytest_collection_modifyitems(config, items):
if not (config.getoption("--addalbum") and TEST_LIBRARY is not None):
skip_addalbum = pytest.mark.skip(
reason="need --addalbum option and MacOS Catalina to run"
)
skip_addalbum = pytest.mark.skip(reason="need --addalbum option to run")
for item in items:
if "addalbum" in item.keywords:
item.add_marker(skip_addalbum)
if not (config.getoption("--timewarp") and TEST_LIBRARY_TIMEWARP is not None):
skip_timewarp = pytest.mark.skip(
reason="need --timewarp option and MacOS Catalina to run"
)
skip_timewarp = pytest.mark.skip(reason="need --timewarp option to run")
for item in items:
if "timewarp" in item.keywords:
item.add_marker(skip_timewarp)
if not (config.getoption("--test-import") and TEST_LIBRARY_IMPORT is not None):
skip_test_import = pytest.mark.skip(
reason="need --test-import option and MacOS Catalina or Ventura to run"
)
skip_test_import = pytest.mark.skip(reason="need --test-import option to run")
for item in items:
if "test_import" in item.keywords:
item.add_marker(skip_test_import)
if not (config.getoption("--test-sync") and TEST_LIBRARY_SYNC is not None):
skip_test_sync = pytest.mark.skip(
reason="need --test-sync option and MacOS Catalina to run"
)
skip_test_sync = pytest.mark.skip(reason="need --test-sync option to run")
for item in items:
if "test_sync" in item.keywords:
item.add_marker(skip_test_sync)
if not (config.getoption("--test-batch-edit")):
skip_test_batch_edit = pytest.mark.skip(
reason="need --test-batch-edit option to run"
)
for item in items:
if "test_batch_edit" in item.keywords:
item.add_marker(skip_test_batch_edit)
if not (
config.getoption("--test-add-locations")
and TEST_LIBRARY_ADD_LOCATIONS is not None

View File

@ -0,0 +1,166 @@
"""Test osxphotos batch-edit command"""
from __future__ import annotations
import os
import time
import photoscript
import pytest
from click.testing import CliRunner
import osxphotos
from osxphotos.cli.batch_edit import batch_edit
# set timezone to avoid issues with comparing dates
os.environ["TZ"] = "US/Pacific"
time.tzset()
TEST_DATA_BATCH_EDIT = {
"uuid": "F12384F6-CD17-4151-ACBA-AE0E3688539E", # Pumkins1.jpg,
"data": [
(
["--title", "Pumpkin Farm {created.year}-{created.mm}-{created.dd}"],
{"title": "Pumpkin Farm 2018-09-28"},
),
(
[
"--description",
"Pumpkin Farm {created.year}",
"--keyword",
"kids",
"--keyword",
"holiday",
],
{
"description": "Pumpkin Farm 2018",
"keywords": sorted(["kids", "holiday"]),
},
),
(
["--location", "34.052235", "-118.243683"],
{"location": (34.052235, -118.243683)},
),
],
}
def say(msg: str) -> None:
"""Say message with text to speech"""
os.system(f"say {msg}")
def ask_user_to_make_selection(
photoslib: photoscript.PhotosLibrary, suspend_capture, msg: str
) -> list[photoscript.Photo]:
"""Ask user to make selection in Photos and press enter when done"""
with suspend_capture:
photoslib.activate()
say(f"Select the photo of the {msg} in Photos and press enter when done")
input("Press enter when done")
return photoslib.selection
@pytest.mark.test_batch_edit
def test_select_photo(photoslib, suspend_capture):
"""Test batch-edit command"""
photos = ask_user_to_make_selection(
photoslib, suspend_capture, "children lifting the pumpkins"
)
assert len(photos) == 1
photo = photos[0]
assert photo.uuid == TEST_DATA_BATCH_EDIT["uuid"]
# initialize the photo's metadata
photo.title = None
photo.description = None
photo.keywords = None
photo.location = None
@pytest.mark.test_batch_edit
@pytest.mark.parametrize("args,expected", TEST_DATA_BATCH_EDIT["data"])
def test_batch_edit(args, expected):
"""Test batch-edit command"""
with CliRunner().isolated_filesystem():
result = CliRunner().invoke(
batch_edit,
[*args, "--dry-run"],
)
assert result.exit_code == 0
photo = osxphotos.PhotosDB().get_photo(TEST_DATA_BATCH_EDIT["uuid"])
for key, expected_value in expected.items():
got = getattr(photo, key)
if isinstance(got, list):
got = sorted(got)
assert got != expected_value
result = CliRunner().invoke(
batch_edit,
[*args],
)
assert result.exit_code == 0
photo = osxphotos.PhotosDB().get_photo(TEST_DATA_BATCH_EDIT["uuid"])
for key, expected_value in expected.items():
got = getattr(photo, key)
if isinstance(got, list):
got = sorted(got)
assert got == expected_value
@pytest.mark.test_batch_edit
def test_batch_edit_undo(photoslib):
"""Test batch-edit command with --undo"""
photo = photoslib.selection[0]
assert photo.uuid == TEST_DATA_BATCH_EDIT["uuid"]
photo.title = "Pumpkin Farm"
photo.description = "Pumpkin Farm"
photo.keywords = ["kids"]
photo.location = (41.256566, -95.940257)
with CliRunner().isolated_filesystem():
result = CliRunner().invoke(
batch_edit,
[
"--title",
"Test",
"--description",
"Test",
"--keyword",
"test",
"--location",
"34.052235",
"-118.243683",
],
)
assert result.exit_code == 0
photo = osxphotos.PhotosDB().get_photo(TEST_DATA_BATCH_EDIT["uuid"])
assert photo.title == "Test"
assert photo.description == "Test"
assert photo.keywords == ["test"]
assert photo.location == (34.052235, -118.243683)
result = CliRunner().invoke(
batch_edit,
["--undo", "--dry-run"],
)
assert result.exit_code == 0
photo = osxphotos.PhotosDB().get_photo(TEST_DATA_BATCH_EDIT["uuid"])
assert photo.title == "Test"
assert photo.description == "Test"
assert photo.keywords == ["test"]
assert photo.location == (34.052235, -118.243683)
result = CliRunner().invoke(
batch_edit,
["--undo"],
)
assert result.exit_code == 0
photo = osxphotos.PhotosDB().get_photo(TEST_DATA_BATCH_EDIT["uuid"])
assert photo.title == "Pumpkin Farm"
assert photo.description == "Pumpkin Farm"
assert photo.keywords == ["kids"]
assert photo.location == (41.256566, -95.940257)