Compare commits

..

10 Commits

Author SHA1 Message Date
Rhet Turnbull
f469cccc4b Updated README.md 2020-12-26 08:12:43 -08:00
Rhet Turnbull
4ece5c0d1c Exposed SearchInfo, closes #121 2020-12-26 08:08:18 -08:00
Rhet Turnbull
9ca5d8f0fd Added version to --verbose, closes #297 2020-12-22 21:05:40 -08:00
Rhet Turnbull
2a49255277 Added --exportdb 2020-12-22 20:42:48 -08:00
Rhet Turnbull
f3b7134af1 Fixed help text 2020-12-21 07:40:42 -08:00
Rhet Turnbull
73716f12cd Updated CHANGELOG.md 2020-12-21 07:35:21 -08:00
Rhet Turnbull
a4bbb6492d Added --exiftool-option to CLI, closes #298 2020-12-21 07:32:38 -08:00
Rhet Turnbull
aca19f4063 Updated CHANGELOG.md 2020-12-20 22:16:06 -08:00
Rhet Turnbull
2ebd4c33ff remove duplicate keywords with --exiftool and --sidecar, closes #294 2020-12-20 22:11:50 -08:00
Rhet Turnbull
da2f91ffc7 Updated CHANGELOG.md 2020-12-20 20:44:38 -08:00
32 changed files with 811 additions and 75 deletions

View File

@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.38.9](https://github.com/RhetTbull/osxphotos/compare/v0.38.8...v0.38.9)
> 21 December 2020
- Added --exiftool-option to CLI, closes #298 [`#298`](https://github.com/RhetTbull/osxphotos/issues/298)
#### [v0.38.8](https://github.com/RhetTbull/osxphotos/compare/v0.38.7...v0.38.8)
> 21 December 2020
- remove duplicate keywords with --exiftool and --sidecar, closes #294 [`#294`](https://github.com/RhetTbull/osxphotos/issues/294)
#### [v0.38.7](https://github.com/RhetTbull/osxphotos/compare/v0.38.6...v0.38.7)
> 21 December 2020
- Added better exiftool error handling, closes #300 [`#300`](https://github.com/RhetTbull/osxphotos/issues/300)
- README.md updates for tested versions [`8d1ccda`](https://github.com/RhetTbull/osxphotos/commit/8d1ccda0c897f84342caf612c1070d78bff421f5)
- version bump [`ef94933`](https://github.com/RhetTbull/osxphotos/commit/ef94933dd87b9ad2a516163ca50a36753dacd55a)
#### [v0.38.6](https://github.com/RhetTbull/osxphotos/compare/v0.38.5...v0.38.6)
> 18 December 2020

102
README.md
View File

@@ -21,6 +21,7 @@
+ [FolderInfo](#folderinfo)
+ [PlaceInfo](#placeinfo)
+ [ScoreInfo](#scoreinfo)
+ [SearchInfo](#searchinfo)
+ [PersonInfo](#personinfo)
+ [FaceInfo](#faceinfo)
+ [CommentInfo](#commentinfo)
@@ -342,6 +343,16 @@ Options:
(see also --ignore-date-modified);
QuickTime:GPSCoordinates;
UserData:GPSCoordinates.
--exiftool-option OPTION Optional flag/option to pass to exiftool
when using --exiftool. For example,
--exiftool-option '-m' to ignore minor
warnings. Specify these as you would on the
exiftool command line. See exiftool docs at
https://exiftool.org/exiftool_pod.html for
full list of options. More than one option
may be specified by repeating the option,
e.g. --exiftool-option '-m' --exiftool-
option '-F'.
--ignore-date-modified If used with --exiftool or --sidecar, will
ignore the photo modification date and set
EXIF:ModifyDate to EXIF:DateTimeOriginal;
@@ -419,6 +430,14 @@ Options:
set. For example, photos which had
previously been exported and were
subsequently deleted in Photos.
--exportdb EXPORTDB_FILE Specify alternate name for database file
which stores state information for export
and --update. If --exportdb is not
specified, export database will be saved to
'.osxphotos_export.db' in the export
directory. Must be specified as filename
only, not a path, as export database will be
saved in export directory.
--load-config <config file path>
Load options from file as written with
--save-config. This allows you to save a
@@ -1452,6 +1471,7 @@ Returns image categorization labels associated with the photo as list of str.
#### `labels_normalized`
Returns image categorization labels associated with the photo as list of str. Labels are normalized (e.g. converted to lower case). Use of normalized strings makes it easier to search if you don't how Apple capitalizes a label. For example:
```python
import osxphotos
@@ -1461,12 +1481,23 @@ for photo in photosdb.photos():
print(f"I found a statue! {photo.original_filename}")
```
**Note**: Only valid on Photos 5; on earlier versions, returns empty list. In Photos 5, Photos runs machine learning image categorization against photos in the library and automatically assigns labels to photos such as "People", "Dog", "Water", etc. A photo may have zero or more labels associated with it. See also [labels](#labels).
**Note**: Only valid on Photos 5+; on earlier versions, returns empty list. In Photos 5+, Photos runs machine learning image categorization against photos in the library and automatically assigns labels to photos such as "People", "Dog", "Water", etc. A photo may have zero or more labels associated with it. See also [labels](#labels).
#### <a name="photosearchinfo">`search_info`</a>
Returns [SearchInfo](#searchinfo) object that represents search metadata for the photo.
**Note**: Only valid on Photos 5+; on ealier versions, returns None.
#### <a name="photosearchinfo-normalized">`search_info_normalized`</a>
Returns [SearchInfo](#searchinfo) object that represents normalized search metadata for the photo. This returns a SearchInfo object just as `search_info` but all the properties of the object return normalized text (converted to lowercase).
**Note**: Only valid on Photos 5+; on ealier versions, returns None.
#### `exif_info`
Returns an [ExifInfo](#exifinfo) object with EXIF details from the Photos database. See [ExifInfo](#exifinfo) for additional details.
**Note**: Only valid on Photos 5; on earlier versions, returns `None`. The EXIF details returned are a subset of the actual EXIF data in a typical image. At import Photos stores this subset in the database and it's this stored data that `exif_info` returns.
**Note**: Only valid on Photos 5+; on earlier versions, returns `None`. The EXIF details returned are a subset of the actual EXIF data in a typical image. At import Photos stores this subset in the database and it's this stored data that `exif_info` returns.
See also `exiftool`.
@@ -1854,7 +1885,7 @@ PostalAddress(street='3700 Wailea Alanui Dr', sub_locality=None, city='Kihei', s
'96753'
```
### ScoreInfo
[PhotoInfo.score](#score) returns a ScoreInfo object that exposes the computed aesthetic scores for each photo (**Photos 5 only**). I have not yet reverse engineered the meaning of each score. The `overall` score seems to the most useful and appears to be a composite of the other scores. The following score properties are currently available:
[PhotoInfo.score](#score) returns a ScoreInfo object that exposes the computed aesthetic scores for each photo (**Photos 5+ only**). I have not yet reverse engineered the meaning of each score. The `overall` score seems to the most useful and appears to be a composite of the other scores. The following score properties are currently available:
```python
overall: float
@@ -1893,6 +1924,71 @@ Example: find your "best" photo of food
>>> best_food_photo = sorted([p for p in photos if "food" in p.labels_normalized], key=lambda p: p.score.overall, reverse=True)[0]
```
### SearchInfo
[PhotoInfo.search_info](#photosearchinfo) and [PhotoInfo.search_info_normalized](#photosearchinfo-normalized) return a SearchInfo object that exposes various metadata that Photos uses when searching for photos such as labels, associated holiday, etc. (**Photos 5+ only**).
The following properties are available:
#### `labels`
Returns list of labels applied to photo by Photos image categorization algorithms.
#### `place_names`
Returns list of place names associated with the photo.
#### `streets`
Returns list of street names associated with the photo. (e.g. reverse geolocation of where the photo was taken)
#### `neighborhoods`
Returns list of neighborhood names associated with the photo.
#### `locality_names`
Returns list of locality names associated with the photo.
#### `city`
Returns str of city/town/municipality associated with the photo.
#### `state`
Returns str of state name associated with the photo.
#### `state_abbreviation`
Returns str of state abbreviation associated with the photo.
#### `country`
Returns str of country name associated with the photo.
#### `month`
Returns str of month name associated witht the photo (e.g. month in which the photo was taken)
#### `year`
Returns year associated with the photo.
#### `bodies_of_water`
Returns list of bodies of water associated with the photo.
#### `holidays`
Returns list of holiday names associated with the photo.
#### `activities`
Returns list of activities associated with the photo.
#### `season`
Returns str of season name associated with the photo.
#### `venues`
Returns list of venue names associated with the photo.
#### `venue_types`
Returns list of venue types associated with the photoo.
#### `media_types`
Returns list of media types associated with the photo.
#### `all`
Returns all search_info properties as a single list of strings.
#### `asdict()`
Returns all associated search_info metadata as a dict.
### PersonInfo
[PhotosDB.person_info](#dbpersoninfo) and [PhotoInfo.person_info](#photopersoninfo) return a list of PersonInfo objects represents persons in the database and in a photo, respectively. The PersonInfo class has the following properties and methods.

View File

@@ -1366,6 +1366,17 @@ def query(
"(video files only): QuickTime:CreationDate; QuickTime:CreateDate; QuickTime:ModifyDate (see also --ignore-date-modified); "
"QuickTime:GPSCoordinates; UserData:GPSCoordinates.",
)
@click.option(
"--exiftool-option",
multiple=True,
metavar="OPTION",
help="Optional flag/option to pass to exiftool when using --exiftool. "
"For example, --exiftool-option '-m' to ignore minor warnings. "
"Specify these as you would on the exiftool command line. "
"See exiftool docs at https://exiftool.org/exiftool_pod.html for full list of options. "
"More than one option may be specified by repeating the option, e.g. "
"--exiftool-option '-m' --exiftool-option '-F'. ",
)
@click.option(
"--ignore-date-modified",
is_flag=True,
@@ -1465,6 +1476,18 @@ def query(
help="Cleanup export directory by deleting any files which were not included in this export set. "
"For example, photos which had previously been exported and were subsequently deleted in Photos.",
)
@click.option(
"--exportdb",
metavar="EXPORTDB_FILE",
default=None,
help=(
"Specify alternate name for database file which stores state information for export and --update. "
f"If --exportdb is not specified, export database will be saved to '{OSXPHOTOS_EXPORT_DB}' "
"in the export directory. Must be specified as filename only, not a path, as export database "
"will be saved in export directory."
),
type=click.Path(),
)
@click.option(
"--load-config",
required=False,
@@ -1550,6 +1573,7 @@ def export(
download_missing,
dest,
exiftool,
exiftool_option,
ignore_date_modified,
portrait,
not_portrait,
@@ -1583,6 +1607,7 @@ def export(
use_photokit,
report,
cleanup,
exportdb,
load_config,
save_config,
):
@@ -1712,11 +1737,14 @@ def export(
use_photokit = cfg.use_photokit
report = cfg.report
cleanup = cfg.cleanup
exportdb = cfg.exportdb
# config file might have changed verbose
VERBOSE = bool(verbose)
verbose_(f"Loaded options from file {load_config}")
verbose_(f"osxphotos version {__version__}")
exclusive_options = [
("favorite", "not_favorite"),
("hidden", "not_hidden"),
@@ -1747,6 +1775,7 @@ def export(
("missing", ("download_missing", "use_photos_export")),
("jpeg_quality", ("convert_to_jpeg")),
("ignore_signature", ("update")),
("exiftool_option", ("exiftool")),
]
try:
cfg.validate(exclusive=exclusive_options, dependent=dependent_options, cli=True)
@@ -1841,8 +1870,27 @@ def export(
_list_libraries()
return
# sanity check exportdb
if exportdb and exportdb != OSXPHOTOS_EXPORT_DB:
if "/" in exportdb:
click.echo(
click.style(
f"Error: --exportdb must be specified as filename not path; "
+ f"export database will saved in export directory '{dest}'.",
fg=CLI_COLOR_ERROR,
)
)
raise click.Abort()
elif pathlib.Path(pathlib.Path(dest) / OSXPHOTOS_EXPORT_DB).exists():
click.echo(
click.style(
f"Warning: export database is '{exportdb}' but found '{OSXPHOTOS_EXPORT_DB}' in {dest}; using '{exportdb}'",
fg=CLI_COLOR_WARNING,
)
)
# open export database and assign copy/link/unlink functions
export_db_path = os.path.join(dest, OSXPHOTOS_EXPORT_DB)
export_db_path = os.path.join(dest, exportdb or OSXPHOTOS_EXPORT_DB)
# check that export isn't in the parent or child of a previously exported library
other_db_files = find_files_in_branch(dest, OSXPHOTOS_EXPORT_DB)
@@ -1998,6 +2046,7 @@ def export(
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
exiftool_option=exiftool_option,
)
results += export_results
@@ -2045,6 +2094,7 @@ def export(
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
exiftool_option=exiftool_option,
)
results += export_results
@@ -2589,6 +2639,7 @@ def export_photo(
jpeg_quality=1.0,
ignore_date_modified=False,
use_photokit=False,
exiftool_option=None,
):
"""Helper function for export that does the actual export
@@ -2622,6 +2673,7 @@ def export_photo(
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
exiftool_option: optional list flags (e.g. ["-m", "-F"]) to pass to exiftool
Returns:
list of path(s) of exported photo or None if photo was missing
@@ -2767,12 +2819,21 @@ def export_photo(
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
verbose=verbose_,
exiftool_flags=exiftool_option,
)
results += export_results
for warning_ in export_results.exiftool_warning:
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")
verbose_(
f"exiftool warning for file {warning_[0]}: {warning_[1]}"
)
for error_ in export_results.exiftool_error:
click.echo(click.style(f"exiftool error for file {error_[0]}: {error_[1]}", fg=CLI_COLOR_ERROR),err=True)
click.echo(
click.style(
f"exiftool error for file {error_[0]}: {error_[1]}",
fg=CLI_COLOR_ERROR,
),
err=True,
)
except Exception as e:
click.echo(
@@ -2862,12 +2923,21 @@ def export_photo(
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
verbose=verbose_,
exiftool_flags=exiftool_option,
)
results += export_results_edited
for warning_ in export_results_edited.exiftool_warning:
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")
verbose_(
f"exiftool warning for file {warning_[0]}: {warning_[1]}"
)
for error_ in export_results_edited.exiftool_error:
click.echo(click.style(f"exiftool error for file {error_[0]}: {error_[1]}", fg=CLI_COLOR_ERROR),err=True)
click.echo(
click.style(
f"exiftool error for file {error_[0]}: {error_[1]}",
fg=CLI_COLOR_ERROR,
),
err=True,
)
except Exception as e:
click.echo(
click.style(

View File

@@ -102,6 +102,63 @@ _OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
SEARCH_CATEGORY_LABEL = 2024
SEARCH_CATEGORY_PLACE_NAME = 1
SEARCH_CATEGORY_STREET = 2
SEARCH_CATEGORY_NEIGHBORHOOD = 3
SEARCH_CATEGORY_LOCALITY_4 = 4
SEARCH_CATEGORY_CITY = 5
SEARCH_CATEGORY_SUB_LOCALITY = 6
SEARCH_CATEGORY_LOCALITY_7 = 7
SEARCH_CATEGORY_LOCALITY_8 = 8
SEARCH_CATEGORY_NAMED_AREA = 9
SEARCH_CATEGORY_ALL_LOCALITY = [
SEARCH_CATEGORY_LOCALITY_4,
SEARCH_CATEGORY_SUB_LOCALITY,
SEARCH_CATEGORY_LOCALITY_7,
SEARCH_CATEGORY_LOCALITY_8,
SEARCH_CATEGORY_NAMED_AREA,
]
SEARCH_CATEGORY_STATE = 10
SEARCH_CATEGORY_STATE_ABBREVIATION = 11
SEARCH_CATEGORY_COUNTRY = 12
SEARCH_CATEGORY_BODY_OF_WATER = 14
SEARCH_CATEGORY_MONTH = 1014
SEARCH_CATEGORY_YEAR = 1015
SEARCH_CATEGORY_KEYWORDS = 2016
SEARCH_CATEGORY_TITLE = 2017
SEARCH_CATEGORY_DESCRIPTION = 2018
SEARCH_CATEGORY_HOME = 2020
SEARCH_CATEGORY_PERSON = 2021
SEARCH_CATEGORY_ACTIVITY = 2027
SEARCH_CATEGORY_HOLIDAY = 2029
SEARCH_CATEGORY_SEASON = 2030
SEARCH_CATEGORY_WORK = 2036
SEARCH_CATEGORY_VENUE = 2038
SEARCH_CATEGORY_VENUE_TYPE = 2039
SEARCH_CATEGORY_PHOTO_TYPE_VIDEO = 2044
SEARCH_CATEGORY_PHOTO_TYPE_SLOMO = 2045
SEARCH_CATEGORY_PHOTO_TYPE_LIVE = 2046
SEARCH_CATEGORY_PHOTO_TYPE_SCREENSHOT = 2047
SEARCH_CATEGORY_PHOTO_TYPE_PANORAMA = 2048
SEARCH_CATEGORY_PHOTO_TYPE_TIMELAPSE = 2049
SEARCH_CATEGORY_PHOTO_TYPE_BURSTS = 2052
SEARCH_CATEGORY_PHOTO_TYPE_PORTRAIT = 2053
SEARCH_CATEGORY_PHOTO_TYPE_SELFIES = 2054
SEARCH_CATEGORY_PHOTO_TYPE_FAVORITES = 2055
SEARCH_CATEGORY_MEDIA_TYPES = [
SEARCH_CATEGORY_PHOTO_TYPE_VIDEO,
SEARCH_CATEGORY_PHOTO_TYPE_SLOMO,
SEARCH_CATEGORY_PHOTO_TYPE_LIVE,
SEARCH_CATEGORY_PHOTO_TYPE_SCREENSHOT,
SEARCH_CATEGORY_PHOTO_TYPE_PANORAMA,
SEARCH_CATEGORY_PHOTO_TYPE_TIMELAPSE,
SEARCH_CATEGORY_PHOTO_TYPE_BURSTS,
SEARCH_CATEGORY_PHOTO_TYPE_PORTRAIT,
SEARCH_CATEGORY_PHOTO_TYPE_SELFIES,
SEARCH_CATEGORY_PHOTO_TYPE_FAVORITES,
]
SEARCH_CATEGORY_PHOTO_NAME = 2056
# Max filename length on MacOS
MAX_FILENAME_LEN = 255
@@ -119,5 +176,5 @@ DEFAULT_EDITED_SUFFIX = "_edited"
DEFAULT_ORIGINAL_SUFFIX = ""
# Colors for print CLI messages
CLI_COLOR_ERROR = 'red'
CLI_COLOR_WARNING = 'yellow'
CLI_COLOR_ERROR = "red"
CLI_COLOR_WARNING = "yellow"

View File

@@ -1,5 +1,5 @@
""" version info """
__version__ = "0.38.7"
__version__ = "0.38.11"

View File

@@ -132,18 +132,21 @@ class _ExifToolProc:
class ExifTool:
""" Basic exiftool interface for reading and writing EXIF tags """
def __init__(self, filepath, exiftool=None, overwrite=True):
def __init__(self, filepath, exiftool=None, overwrite=True, flags=None):
""" Create ExifTool object
Args:
file: path to image file
exiftool: path to exiftool, if not specified will look in path
overwrite: if True, will overwrite image file without creating backup, default=False
file: path to image file
exiftool: path to exiftool, if not specified will look in path
overwrite: if True, will overwrite image file without creating backup, default=False
flags: optional list of exiftool flags to prepend to exiftool command when writing metadata (e.g. -m or -F)
Returns:
ExifTool instance
"""
self.file = filepath
self.overwrite = overwrite
self.flags = flags or []
self.data = {}
self.warning = None
self.error = None
@@ -249,14 +252,21 @@ class ExifTool:
commands.append("-overwrite_original")
filename = os.fsencode(self.file) if not no_file else b""
command_str = (
if self.flags:
command_str = b"\n".join([f.encode("utf-8") for f in self.flags])
command_str += b"\n"
else:
command_str = b""
command_str += (
b"\n".join([c.encode("utf-8") for c in commands])
+ b"\n"
+ filename
+ b"\n"
+ b"-execute\n"
)
# send the command
self._process.stdin.write(command_str)
self._process.stdin.flush()

View File

@@ -102,7 +102,7 @@ class ExportResults:
)
files += [x[0] for x in self.exiftool_warning]
files += [x[0] for x in self.exiftool_error]
files = list(set(files))
return files
@@ -425,6 +425,7 @@ def export2(
ignore_date_modified=False,
use_photokit=False,
verbose=None,
exiftool_flags=None,
):
"""export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -469,6 +470,7 @@ def export2(
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
exiftool_flags: optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
Returns: ExportResults class
ExportResults has attributes:
@@ -972,6 +974,7 @@ def export2(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
flags=exiftool_flags,
)
if warning_:
exiftool_warning.append((exported_file, warning_))
@@ -1006,6 +1009,7 @@ def export2(
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
flags=exiftool_flags,
)
if warning_:
exiftool_warning.append((exported_file, warning_))
@@ -1244,6 +1248,7 @@ def _write_exif_data(
keyword_template=None,
description_template=None,
ignore_date_modified=False,
flags=None,
):
"""write exif data to image file at filepath
@@ -1253,6 +1258,7 @@ def _write_exif_data(
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
flags: optional list of exiftool flags to prepend to exiftool command when writing metadata (e.g. -m or -F)
Returns:
(warning, error) of warning and error strings if exiftool produces warnings or errors
@@ -1267,7 +1273,7 @@ def _write_exif_data(
ignore_date_modified=ignore_date_modified,
)
with ExifTool(filepath) as exiftool:
with ExifTool(filepath, flags=flags) as exiftool:
for exiftag, val in exif_info.items():
if type(val) == list:
for v in val:
@@ -1341,13 +1347,13 @@ def _exiftool_dict(
person_list = []
if self.persons:
# filter out _UNKNOWN_PERSON
person_list = sorted([p for p in self.persons if p != _UNKNOWN_PERSON])
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
if use_persons_as_keywords and person_list:
keyword_list.extend(sorted(person_list))
keyword_list.extend(person_list)
if use_albums_as_keywords and self.albums:
keyword_list.extend(sorted(self.albums))
keyword_list.extend(self.albums)
if keyword_template:
rendered_keywords = []
@@ -1382,16 +1388,19 @@ def _exiftool_dict(
keyword_list.extend(rendered_keywords)
if keyword_list:
# remove duplicates
keyword_list = sorted(list(set(keyword_list)))
exif["XMP:TagsList"] = keyword_list.copy()
exif["IPTC:Keywords"] = keyword_list.copy()
if person_list:
person_list = sorted(list(set(person_list)))
exif["XMP:PersonInImage"] = person_list.copy()
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
# only use Photos' keywords for subject (e.g. don't include template values)
exif["XMP:Subject"] = self.keywords.copy() + person_list.copy()
exif["XMP:Subject"] = sorted(list(set(self.keywords + person_list)))
# if self.favorite():
# exif["Rating"] = 5
@@ -1460,13 +1469,12 @@ def _exiftool_dict(
date_utc = datetime_tz_to_utc(date)
creationdate = date_utc.strftime("%Y:%m:%d %H:%M:%S")
exif["QuickTime:CreateDate"] = creationdate
if self.date_modified is not None and not ignore_date_modified:
if self.date_modified is None or ignore_date_modified:
exif["QuickTime:ModifyDate"] = creationdate
else:
exif["QuickTime:ModifyDate"] = datetime_tz_to_utc(
self.date_modified
).strftime("%Y:%m:%d %H:%M:%S")
else:
exif["QuickTime:ModifyDate"] = creationdate
return exif
@@ -1604,6 +1612,15 @@ def _xmp_sidecar(
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
subject_list = list(self.keywords) + person_list
# remove duplicates
# sorted mainly to make testing the XMP file easier
if keyword_list:
keyword_list = sorted(list(set(keyword_list)))
if subject_list:
subject_list = sorted(list(set(subject_list)))
if person_list:
person_list = sorted(list(set(person_list)))
xmp_str = xmp_template.render(
photo=self,
description=description,

View File

@@ -1,11 +1,32 @@
""" Methods and class for PhotoInfo exposing SearchInfo data such as labels
Adds the following properties to PhotoInfo (valid only for Photos 5):
search_info: returns a SearchInfo object
search_info_normalized: returns a SearchInfo object with properties that produce normalized results
labels: returns list of labels
labels_normalized: returns list of normalized labels
"""
from .._constants import _PHOTOS_4_VERSION, SEARCH_CATEGORY_LABEL
from .._constants import (
_PHOTOS_4_VERSION,
SEARCH_CATEGORY_CITY,
SEARCH_CATEGORY_LABEL,
SEARCH_CATEGORY_NEIGHBORHOOD,
SEARCH_CATEGORY_PLACE_NAME,
SEARCH_CATEGORY_STREET,
SEARCH_CATEGORY_ALL_LOCALITY,
SEARCH_CATEGORY_COUNTRY,
SEARCH_CATEGORY_STATE,
SEARCH_CATEGORY_STATE_ABBREVIATION,
SEARCH_CATEGORY_BODY_OF_WATER,
SEARCH_CATEGORY_MONTH,
SEARCH_CATEGORY_YEAR,
SEARCH_CATEGORY_HOLIDAY,
SEARCH_CATEGORY_ACTIVITY,
SEARCH_CATEGORY_SEASON,
SEARCH_CATEGORY_VENUE,
SEARCH_CATEGORY_VENUE_TYPE,
SEARCH_CATEGORY_MEDIA_TYPES,
)
@property
@@ -24,6 +45,22 @@ def search_info(self):
return self._search_info
@property
def search_info_normalized(self):
""" returns SearchInfo object for photo that produces normalized results
only valid on Photos 5, on older libraries, returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return None
# memoize SearchInfo object
try:
return self._search_info_normalized
except AttributeError:
self._search_info_normalized = SearchInfo(self, normalized=True)
return self._search_info_normalized
@property
def labels(self):
""" returns list of labels applied to photo by Photos image categorization
@@ -43,14 +80,15 @@ def labels_normalized(self):
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info.labels_normalized
return self.search_info_normalized.labels
class SearchInfo:
""" Info about search terms such as machine learning labels that Photos knows about a photo """
def __init__(self, photo):
""" photo: PhotoInfo object """
def __init__(self, photo, normalized=False):
""" photo: PhotoInfo object
normalized: if True, all properties return normalized (lower case) results """
if photo._db._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError(
@@ -58,6 +96,7 @@ class SearchInfo:
)
self._photo = photo
self._normalized = normalized
self.uuid = photo.uuid
try:
# get search info for this UUID
@@ -69,25 +108,170 @@ class SearchInfo:
@property
def labels(self):
""" return list of labels associated with Photo """
if self._db_searchinfo:
labels = [
rec["content_string"]
for rec in self._db_searchinfo
if rec["category"] == SEARCH_CATEGORY_LABEL
]
else:
labels = []
return labels
return self._get_text_for_category(SEARCH_CATEGORY_LABEL)
@property
def labels_normalized(self):
""" return list of normalized labels associated with Photo """
def place_names(self):
""" returns list of place names """
return self._get_text_for_category(SEARCH_CATEGORY_PLACE_NAME)
@property
def streets(self):
""" returns list of street names """
return self._get_text_for_category(SEARCH_CATEGORY_STREET)
@property
def neighborhoods(self):
""" returns list of neighborhoods """
return self._get_text_for_category(SEARCH_CATEGORY_NEIGHBORHOOD)
@property
def locality_names(self):
""" returns list of other locality names """
locality = []
for category in SEARCH_CATEGORY_ALL_LOCALITY:
locality += self._get_text_for_category(category)
return locality
@property
def city(self):
""" returns city/town """
city = self._get_text_for_category(SEARCH_CATEGORY_CITY)
return city[0] if city else ""
@property
def state(self):
""" returns state name """
state = self._get_text_for_category(SEARCH_CATEGORY_STATE)
return state[0] if state else ""
@property
def state_abbreviation(self):
""" returns state abbreviation """
abbrev = self._get_text_for_category(SEARCH_CATEGORY_STATE_ABBREVIATION)
return abbrev[0] if abbrev else ""
@property
def country(self):
""" returns country name """
country = self._get_text_for_category(SEARCH_CATEGORY_COUNTRY)
return country[0] if country else ""
@property
def month(self):
""" returns month name """
month = self._get_text_for_category(SEARCH_CATEGORY_MONTH)
return month[0] if month else ""
@property
def year(self):
""" returns year """
year = self._get_text_for_category(SEARCH_CATEGORY_YEAR)
return year[0] if year else ""
@property
def bodies_of_water(self):
""" returns list of body of water names """
return self._get_text_for_category(SEARCH_CATEGORY_BODY_OF_WATER)
@property
def holidays(self):
""" returns list of holiday names """
return self._get_text_for_category(SEARCH_CATEGORY_HOLIDAY)
@property
def activities(self):
""" returns list of activity names """
return self._get_text_for_category(SEARCH_CATEGORY_ACTIVITY)
@property
def season(self):
""" returns season name """
season = self._get_text_for_category(SEARCH_CATEGORY_SEASON)
return season[0] if season else ""
@property
def venues(self):
""" returns list of venue names """
return self._get_text_for_category(SEARCH_CATEGORY_VENUE)
@property
def venue_types(self):
""" returns list of venue types """
return self._get_text_for_category(SEARCH_CATEGORY_VENUE_TYPE)
@property
def media_types(self):
""" returns list of media types (photo, video, panorama, etc) """
types = []
for category in SEARCH_CATEGORY_MEDIA_TYPES:
types += self._get_text_for_category(category)
return types
@property
def all(self):
""" return all search info properties in a single list """
all = (
self.labels
+ self.place_names
+ self.streets
+ self.neighborhoods
+ self.locality_names
+ self.bodies_of_water
+ self.holidays
+ self.activities
+ self.venues
+ self.venue_types
+ self.media_types
)
if self.city:
all += [self.city]
if self.state:
all += [self.state]
if self.state_abbreviation:
all += [self.state_abbreviation]
if self.country:
all += [self.country]
if self.month:
all += [self.month]
if self.year:
all += [self.year]
if self.season:
all += [self.season]
return all
def asdict(self):
""" return dict of search info """
return {
"labels": self.labels,
"place_names": self.place_names,
"streets": self.streets,
"neighborhoods": self.neighborhoods,
"city": self.city,
"locality_names": self.locality_names,
"state": self.state,
"state_abbreviation": self.state_abbreviation,
"country": self.country,
"bodies_of_water": self.bodies_of_water,
"month": self.month,
"year": self.year,
"holidays": self.holidays,
"activities": self.activities,
"season": self.season,
"venues": self.venues,
"venue_types": self.venue_types,
"media_types": self.media_types,
}
def _get_text_for_category(self, category):
""" return list of text for a specified category ID """
if self._db_searchinfo:
labels = [
rec["normalized_string"]
content = "normalized_string" if self._normalized else "content_string"
return [
rec[content]
for rec in self._db_searchinfo
if rec["category"] == SEARCH_CATEGORY_LABEL
if rec["category"] == category
]
else:
labels = []
return labels
return []

View File

@@ -43,6 +43,7 @@ class PhotoInfo:
# import additional methods
from ._photoinfo_searchinfo import (
search_info,
search_info_normalized,
labels,
labels_normalized,
SearchInfo,
@@ -980,6 +981,7 @@ class PhotoInfo:
comments = [comment.asdict() for comment in self.comments]
likes = [like.asdict() for like in self.likes]
faces = [face.asdict() for face in self.face_info]
search_info = self.search_info.asdict() if self.search_info else {}
return {
"library": self._db._library_path,
@@ -1041,6 +1043,7 @@ class PhotoInfo:
"original_filesize": self.original_filesize,
"comments": comments,
"likes": likes,
"search_info": search_info,
}
def json(self):

View File

@@ -104,17 +104,19 @@ def _process_searchinfo(self):
for row in c:
uuid = ints_to_uuid(row[1], row[2])
# strings have null character appended, so strip it
record = {}
record["uuid"] = uuid
record["rowid"] = row[0]
record["uuid_0"] = row[1]
record["uuid_1"] = row[2]
record["groupid"] = row[3]
record["category"] = row[4]
record["owning_groupid"] = row[5]
record["content_string"] = normalize_unicode(row[6].replace("\x00", ""))
record = {
"uuid": uuid,
"rowid": row[0],
"uuid_0": row[1],
"uuid_1": row[2],
"groupid": row[3],
"category": row[4],
"owning_groupid": row[5],
"content_string": normalize_unicode(row[6].replace("\x00", "")),
}
record["normalized_string"] = normalize_unicode(row[7].replace("\x00", ""))
record["lookup_identifier"] = row[8]
record["lookup_identifier"] = normalize_unicode(row[8].replace("\x00", ""))
try:
_db_searchinfo_uuid[uuid].append(record)

View File

@@ -7,7 +7,7 @@
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<key>pid</key>
<integer>19275</integer>
<integer>55247</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -35,6 +35,7 @@ KEYWORDS = [
"United Kingdom",
"foo/bar",
"Travel",
"Maria",
]
# Photos 5 includes blank person for detected face
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
@@ -60,6 +61,7 @@ KEYWORDS_DICT = {
"United Kingdom": 1,
"foo/bar": 1,
"Travel": 2,
"Maria": 1,
}
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 2, _UNKNOWN_PERSON: 1}
ALBUM_DICT = {
@@ -339,7 +341,7 @@ def test_attributes_2(photosdb):
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
assert len(photos) == 1
p = photos[0]
assert p.keywords == ["wedding"]
assert sorted(p.keywords) == ["Maria", "wedding"]
assert p.original_filename == "wedding.jpg"
assert p.filename == "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg"
assert p.date == datetime.datetime(

View File

@@ -407,6 +407,10 @@ CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = {
CLI_EXIFTOOL_ERROR = ["E2078879-A29C-4D6F-BACB-E3BBE6C3EB91"]
CLI_EXIFTOOL_DUPLICATE_KEYWORDS = {
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": "wedding.jpg"
}
LABELS_JSON = {
"labels": {
"Plant": 7,
@@ -1017,7 +1021,10 @@ def test_export_exiftool():
exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict()
for key in CLI_EXIFTOOL[uuid]:
assert exif[key] == CLI_EXIFTOOL[uuid][key]
if type(exif[key]) == list:
assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key])
else:
assert exif[key] == CLI_EXIFTOOL[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
@@ -1051,7 +1058,12 @@ def test_export_exiftool_ignore_date_modified():
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]["File:FileName"]
).asdict()
for key in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]:
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
if type(exif[key]) == list:
assert sorted(exif[key]) == sorted(
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
)
else:
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
@@ -1093,6 +1105,38 @@ def test_export_exiftool_quicktime():
for filename in files:
os.unlink(filename)
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_duplicate_keywords():
""" ensure duplicate keywords are removed """
import glob
import os
import os.path
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_EXIFTOOL_DUPLICATE_KEYWORDS:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--uuid",
f"{uuid}",
],
)
exif = ExifTool(CLI_EXIFTOOL_DUPLICATE_KEYWORDS[uuid])
exifdict = exif.asdict()
assert sorted(exifdict["IPTC:Keywords"]) == ["Maria", "wedding"]
assert sorted(exifdict["XMP:Subject"]) == ["Maria", "wedding"]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_error():
"""" test --exiftool catching error """
@@ -1106,7 +1150,7 @@ def test_export_exiftool_error():
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_EXIFTOOL_ERROR:
for uuid in CLI_EXIFTOOL:
result = runner.invoke(
export,
[
@@ -1119,7 +1163,52 @@ def test_export_exiftool_error():
],
)
assert result.exit_code == 0
assert "exiftool error" in result.output
files = glob.glob("*")
assert sorted(files) == sorted([CLI_EXIFTOOL[uuid]["File:FileName"]])
exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict()
for key in CLI_EXIFTOOL[uuid]:
if type(exif[key]) == list:
assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key])
else:
assert exif[key] == CLI_EXIFTOOL[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_option():
""" test --exiftool-option """
import glob
import os
import os.path
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
# first export with --exiftool, one file produces a warning
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--exiftool"]
)
assert result.exit_code == 0
assert "exiftool warning" in result.output
# run again with exiftool-option = "-m" (ignore minor warnings)
# shouldn't see the warning this time
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--exiftool-option",
"-m",
],
)
assert result.exit_code == 0
assert "exiftool warning" not in result.output
def test_export_edited_suffix():
@@ -4355,3 +4444,71 @@ def test_save_load_config():
assert result.exit_code == 0
assert "Writing exiftool JSON sidecar" in result.output
assert "Writing XMP sidecar" not in result.output
def test_export_exportdb():
""" test --exportdb """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
import re
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "export.db"],
)
assert result.exit_code == 0
assert re.search(r"Created export database.*export\.db", result.output)
files = glob.glob("*")
assert "export.db" in files
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--exportdb",
"export.db",
"--update",
],
)
assert result.exit_code == 0
assert re.search(r"Using export database.*export\.db", result.output)
# export again w/o --exportdb
result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"])
assert result.exit_code == 0
assert re.search(
r"Created export database.*\.osxphotos_export\.db", result.output
)
files = glob.glob(".*")
assert ".osxphotos_export.db" in files
# now try again with --exportdb, should generate warning
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "export.db"],
)
assert result.exit_code == 0
assert (
"Warning: export database is 'export.db' but found '.osxphotos_export.db'"
in result.output
)
# specify a path for exportdb, should generate error
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "./export.db"],
)
assert result.exit_code != 0
assert (
"Error: --exportdb must be specified as filename not path" in result.output
)

View File

@@ -3,6 +3,7 @@ from osxphotos.exiftool import get_exiftool_path
TEST_FILE_ONE_KEYWORD = "tests/test-images/wedding.jpg"
TEST_FILE_BAD_IMAGE = "tests/test-images/badimage.jpeg"
TEST_FILE_WARNING = "tests/test-images/exiftool_warning.heic"
TEST_FILE_MULTI_KEYWORD = "tests/test-images/Tulips.jpg"
TEST_MULTI_KEYWORDS = [
"Top Shot",
@@ -200,6 +201,29 @@ def test_setvalue_context_manager_error():
assert exif.error
def test_flags():
# test that flags work
import os.path
import tempfile
import osxphotos.exiftool
from osxphotos.fileutil import FileUtil
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
tempfile = os.path.join(tempdir.name, os.path.basename(TEST_FILE_WARNING))
FileUtil.copy(TEST_FILE_WARNING, tempfile)
with osxphotos.exiftool.ExifTool(tempfile) as exif:
exif.setvalue("XMP:Subject", "foo/bar")
assert exif.warning
# test again with -m: ignore minor warnings
FileUtil.unlink(tempfile)
FileUtil.copy(TEST_FILE_WARNING, tempfile)
with osxphotos.exiftool.ExifTool(tempfile, flags=["-m"]) as exif:
exif.setvalue("XMP:Subject", "foo/bar")
assert not exif.warning
def test_clear_value():
# test clearing a tag value
import os.path

View File

@@ -22,6 +22,7 @@ KEYWORDS = [
"St. James's Park",
"UK",
"United Kingdom",
"Maria"
]
# Photos 5 includes blank person for detected face
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
@@ -39,6 +40,7 @@ KEYWORDS_DICT = {
"St. James's Park": 1,
"UK": 1,
"United Kingdom": 1,
"Maria": 1,
}
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1, _UNKNOWN_PERSON: 1}
ALBUM_DICT = {
@@ -70,8 +72,8 @@ EXIF_JSON_UUID = UUID_DICT["has_adjustments"]
EXIF_JSON_EXPECTED = """
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding"],
"IPTC:Keywords": ["wedding"],
"XMP:TagsList": ["Maria", "wedding"],
"IPTC:Keywords": ["Maria", "wedding"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
@@ -85,8 +87,8 @@ EXIF_JSON_EXPECTED = """
EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding"],
"IPTC:Keywords": ["wedding"],
"XMP:TagsList": ["Maria", "wedding"],
"IPTC:Keywords": ["Maria", "wedding"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
@@ -522,8 +524,8 @@ def test_exiftool_json_sidecar_keyword_template_long(caplog):
"""
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"IPTC:Keywords": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"XMP:TagsList": ["Maria", "wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"IPTC:Keywords": ["Maria", "wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
@@ -571,8 +573,8 @@ def test_exiftool_json_sidecar_keyword_template():
"""
[{"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"IPTC:Keywords": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"XMP:TagsList": ["Maria", "wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"IPTC:Keywords": ["Maria", "wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",

View File

@@ -427,9 +427,9 @@ def test_xmp_sidecar():
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Katie</rdf:li>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
@@ -438,8 +438,8 @@ def test_xmp_sidecar():
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
<rdf:li>Suzy</rdf:li>
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>

View File

@@ -210,7 +210,7 @@ def test_search_info(photosdb):
def test_labels_normalized(photosdb):
for uuid in LABELS_NORMALIZED_DICT:
photo = photosdb.photos(uuid=[uuid])[0]
assert sorted(photo.search_info.labels_normalized) == sorted(
assert sorted(photo.search_info_normalized.labels) == sorted(
LABELS_NORMALIZED_DICT[uuid]
)
assert sorted(photo.labels_normalized) == sorted(LABELS_NORMALIZED_DICT[uuid])

View File

@@ -349,7 +349,7 @@ def test_labels_normalized(photosdb):
for uuid in LABELS_NORMALIZED_DICT:
photo = photosdb.photos(uuid=[uuid])[0]
logging.warning(f"uuid = {uuid}")
assert sorted(photo.search_info.labels_normalized) == sorted(
assert sorted(photo.search_info_normalized.labels) == sorted(
LABELS_NORMALIZED_DICT[uuid]
)
assert sorted(photo.labels_normalized) == sorted(LABELS_NORMALIZED_DICT[uuid])

View File

@@ -0,0 +1,57 @@
""" test SearchInfo class """
import json
import os
import pytest
import osxphotos
# These tests must be run against the author's personal photo library
skip_test = "OSXPHOTOS_TEST_EXPORT" not in os.environ
pytestmark = pytest.mark.skipif(
skip_test, reason="These tests only run against system Photos library"
)
PHOTOS_DB = "/Users/rhet/Pictures/Photos Library.photoslibrary"
with open("tests/search_info_test_data_10_15_7.json") as fp:
test_data = json.load(fp)
UUID_SEARCH_INFO = test_data["UUID_SEARCH_INFO"]
UUID_SEARCH_INFO_NORMALIZED = test_data["UUID_SEARCH_INFO_NORMALIZED"]
UUID_SEARCH_INFO_ALL = test_data["UUID_SEARCH_INFO_ALL"]
UUID_SEARCH_INFO_ALL_NORMALIZED = test_data["UUID_SEARCH_INFO_ALL_NORMALIZED"]
@pytest.fixture(scope="module")
def photosdb():
return osxphotos.PhotosDB(dbfile=PHOTOS_DB)
def test_search_info(photosdb):
for uuid in UUID_SEARCH_INFO:
photo = photosdb.get_photo(uuid)
assert photo.search_info.asdict() == UUID_SEARCH_INFO[uuid]
def test_search_info_normalized(photosdb):
for uuid in UUID_SEARCH_INFO_NORMALIZED:
photo = photosdb.get_photo(uuid)
assert (
photo.search_info_normalized.asdict() == UUID_SEARCH_INFO_NORMALIZED[uuid]
)
def test_search_info_all(photosdb):
for uuid in UUID_SEARCH_INFO_ALL:
photo = photosdb.get_photo(uuid)
assert sorted(photo.search_info.all) == sorted(UUID_SEARCH_INFO_ALL[uuid])
def test_search_info_all_normalized(photosdb):
for uuid in UUID_SEARCH_INFO_ALL_NORMALIZED:
photo = photosdb.get_photo(uuid)
assert sorted(photo.search_info_normalized.all) == sorted(
UUID_SEARCH_INFO_ALL_NORMALIZED[uuid]
)

View File

@@ -0,0 +1,34 @@
""" Create the test data needed for test_search_info_10_15_7.py """
# reads data from the author's system photo library to build the test data
# used to test SearchInfo
import json
import osxphotos
UUID = [
"C8EAF50A-D891-4E0C-8086-C417E1284153",
"71DFB4C3-E868-4BE4-906E-D96BD8692D7E",
"2C151013-5BBA-4D00-B70F-1C9420418B86",
]
data = {
"UUID_SEARCH_INFO": {},
"UUID_SEARCH_INFO_NORMALIZED": {},
"UUID_SEARCH_INFO_ALL": {},
"UUID_SEARCH_INFO_ALL_NORMALIZED": {},
}
photosdb = osxphotos.PhotosDB()
for uuid in UUID:
photo = photosdb.get_photo(uuid)
search = photo.search_info
search_norm = photo.search_info_normalized
data["UUID_SEARCH_INFO"][uuid] = search.asdict()
data["UUID_SEARCH_INFO_NORMALIZED"][uuid] = search_norm.asdict()
data["UUID_SEARCH_INFO_ALL"][uuid] = search.all
data["UUID_SEARCH_INFO_ALL_NORMALIZED"][uuid] = search_norm.all
print(json.dumps(data))