This commit is contained in:
Rhet Turnbull
2021-12-25 05:41:37 -08:00
parent 7819740f70
commit debb17c952
4 changed files with 124 additions and 18 deletions

View File

@@ -543,6 +543,17 @@ def QUERY_OPTIONS(f):
is_flag=True, is_flag=True,
help="Filter for photos that are currently selected in Photos.", 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( o(
"--query-eval", "--query-eval",
metavar="CRITERIA", metavar="CRITERIA",
@@ -1190,6 +1201,7 @@ def export(
max_size, max_size,
regex, regex,
selected, selected,
exif,
query_eval, query_eval,
query_function, query_function,
duplicate, duplicate,
@@ -1354,6 +1366,7 @@ def export(
max_size = cfg.max_size max_size = cfg.max_size
regex = cfg.regex regex = cfg.regex
selected = cfg.selected selected = cfg.selected
exif = cfg.exif
query_eval = cfg.query_eval query_eval = cfg.query_eval
query_function = cfg.query_function query_function = cfg.query_function
duplicate = cfg.duplicate duplicate = cfg.duplicate
@@ -1673,6 +1686,7 @@ def export(
max_size=max_size, max_size=max_size,
regex=regex, regex=regex,
selected=selected, selected=selected,
exif=exif,
query_eval=query_eval, query_eval=query_eval,
function=query_function, function=query_function,
duplicate=duplicate, duplicate=duplicate,
@@ -2085,6 +2099,7 @@ def query(
max_size, max_size,
regex, regex,
selected, selected,
exif,
query_eval, query_eval,
query_function, query_function,
add_to_album, add_to_album,
@@ -2120,6 +2135,7 @@ def query(
max_size, max_size,
regex, regex,
selected, selected,
exif,
duplicate, duplicate,
] ]
exclusive = [ exclusive = [
@@ -2251,6 +2267,7 @@ def query(
function=query_function, function=query_function,
regex=regex, regex=regex,
selected=selected, selected=selected,
exif=exif,
duplicate=duplicate, duplicate=duplicate,
) )
@@ -2790,8 +2807,7 @@ def _render_suffix_template(
rendered_suffix, unmatched = photo.render_template(suffix_template, options) rendered_suffix, unmatched = photo.render_template(suffix_template, options)
except ValueError as e: except ValueError as e:
raise click.BadOptionUsage( raise click.BadOptionUsage(
var_name, var_name, f"Invalid template for {option_name} '{suffix_template}': {e}",
f"Invalid template for {option_name} '{suffix_template}': {e}",
) )
if not rendered_suffix or unmatched: if not rendered_suffix or unmatched:
raise click.BadOptionUsage( raise click.BadOptionUsage(
@@ -3481,12 +3497,7 @@ def write_finder_tags(
def write_extended_attributes( def write_extended_attributes(
photo, photo, files, xattr_template, strip=False, export_dir=None, export_db=None,
files,
xattr_template,
strip=False,
export_dir=None,
export_db=None,
): ):
"""Writes extended attributes to exported files """Writes extended attributes to exported files
@@ -3606,7 +3617,7 @@ def run_post_command(
@cli.command() @cli.command()
@click.argument("packages", nargs=-1, required=True) @click.argument("packages", nargs=-1, required=True)
@click.option( @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): def install(packages, upgrade):
"""Install Python packages into the same environment as osxphotos""" """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") @cli.command(name="tutorial")
@click.argument( @click.argument(
"WIDTH", "WIDTH", nargs=-1, type=click.INT,
nargs=-1,
type=click.INT,
) )
@click.pass_obj @click.pass_obj
@click.pass_context @click.pass_context

View File

@@ -12,6 +12,7 @@ import re
import sys import sys
import tempfile import tempfile
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pprint import pformat from pprint import pformat
from typing import List from typing import List
@@ -3470,6 +3471,34 @@ class PhotosDB:
# selection only works if photos selected in main media browser # selection only works if photos selected in main media browser
photos = [] 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: if options.function:
for function in options.function: for function in options.function:
photos = function[0](photos) photos = function[0](photos)

View File

@@ -84,6 +84,7 @@ class QueryOptions:
no_location: Optional[bool] = None no_location: Optional[bool] = None
function: Optional[List[Tuple[callable, str]]] = None function: Optional[List[Tuple[callable, str]]] = None
selected: Optional[bool] = None selected: Optional[bool] = None
exif: Optional[Iterable[Tuple[str, str]]] = None
def asdict(self): def asdict(self):
return asdict(self) return asdict(self)

View File

@@ -886,6 +886,11 @@ EXPORT_UNICODE_TITLE_FILENAMES = [
"Frítest (3).jpg", "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): def modify_file(filename):
"""appends data to a file to modify it""" """appends data to a file to modify it"""
@@ -1251,8 +1256,7 @@ def test_query_duplicate():
runner = CliRunner() runner = CliRunner()
cwd = os.getcwd() cwd = os.getcwd()
result = runner.invoke( result = runner.invoke(
query, query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--duplicate"],
["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--duplicate"],
) )
assert result.exit_code == 0 assert result.exit_code == 0
@@ -1273,8 +1277,7 @@ def test_query_location():
runner = CliRunner() runner = CliRunner()
cwd = os.getcwd() cwd = os.getcwd()
result = runner.invoke( result = runner.invoke(
query, query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--location"],
["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--location"],
) )
assert result.exit_code == 0 assert result.exit_code == 0
@@ -1296,8 +1299,7 @@ def test_query_no_location():
runner = CliRunner() runner = CliRunner()
cwd = os.getcwd() cwd = os.getcwd()
result = runner.invoke( result = runner.invoke(
query, query, ["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--no-location"],
["--json", "--db", os.path.join(cwd, CLI_PHOTOS_DB), "--no-location"],
) )
assert result.exit_code == 0 assert result.exit_code == 0
@@ -1308,6 +1310,71 @@ def test_query_no_location():
assert UUID_LOCATION not in uuid_got 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(): def test_export():
import glob import glob
import os import os