Added --label to CLI, closes #157

This commit is contained in:
Rhet Turnbull
2020-06-13 19:40:46 -07:00
parent f39a92a352
commit d9802247d9
4 changed files with 325 additions and 56 deletions

View File

@@ -34,7 +34,7 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
## Supported operating systems ## Supported operating systems
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 & 10.15.4 / Photos 5.0. Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 - 10.15.5 / Photos 5.0.
Requires python >= 3.8. You can probably get this to run with Python 3.6 or 3.7 (see notes [below](#Installation-instructions)) but only 3.8+ is officially supported. Requires python >= 3.8. You can probably get this to run with Python 3.6 or 3.7 (see notes [below](#Installation-instructions)) but only 3.8+ is officially supported.
@@ -59,7 +59,7 @@ You can also install directly from [pypi](https://pypi.org/) but you must use py
This package will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos` This package will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos`
If you only care about the command line tool, I recommend installing with [pipx](https://github.com/pipxproject/pipx) If you only care about the command line tool, you can download an executable of the latest [release](https://github.com/RhetTbull/osxphotos/releases). Alternatively, I recommend installing with [pipx](https://github.com/pipxproject/pipx)
After installing pipx: After installing pipx:
`pipx install osxphotos` `pipx install osxphotos`
@@ -90,6 +90,7 @@ Commands:
help Print help; for help on commands: help <command>. help Print help; for help on commands: help <command>.
info Print out descriptive info of the Photos library database. info Print out descriptive info of the Photos library database.
keywords Print out keywords found in the Photos library. keywords Print out keywords found in the Photos library.
labels Print out image classification labels found in the Photos...
list Print list of Photos libraries found on the system. list Print list of Photos libraries found on the system.
persons Print out persons (faces) found in the Photos library. persons Print out persons (faces) found in the Photos library.
places Print out places found in the Photos library. places Print out places found in the Photos library.
@@ -125,13 +126,13 @@ Options:
-V, --verbose Print verbose output. -V, --verbose Print verbose output.
--keyword KEYWORD Search for photos with keyword KEYWORD. If --keyword KEYWORD Search for photos with keyword KEYWORD. If
more than one keyword, treated as "OR", e.g. more than one keyword, treated as "OR", e.g.
find photos match any keyword find photos matching any keyword
--person PERSON Search for photos with person PERSON. If --person PERSON Search for photos with person PERSON. If
more than one person, treated as "OR", e.g. more than one person, treated as "OR", e.g.
find photos match any person find photos matching any person
--album ALBUM Search for photos in album ALBUM. If more --album ALBUM Search for photos in album ALBUM. If more
than one album, treated as "OR", e.g. find than one album, treated as "OR", e.g. find
photos match any album photos matching any album
--folder FOLDER Search for photos in an album in folder --folder FOLDER Search for photos in an album in folder
FOLDER. If more than one folder, treated as FOLDER. If more than one folder, treated as
"OR", e.g. find photos in any FOLDER. Only "OR", e.g. find photos in any FOLDER. Only
@@ -146,11 +147,15 @@ Options:
geolocation info geolocation info
--no-place Search for photos with no associated place --no-place Search for photos with no associated place
name info (no reverse geolocation info) name info (no reverse geolocation info)
--label LABEL Search for photos with image classification
label LABEL (Photos 5 only). If more than
one label, treated as "OR", e.g. find photos
matching any label
--uti UTI Search for photos whose uniform type --uti UTI Search for photos whose uniform type
identifier (UTI) matches UTI identifier (UTI) matches UTI
-i, --ignore-case Case insensitive search for title, -i, --ignore-case Case insensitive search for title,
description, or place. Does not apply to description, place, keyword, person, or
keyword, person, or album. album.
--edited Search for photos that have been edited. --edited Search for photos that have been edited.
--external-edit Search for photos edited in external editor. --external-edit Search for photos edited in external editor.
--favorite Search for photos marked favorite. --favorite Search for photos marked favorite.

View File

@@ -245,7 +245,7 @@ def query_options(f):
default=None, default=None,
multiple=True, multiple=True,
help="Search for photos with keyword KEYWORD. " help="Search for photos with keyword KEYWORD. "
'If more than one keyword, treated as "OR", e.g. find photos match any keyword', 'If more than one keyword, treated as "OR", e.g. find photos matching any keyword',
), ),
o( o(
"--person", "--person",
@@ -253,7 +253,7 @@ def query_options(f):
default=None, default=None,
multiple=True, multiple=True,
help="Search for photos with person PERSON. " help="Search for photos with person PERSON. "
'If more than one person, treated as "OR", e.g. find photos match any person', 'If more than one person, treated as "OR", e.g. find photos matching any person',
), ),
o( o(
"--album", "--album",
@@ -261,7 +261,7 @@ def query_options(f):
default=None, default=None,
multiple=True, multiple=True,
help="Search for photos in album ALBUM. " help="Search for photos in album ALBUM. "
'If more than one album, treated as "OR", e.g. find photos match any album', 'If more than one album, treated as "OR", e.g. find photos matching any album',
), ),
o( o(
"--folder", "--folder",
@@ -311,6 +311,13 @@ def query_options(f):
is_flag=True, is_flag=True,
help="Search for photos with no associated place name info (no reverse geolocation info)", help="Search for photos with no associated place name info (no reverse geolocation info)",
), ),
o(
"--label",
metavar="LABEL",
multiple=True,
help="Search for photos with image classification label LABEL (Photos 5 only). "
'If more than one label, treated as "OR", e.g. find photos matching any label',
),
o( o(
"--uti", "--uti",
metavar="UTI", metavar="UTI",
@@ -527,7 +534,9 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid):
def keywords(ctx, cli_obj, db, json_, photos_library): def keywords(ctx, cli_obj, db, json_, photos_library):
""" Print out keywords found in the Photos library. """ """ Print out keywords found in the Photos library. """
db = get_photos_db(*photos_library, db, cli_obj.db) # below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
if db is None: if db is None:
click.echo(cli.commands["keywords"].get_help(ctx), err=True) click.echo(cli.commands["keywords"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True) click.echo("\n\nLocated the following Photos library databases: ", err=True)
@@ -551,7 +560,9 @@ def keywords(ctx, cli_obj, db, json_, photos_library):
def albums(ctx, cli_obj, db, json_, photos_library): def albums(ctx, cli_obj, db, json_, photos_library):
""" Print out albums found in the Photos library. """ """ Print out albums found in the Photos library. """
db = get_photos_db(*photos_library, db, cli_obj.db) # below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
if db is None: if db is None:
click.echo(cli.commands["albums"].get_help(ctx), err=True) click.echo(cli.commands["albums"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True) click.echo("\n\nLocated the following Photos library databases: ", err=True)
@@ -578,7 +589,9 @@ def albums(ctx, cli_obj, db, json_, photos_library):
def persons(ctx, cli_obj, db, json_, photos_library): def persons(ctx, cli_obj, db, json_, photos_library):
""" Print out persons (faces) found in the Photos library. """ """ Print out persons (faces) found in the Photos library. """
db = get_photos_db(*photos_library, db, cli_obj.db) # below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
if db is None: if db is None:
click.echo(cli.commands["persons"].get_help(ctx), err=True) click.echo(cli.commands["persons"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True) click.echo("\n\nLocated the following Photos library databases: ", err=True)
@@ -593,6 +606,32 @@ def persons(ctx, cli_obj, db, json_, photos_library):
click.echo(yaml.dump(persons, sort_keys=False)) click.echo(yaml.dump(persons, sort_keys=False))
@cli.command()
@DB_OPTION
@JSON_OPTION
@DB_ARGUMENT
@click.pass_obj
@click.pass_context
def labels(ctx, cli_obj, db, json_, photos_library):
""" Print out image classification labels found in the Photos library. """
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
db = get_photos_db(*photos_library, db, cli_db)
if db is None:
click.echo(cli.commands["labels"].get_help(ctx), err=True)
click.echo("\n\nLocated the following Photos library databases: ", err=True)
_list_libraries()
return
photosdb = osxphotos.PhotosDB(dbfile=db)
labels = {"labels": photosdb.labels_as_dict}
if json_ or cli_obj.json:
click.echo(json.dumps(labels))
else:
click.echo(yaml.dump(labels, sort_keys=False))
@cli.command() @cli.command()
@DB_OPTION @DB_OPTION
@JSON_OPTION @JSON_OPTION
@@ -861,6 +900,7 @@ def query(
has_raw, has_raw,
place, place,
no_place, no_place,
label,
): ):
""" Query the Photos database using 1 or more search options; """ Query the Photos database using 1 or more search options;
if more than one option is provided, they are treated as "AND" if more than one option is provided, they are treated as "AND"
@@ -881,6 +921,7 @@ def query(
has_raw, has_raw,
from_date, from_date,
to_date, to_date,
label,
] ]
exclusive = [ exclusive = [
(favorite, not_favorite), (favorite, not_favorite),
@@ -976,6 +1017,7 @@ def query(
has_raw=has_raw, has_raw=has_raw,
place=place, place=place,
no_place=no_place, no_place=no_place,
label=label,
) )
# below needed for to make CliRunner work for testing # below needed for to make CliRunner work for testing
@@ -1209,6 +1251,7 @@ def export(
place, place,
no_place, no_place,
no_extended_attributes, no_extended_attributes,
label,
): ):
""" Export photos from the Photos database. """ Export photos from the Photos database.
Export path DEST is required. Export path DEST is required.
@@ -1348,6 +1391,7 @@ def export(
has_raw=has_raw, has_raw=has_raw,
place=place, place=place,
no_place=no_place, no_place=no_place,
label=label,
) )
results_exported = [] results_exported = []
@@ -1636,6 +1680,7 @@ def _query(
has_raw=None, has_raw=None,
place=None, place=None,
no_place=None, no_place=None,
label=None,
): ):
""" run a query against PhotosDB to extract the photos based on user supply criteria """ run a query against PhotosDB to extract the photos based on user supply criteria
used by query and export commands used by query and export commands
@@ -1648,50 +1693,16 @@ def _query(
) )
if album: if album:
photos_album = [] photos = get_photos_by_attribute(photos, "albums", album, ignore_case)
if ignore_case:
# case-insensitive
for a in album:
a = a.lower()
photos_album.extend(
p for p in photos if a in [album.lower() for album in p.albums]
)
else:
for a in album:
photos_album.extend(p for p in photos if a in p.albums)
photos = photos_album
if keyword: if keyword:
photos_keyword = [] photos = get_photos_by_attribute(photos, "keywords", keyword, ignore_case)
if ignore_case:
# case-insensitive
for k in keyword:
k = k.lower()
photos_keyword.extend(
p
for p in photos
if k in [keyword.lower() for keyword in p.keywords]
)
else:
for k in keyword:
photos_keyword.extend(p for p in photos if k in p.keywords)
photos = photos_keyword
if person: if person:
photos_person = [] photos = get_photos_by_attribute(photos, "persons", person, ignore_case)
if ignore_case:
# case-insensitive if label:
for prsn in person: photos = get_photos_by_attribute(photos, "labels", label, ignore_case)
prsn = prsn.lower()
photos_person.extend(
p
for p in photos
if prsn in [person_.lower() for person_ in p.persons]
)
else:
for prsn in person:
photos_person.extend(p for p in photos if prsn in p.persons)
photos = photos_person
if folder: if folder:
# search for photos in an album in folder # search for photos in an album in folder
@@ -1867,6 +1878,34 @@ def _query(
return photos return photos
def get_photos_by_attribute(photos, attribute, values, ignore_case):
"""Search for photos based on values being in PhotoInfo.attribute
Args:
photos: a list of PhotoInfo objects
attribute: str, name of PhotoInfo attribute to search (e.g. keywords, persons, etc)
values: list of values to search in property
ignore_case: ignore case when searching
Returns:
list of PhotoInfo objects matching search criteria
"""
photos_search = []
if ignore_case:
# case-insensitive
for x in values:
x = x.lower()
photos_search.extend(
p
for p in photos
if x in [attr.lower() for attr in getattr(p, attribute)]
)
else:
for x in values:
photos_search.extend(p for p in photos if x in getattr(p, attribute))
return photos_search
def export_photo( def export_photo(
photo=None, photo=None,
dest=None, dest=None,

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.29.17" __version__ = "0.29.18"

View File

@@ -30,6 +30,7 @@ CLI_OUTPUT_NO_SUBCOMMAND = [
" help Print help; for help on commands: help <command>.", " help Print help; for help on commands: help <command>.",
" info Print out descriptive info of the Photos library database.", " info Print out descriptive info of the Photos library database.",
" keywords Print out keywords found in the Photos library.", " keywords Print out keywords found in the Photos library.",
" labels Print out image classification labels found in the Photos",
" list Print list of Photos libraries found on the system.", " list Print list of Photos libraries found on the system.",
" persons Print out persons (faces) found in the Photos library.", " persons Print out persons (faces) found in the Photos library.",
" places Print out places found in the Photos library.", " places Print out places found in the Photos library.",
@@ -210,6 +211,62 @@ CLI_EXIFTOOL = {
"XMP:Subject": ["Kids", "Katie"], "XMP:Subject": ["Kids", "Katie"],
} }
} }
LABELS_JSON = {
"labels": {
"Plant": 5,
"Tree": 2,
"Sky": 2,
"Outdoor": 2,
"Art": 2,
"Foliage": 2,
"Waterways": 1,
"River": 1,
"Cloudy": 1,
"Land": 1,
"Water Body": 1,
"Water": 1,
"Statue": 1,
"Window": 1,
"Decorative Plant": 1,
"Blue Sky": 1,
"Palm Tree": 1,
"Flower": 1,
"Flower Arrangement": 1,
"Bouquet": 1,
"Vase": 1,
"Container": 1,
"Camera": 1,
}
}
KEYWORDS_JSON = {
"keywords": {
"Kids": 4,
"wedding": 2,
"London 2018": 1,
"St. James's Park": 1,
"England": 1,
"United Kingdom": 1,
"UK": 1,
"London": 1,
"flowers": 1,
}
}
ALBUMS_JSON = {
"albums": {
"Raw": 4,
"Pumpkin Farm": 3,
"AlbumInFolder": 2,
"Test Album": 2,
"I have a deleted twin": 1,
},
"shared albums": {},
}
PERSONS_JSON = {"persons": {"Katie": 3, "Suzy": 2, "_UNKNOWN_": 1, "Maria": 1}}
# determine if exiftool installed so exiftool tests can be skipped # determine if exiftool installed so exiftool tests can be skipped
try: try:
exiftool = get_exiftool_path() exiftool = get_exiftool_path()
@@ -224,9 +281,10 @@ def test_osxphotos():
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, []) result = runner.invoke(cli, [])
output = result.output output = result.output
assert result.exit_code == 0 assert result.exit_code == 0
for line in CLI_OUTPUT_NO_SUBCOMMAND: for line in CLI_OUTPUT_NO_SUBCOMMAND:
assert line in output assert line.strip() in output
def test_osxphotos_help_1(): def test_osxphotos_help_1():
@@ -239,7 +297,7 @@ def test_osxphotos_help_1():
output = result.output output = result.output
assert result.exit_code == 0 assert result.exit_code == 0
for line in CLI_OUTPUT_NO_SUBCOMMAND: for line in CLI_OUTPUT_NO_SUBCOMMAND:
assert line in output assert line.strip() in output
def test_osxphotos_help_2(): def test_osxphotos_help_2():
@@ -804,6 +862,97 @@ def test_query_album_4():
assert len(json_got) == 7 assert len(json_got) == 7
def test_query_label_1():
"""Test query --label"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--label", "Statue"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 1
def test_query_label_2():
"""Test query --label with lower case label """
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--label", "statue"],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 0
def test_query_label_3():
"""Test query --label with lower case label and --ignore-case"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--label",
"statue",
"--ignore-case",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 1
def test_query_label_4():
"""Test query with more than one --label"""
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import query
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
query,
[
"--json",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
"--label",
"Statue",
"--label",
"Plant",
],
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert len(json_got) == 6
def test_export_sidecar(): def test_export_sidecar():
import glob import glob
import os import os
@@ -1904,3 +2053,79 @@ def test_export_directory_template_1_dry_run():
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1: for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1:
assert f"Exported {filepath}" in result.output assert f"Exported {filepath}" in result.output
assert not os.path.isfile(os.path.join(workdir, filepath)) assert not os.path.isfile(os.path.join(workdir, filepath))
def test_labels():
"""Test osxphotos labels """
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import labels
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
labels, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert json_got == LABELS_JSON
def test_keywords():
"""Test osxphotos keywords """
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import keywords
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
keywords, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert json_got == KEYWORDS_JSON
def test_albums():
"""Test osxphotos albums """
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import albums
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
albums, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert json_got == ALBUMS_JSON
def test_persons():
"""Test osxphotos albums """
import json
import osxphotos
import os
import os.path
from osxphotos.__main__ import persons
runner = CliRunner()
cwd = os.getcwd()
result = runner.invoke(
persons, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
)
assert result.exit_code == 0
json_got = json.loads(result.output)
assert json_got == PERSONS_JSON