diff --git a/README.md b/README.md index 090c54e5..0c0b5415 100644 --- a/README.md +++ b/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 . 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. diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 6d1edcd1..0681487c 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -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, diff --git a/osxphotos/_version.py b/osxphotos/_version.py index ce7c4b2e..3576b712 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.29.17" +__version__ = "0.29.18" diff --git a/tests/test_cli.py b/tests/test_cli.py index 4b65a2aa..52f54329 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,6 +30,7 @@ CLI_OUTPUT_NO_SUBCOMMAND = [ " help Print help; for help on commands: help .", " 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