From debb17c9520bec25d725426feaa512745e9d4ec0 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sat, 25 Dec 2021 05:41:37 -0800 Subject: [PATCH] Implement #323 --- osxphotos/cli.py | 33 ++++++++------ osxphotos/photosdb/photosdb.py | 29 +++++++++++++ osxphotos/queryoptions.py | 1 + tests/test_cli.py | 79 +++++++++++++++++++++++++++++++--- 4 files changed, 124 insertions(+), 18 deletions(-) diff --git a/osxphotos/cli.py b/osxphotos/cli.py index 42f51534..a7557987 100644 --- a/osxphotos/cli.py +++ b/osxphotos/cli.py @@ -543,6 +543,17 @@ def QUERY_OPTIONS(f): is_flag=True, help="Filter for photos that are currently selected in Photos.", ), + o( + "--exif", + metavar="EXIF_TAG VALUE", + nargs=2, + multiple=True, + help="Search for photos where EXIF_TAG exists in photo's EXIF data and contains VALUE. " + "For example, to find photos created by Adobe Photoshop: `--exif Software 'Adobe Photoshop' `" + "or to find all photos shot on a Canon camera: `--exif Make Canon`. " + "EXIF_TAG can be any valid exiftool tag, with or without group name, e.g. `EXIF:Make` or `Make`. " + "To use --exif, exiftool must be installed and in the path.", + ), o( "--query-eval", metavar="CRITERIA", @@ -1190,6 +1201,7 @@ def export( max_size, regex, selected, + exif, query_eval, query_function, duplicate, @@ -1354,6 +1366,7 @@ def export( max_size = cfg.max_size regex = cfg.regex selected = cfg.selected + exif = cfg.exif query_eval = cfg.query_eval query_function = cfg.query_function duplicate = cfg.duplicate @@ -1673,6 +1686,7 @@ def export( max_size=max_size, regex=regex, selected=selected, + exif=exif, query_eval=query_eval, function=query_function, duplicate=duplicate, @@ -2085,6 +2099,7 @@ def query( max_size, regex, selected, + exif, query_eval, query_function, add_to_album, @@ -2120,6 +2135,7 @@ def query( max_size, regex, selected, + exif, duplicate, ] exclusive = [ @@ -2251,6 +2267,7 @@ def query( function=query_function, regex=regex, selected=selected, + exif=exif, duplicate=duplicate, ) @@ -2790,8 +2807,7 @@ def _render_suffix_template( rendered_suffix, unmatched = photo.render_template(suffix_template, options) except ValueError as e: raise click.BadOptionUsage( - var_name, - f"Invalid template for {option_name} '{suffix_template}': {e}", + var_name, f"Invalid template for {option_name} '{suffix_template}': {e}", ) if not rendered_suffix or unmatched: raise click.BadOptionUsage( @@ -3481,12 +3497,7 @@ def write_finder_tags( def write_extended_attributes( - photo, - files, - xattr_template, - strip=False, - export_dir=None, - export_db=None, + photo, files, xattr_template, strip=False, export_dir=None, export_db=None, ): """Writes extended attributes to exported files @@ -3606,7 +3617,7 @@ def run_post_command( @cli.command() @click.argument("packages", nargs=-1, required=True) @click.option( - "-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version" + "-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version" ) def install(packages, upgrade): """Install Python packages into the same environment as osxphotos""" @@ -4072,9 +4083,7 @@ OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMA @cli.command(name="tutorial") @click.argument( - "WIDTH", - nargs=-1, - type=click.INT, + "WIDTH", nargs=-1, type=click.INT, ) @click.pass_obj @click.pass_context diff --git a/osxphotos/photosdb/photosdb.py b/osxphotos/photosdb/photosdb.py index 551c7c84..fd63204f 100644 --- a/osxphotos/photosdb/photosdb.py +++ b/osxphotos/photosdb/photosdb.py @@ -12,6 +12,7 @@ import re import sys import tempfile from collections import OrderedDict +from collections.abc import Iterable from datetime import datetime, timedelta, timezone from pprint import pformat from typing import List @@ -3470,6 +3471,34 @@ class PhotosDB: # selection only works if photos selected in main media browser photos = [] + if options.exif: + matching_photos = [] + for p in photos: + if not p.exiftool: + continue + exifdata = p.exiftool.asdict(normalized=True) + exifdata.update(p.exiftool.asdict(tag_groups=False, normalized=True)) + for exiftag, exifvalue in options.exif: + if options.ignore_case: + exifvalue = exifvalue.lower() + exifdata_value = exifdata.get(exiftag.lower(), "") + if isinstance(exifdata_value, str): + exifdata_value = exifdata_value.lower() + elif isinstance(exifdata_value, Iterable): + exifdata_value = [v.lower() for v in exifdata_value] + else: + exifdata_value = str(exifdata_value) + + if exifvalue in exifdata_value: + matching_photos.append(p) + else: + exifdata_value = exifdata.get(exiftag.lower(), "") + if not isinstance(exifdata_value, (str, Iterable)): + exifdata_value = str(exifdata_value) + if exifvalue in exifdata_value: + matching_photos.append(p) + photos = matching_photos + if options.function: for function in options.function: photos = function[0](photos) diff --git a/osxphotos/queryoptions.py b/osxphotos/queryoptions.py index 23c27505..d2e0a40e 100644 --- a/osxphotos/queryoptions.py +++ b/osxphotos/queryoptions.py @@ -84,6 +84,7 @@ class QueryOptions: no_location: Optional[bool] = None function: Optional[List[Tuple[callable, str]]] = None selected: Optional[bool] = None + exif: Optional[Iterable[Tuple[str, str]]] = None def asdict(self): return asdict(self) diff --git a/tests/test_cli.py b/tests/test_cli.py index d5199938..878ebb09 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -886,6 +886,11 @@ EXPORT_UNICODE_TITLE_FILENAMES = [ "Frítest (3).jpg", ] +QUERY_EXIF_DATA = [("EXIF:Make", "FUJIFILM", ["6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"])] +QUERY_EXIF_DATA_CASE_INSENSITIVE = [ + ("Make", "Fujifilm", ["6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"]) +] + def modify_file(filename): """appends data to a file to modify it""" @@ -1251,8 +1256,7 @@ def test_query_duplicate(): runner = CliRunner() cwd = os.getcwd() result = runner.invoke( - query, - ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--duplicate"], + query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--duplicate"], ) assert result.exit_code == 0 @@ -1273,8 +1277,7 @@ def test_query_location(): runner = CliRunner() cwd = os.getcwd() result = runner.invoke( - query, - ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--location"], + query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--location"], ) assert result.exit_code == 0 @@ -1296,8 +1299,7 @@ def test_query_no_location(): runner = CliRunner() cwd = os.getcwd() result = runner.invoke( - query, - ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--no-location"], + query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--no-location"], ) assert result.exit_code == 0 @@ -1308,6 +1310,71 @@ def test_query_no_location(): assert UUID_LOCATION not in uuid_got +@pytest.mark.skipif(exiftool is None, reason="exiftool not installed") +@pytest.mark.parametrize("exiftag,exifvalue,uuid_expected", QUERY_EXIF_DATA) +def test_query_exif(exiftag, exifvalue, uuid_expected): + """Test query with --exif""" + import json + import os + import os.path + + from osxphotos.cli import query + + runner = CliRunner() + cwd = os.getcwd() + result = runner.invoke( + query, + [ + "--json", + "--db", + os.path.join(cwd, CLI_PHOTOS_DB), + "--exif", + exiftag, + exifvalue, + ], + ) + assert result.exit_code == 0 + + # build list of uuids we got from the output JSON + json_got = json.loads(result.output) + uuid_got = [photo["uuid"] for photo in json_got] + assert sorted(uuid_got) == sorted(uuid_expected) + + +@pytest.mark.skipif(exiftool is None, reason="exiftool not installed") +@pytest.mark.parametrize( + "exiftag,exifvalue,uuid_expected", QUERY_EXIF_DATA_CASE_INSENSITIVE +) +def test_query_exif_case_insensitive(exiftag, exifvalue, uuid_expected): + """Test query with --exif -i""" + import json + import os + import os.path + + from osxphotos.cli import query + + runner = CliRunner() + cwd = os.getcwd() + result = runner.invoke( + query, + [ + "--json", + "--db", + os.path.join(cwd, CLI_PHOTOS_DB), + "--exif", + exiftag, + exifvalue, + "-i", + ], + ) + assert result.exit_code == 0 + + # build list of uuids we got from the output JSON + json_got = json.loads(result.output) + uuid_got = [photo["uuid"] for photo in json_got] + assert sorted(uuid_got) == sorted(uuid_expected) + + def test_export(): import glob import os