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
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.
@@ -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`
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:
`pipx install osxphotos`
@@ -90,6 +90,7 @@ Commands:
help Print help; for help on commands: help <command>.
info Print out descriptive info of the Photos library database.
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.
persons Print out persons (faces) found in the Photos library.
places Print out places found in the Photos library.
@@ -125,13 +126,13 @@ Options:
-V, --verbose Print verbose output.
--keyword KEYWORD Search for photos with keyword KEYWORD. If
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
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
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. If more than one folder, treated as
"OR", e.g. find photos in any FOLDER. Only
@@ -146,11 +147,15 @@ Options:
geolocation info
--no-place Search for photos with no associated place
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
identifier (UTI) matches UTI
-i, --ignore-case Case insensitive search for title,
description, or place. Does not apply to
keyword, person, or album.
description, place, keyword, person, or
album.
--edited Search for photos that have been edited.
--external-edit Search for photos edited in external editor.
--favorite Search for photos marked favorite.

View File

@@ -245,7 +245,7 @@ def query_options(f):
default=None,
multiple=True,
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(
"--person",
@@ -253,7 +253,7 @@ def query_options(f):
default=None,
multiple=True,
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(
"--album",
@@ -261,7 +261,7 @@ def query_options(f):
default=None,
multiple=True,
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(
"--folder",
@@ -311,6 +311,13 @@ def query_options(f):
is_flag=True,
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(
"--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):
""" 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:
click.echo(cli.commands["keywords"].get_help(ctx), 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):
""" 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:
click.echo(cli.commands["albums"].get_help(ctx), 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):
""" 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:
click.echo(cli.commands["persons"].get_help(ctx), 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))
@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()
@DB_OPTION
@JSON_OPTION
@@ -861,6 +900,7 @@ def query(
has_raw,
place,
no_place,
label,
):
""" Query the Photos database using 1 or more search options;
if more than one option is provided, they are treated as "AND"
@@ -881,6 +921,7 @@ def query(
has_raw,
from_date,
to_date,
label,
]
exclusive = [
(favorite, not_favorite),
@@ -976,6 +1017,7 @@ def query(
has_raw=has_raw,
place=place,
no_place=no_place,
label=label,
)
# below needed for to make CliRunner work for testing
@@ -1209,6 +1251,7 @@ def export(
place,
no_place,
no_extended_attributes,
label,
):
""" Export photos from the Photos database.
Export path DEST is required.
@@ -1348,6 +1391,7 @@ def export(
has_raw=has_raw,
place=place,
no_place=no_place,
label=label,
)
results_exported = []
@@ -1636,6 +1680,7 @@ def _query(
has_raw=None,
place=None,
no_place=None,
label=None,
):
""" run a query against PhotosDB to extract the photos based on user supply criteria
used by query and export commands
@@ -1648,50 +1693,16 @@ def _query(
)
if album:
photos_album = []
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
photos = get_photos_by_attribute(photos, "albums", album, ignore_case)
if keyword:
photos_keyword = []
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
photos = get_photos_by_attribute(photos, "keywords", keyword, ignore_case)
if person:
photos_person = []
if ignore_case:
# case-insensitive
for prsn in person:
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
photos = get_photos_by_attribute(photos, "persons", person, ignore_case)
if label:
photos = get_photos_by_attribute(photos, "labels", label, ignore_case)
if folder:
# search for photos in an album in folder
@@ -1867,6 +1878,34 @@ def _query(
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(
photo=None,
dest=None,

View File

@@ -1,3 +1,3 @@
""" 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>.",
" info Print out descriptive info of the Photos library database.",
" 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.",
" persons Print out persons (faces) found in the Photos library.",
" places Print out places found in the Photos library.",
@@ -210,6 +211,62 @@ CLI_EXIFTOOL = {
"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
try:
exiftool = get_exiftool_path()
@@ -224,9 +281,10 @@ def test_osxphotos():
runner = CliRunner()
result = runner.invoke(cli, [])
output = result.output
assert result.exit_code == 0
for line in CLI_OUTPUT_NO_SUBCOMMAND:
assert line in output
assert line.strip() in output
def test_osxphotos_help_1():
@@ -239,7 +297,7 @@ def test_osxphotos_help_1():
output = result.output
assert result.exit_code == 0
for line in CLI_OUTPUT_NO_SUBCOMMAND:
assert line in output
assert line.strip() in output
def test_osxphotos_help_2():
@@ -804,6 +862,97 @@ def test_query_album_4():
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():
import glob
import os
@@ -1904,3 +2053,79 @@ def test_export_directory_template_1_dry_run():
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1:
assert f"Exported {filepath}" in result.output
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