Added --label to CLI, closes #157
This commit is contained in:
19
README.md
19
README.md
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.29.17"
|
||||
__version__ = "0.29.18"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user