Implement #323
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user