Refactored photoinfo, photoexporter; #462

This commit is contained in:
Rhet Turnbull 2022-01-02 09:06:04 -08:00
parent c99cf5518d
commit a73dc72558
23 changed files with 2681 additions and 2604 deletions

View File

@ -1,7 +1,8 @@
from ._constants import AlbumSortOrder
from ._version import __version__
from .exiftool import ExifTool
from .photoinfo import ExportResults, PhotoInfo
from .photoexporter import ExportResults, PhotoExporter
from .photoinfo import PhotoInfo
from .photosdb import PhotosDB
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
from .phototemplate import PhotoTemplate

View File

@ -12,6 +12,7 @@ import shlex
import subprocess
import sys
import time
from runpy import run_module
import bitmath
import click
@ -20,7 +21,6 @@ import photoscript
import rich.traceback
import yaml
from rich import pretty
from runpy import run_module
import osxphotos
@ -56,7 +56,8 @@ from .exiftool import get_exiftool_path
from .export_db import ExportDB, ExportDBInMemory
from .fileutil import FileUtil, FileUtilNoOp
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .photoinfo import ExportResults, PhotoInfo
from .photoexporter import ExportResults, PhotoExporter
from .photoinfo import PhotoInfo
from .photokit import check_photokit_authorization, request_photokit_authorization
from .photosalbum import PhotosAlbum
from .phototemplate import PhotoTemplate, RenderOptions
@ -2954,7 +2955,8 @@ def export_photo_to_directory(
tries += 1
error = 0
try:
export_results = photo.export2(
exporter = PhotoExporter(photo)
export_results = exporter.export2(
dest_path,
original_filename=filename,
edited=edited,
@ -3466,7 +3468,7 @@ def write_finder_tags(
skipped = []
if keywords:
# match whatever keywords would've been used in --exiftool or --sidecar
exif = photo._exiftool_dict(
exif = PhotoExporter(photo)._exiftool_dict(
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,

28
osxphotos/exifinfo.py Normal file
View File

@ -0,0 +1,28 @@
""" ExifInfo class to expose EXIF info from the library """
from dataclasses import dataclass
@dataclass(frozen=True)
class ExifInfo:
"""EXIF info associated with a photo from the Photos library"""
flash_fired: bool
iso: int
metering_mode: int
sample_rate: int
track_format: int
white_balance: int
aperture: float
bit_rate: float
duration: float
exposure_bias: float
focal_length: float
fps: float
latitude: float
longitude: float
shutter_speed: float
camera_make: str
camera_model: str
codec: str
lens_model: str

2179
osxphotos/photoexporter.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ from typing import Optional
import yaml
from osxmetadata import OSXMetaData
from .._constants import (
from ._constants import (
_MOVIE_TYPE,
_PHOTO_TYPE,
_PHOTOS_4_ALBUM_KIND,
@ -37,16 +37,21 @@ from .._constants import (
BURST_SELECTED,
TEXT_DETECTION_CONFIDENCE_THRESHOLD,
)
from ..adjustmentsinfo import AdjustmentsInfo
from ..albuminfo import AlbumInfo, ImportInfo, ProjectInfo
from ..momentinfo import MomentInfo
from ..personinfo import FaceInfo, PersonInfo
from ..phototemplate import PhotoTemplate, RenderOptions
from ..placeinfo import PlaceInfo4, PlaceInfo5
from ..query_builder import get_query
from ..text_detection import detect_text
from ..uti import get_preferred_uti_extension, get_uti_for_extension
from ..utils import _debug, _get_resource_loc, findfiles
from .adjustmentsinfo import AdjustmentsInfo
from .albuminfo import AlbumInfo, ImportInfo, ProjectInfo
from .exifinfo import ExifInfo
from .exiftool import ExifToolCaching, get_exiftool_path
from .momentinfo import MomentInfo
from .personinfo import FaceInfo, PersonInfo
from .photoexporter import PhotoExporter
from .phototemplate import PhotoTemplate, RenderOptions
from .placeinfo import PlaceInfo4, PlaceInfo5
from .query_builder import get_query
from .scoreinfo import ScoreInfo
from .searchinfo import SearchInfo
from .text_detection import detect_text
from .uti import get_preferred_uti_extension, get_uti_for_extension
from .utils import _debug, _get_resource_loc, findfiles
class PhotoInfo:
@ -55,42 +60,12 @@ class PhotoInfo:
including keywords, persons, albums, uuid, path, etc.
"""
# import additional methods
from ._photoinfo_comments import comments, likes
from ._photoinfo_exifinfo import ExifInfo, exif_info
from ._photoinfo_exiftool import exiftool
from ._photoinfo_export import (
ExportResults,
_exiftool_dict,
_exiftool_json_sidecar,
_export_photo,
_export_photo_with_photos_export,
_get_exif_keywords,
_get_exif_persons,
_write_exif_data,
_write_sidecar,
_xmp_sidecar,
export,
export2,
)
from ._photoinfo_scoreinfo import ScoreInfo, score
from ._photoinfo_searchinfo import (
SearchInfo,
labels,
labels_normalized,
search_info,
search_info_normalized,
)
def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid
self._info = info
self._db = db
self._verbose = self._db._verbose
# TODO: remove this once refactor of PhotoExporter is done
self._render_options = RenderOptions()
@property
def filename(self):
"""filename of the picture"""
@ -1140,21 +1115,239 @@ class PhotoInfo:
self._owner = None
return self._owner
def render_template(
self, template_str: str, options: Optional[RenderOptions] = None
):
"""Renders a template string for PhotoInfo instance using PhotoTemplate
Args:
template_str: a template string with fields to render
options: a RenderOptions instance
@property
def score(self):
"""Computed score information for a photo
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
ScoreInfo instance
"""
options = options or RenderOptions()
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
return template.render(template_str, options)
if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"score not implemented for this database version")
return None
try:
return self._scoreinfo # pylint: disable=access-member-before-definition
except AttributeError:
try:
scores = self._db._db_scoreinfo_uuid[self.uuid]
self._scoreinfo = ScoreInfo(
overall=scores["overall_aesthetic"],
curation=scores["curation"],
promotion=scores["promotion"],
highlight_visibility=scores["highlight_visibility"],
behavioral=scores["behavioral"],
failure=scores["failure"],
harmonious_color=scores["harmonious_color"],
immersiveness=scores["immersiveness"],
interaction=scores["interaction"],
interesting_subject=scores["interesting_subject"],
intrusive_object_presence=scores["intrusive_object_presence"],
lively_color=scores["lively_color"],
low_light=scores["low_light"],
noise=scores["noise"],
pleasant_camera_tilt=scores["pleasant_camera_tilt"],
pleasant_composition=scores["pleasant_composition"],
pleasant_lighting=scores["pleasant_lighting"],
pleasant_pattern=scores["pleasant_pattern"],
pleasant_perspective=scores["pleasant_perspective"],
pleasant_post_processing=scores["pleasant_post_processing"],
pleasant_reflection=scores["pleasant_reflection"],
pleasant_symmetry=scores["pleasant_symmetry"],
sharply_focused_subject=scores["sharply_focused_subject"],
tastefully_blurred=scores["tastefully_blurred"],
well_chosen_subject=scores["well_chosen_subject"],
well_framed_subject=scores["well_framed_subject"],
well_timed_shot=scores["well_timed_shot"],
)
return self._scoreinfo
except KeyError:
self._scoreinfo = ScoreInfo(
overall=0.0,
curation=0.0,
promotion=0.0,
highlight_visibility=0.0,
behavioral=0.0,
failure=0.0,
harmonious_color=0.0,
immersiveness=0.0,
interaction=0.0,
interesting_subject=0.0,
intrusive_object_presence=0.0,
lively_color=0.0,
low_light=0.0,
noise=0.0,
pleasant_camera_tilt=0.0,
pleasant_composition=0.0,
pleasant_lighting=0.0,
pleasant_pattern=0.0,
pleasant_perspective=0.0,
pleasant_post_processing=0.0,
pleasant_reflection=0.0,
pleasant_symmetry=0.0,
sharply_focused_subject=0.0,
tastefully_blurred=0.0,
well_chosen_subject=0.0,
well_framed_subject=0.0,
well_timed_shot=0.0,
)
return self._scoreinfo
@property
def search_info(self):
"""returns SearchInfo object for photo
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
except AttributeError:
self._search_info = SearchInfo(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
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info.labels
@property
def labels_normalized(self):
"""returns normalized list of labels applied to photo by Photos image categorization
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info_normalized.labels
@property
def comments(self):
"""Returns list of Comment objects for any comments on the photo (sorted by date)"""
try:
return self._db._db_comments_uuid[self.uuid]["comments"]
except:
return []
@property
def likes(self):
"""Returns list of Like objects for any likes on the photo (sorted by date)"""
try:
return self._db._db_comments_uuid[self.uuid]["likes"]
except:
return []
@property
def exif_info(self):
"""Returns an ExifInfo object with the EXIF data for photo
Note: the returned EXIF data is the data Photos stores in the database on import;
ExifInfo does not provide access to the EXIF info in the actual image file
Some or all of the fields may be None
Only valid for Photos 5; on earlier database returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"exif_info not implemented for this database version")
return None
try:
exif = self._db._db_exifinfo_uuid[self.uuid]
exif_info = ExifInfo(
iso=exif["ZISO"],
flash_fired=True if exif["ZFLASHFIRED"] == 1 else False,
metering_mode=exif["ZMETERINGMODE"],
sample_rate=exif["ZSAMPLERATE"],
track_format=exif["ZTRACKFORMAT"],
white_balance=exif["ZWHITEBALANCE"],
aperture=exif["ZAPERTURE"],
bit_rate=exif["ZBITRATE"],
duration=exif["ZDURATION"],
exposure_bias=exif["ZEXPOSUREBIAS"],
focal_length=exif["ZFOCALLENGTH"],
fps=exif["ZFPS"],
latitude=exif["ZLATITUDE"],
longitude=exif["ZLONGITUDE"],
shutter_speed=exif["ZSHUTTERSPEED"],
camera_make=exif["ZCAMERAMAKE"],
camera_model=exif["ZCAMERAMODEL"],
codec=exif["ZCODEC"],
lens_model=exif["ZLENSMODEL"],
)
except KeyError:
logging.debug(f"Could not find exif record for uuid {self.uuid}")
exif_info = ExifInfo(
iso=None,
flash_fired=None,
metering_mode=None,
sample_rate=None,
track_format=None,
white_balance=None,
aperture=None,
bit_rate=None,
duration=None,
exposure_bias=None,
focal_length=None,
fps=None,
latitude=None,
longitude=None,
shutter_speed=None,
camera_make=None,
camera_model=None,
codec=None,
lens_model=None,
)
return exif_info
@property
def exiftool(self):
"""Returns a ExifToolCaching (read-only instance of ExifTool) object for the photo.
Requires that exiftool (https://exiftool.org/) be installed
If exiftool not installed, logs warning and returns None
If photo path is missing, returns None
"""
try:
# return the memoized instance if it exists
return self._exiftool
except AttributeError:
try:
exiftool_path = self._db._exiftool_path or get_exiftool_path()
if self.path is not None and os.path.isfile(self.path):
exiftool = ExifToolCaching(self.path, exiftool=exiftool_path)
else:
exiftool = None
except FileNotFoundError:
# get_exiftool_path raises FileNotFoundError if exiftool not found
exiftool = None
logging.warning(
"exiftool not in path; download and install from https://exiftool.org/"
)
self._exiftool = exiftool
return self._exiftool
def detected_text(self, confidence_threshold=TEXT_DETECTION_CONFIDENCE_THRESHOLD):
"""Detects text in photo and returns lists of results as (detected text, confidence)
@ -1213,6 +1406,108 @@ class PhotoInfo:
"""Returns latitude, in degrees"""
return self._info["latitude"]
def render_template(
self, template_str: str, options: Optional[RenderOptions] = None
):
"""Renders a template string for PhotoInfo instance using PhotoTemplate
Args:
template_str: a template string with fields to render
options: a RenderOptions instance
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
options = options or RenderOptions()
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
return template.render(template_str, options)
def export(
self,
dest,
filename=None,
edited=False,
live_photo=False,
raw_photo=False,
export_as_hardlink=False,
overwrite=False,
increment=True,
sidecar_json=False,
sidecar_exiftool=False,
sidecar_xmp=False,
use_photos_export=False,
timeout=120,
exiftool=False,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
render_options: Optional[RenderOptions] = None,
):
"""export photo
dest: must be valid destination path (or exception raised)
filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
export will print a warning but will export the photo using the
incorrect file extension (unless use_photos_export is true, in which case export will
use the extension provided by Photos upon export; in this case, an incorrect extension is
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo, otherwise exports the original version
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associated RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
overwrite: (boolean, default=False); if True will overwrite files if they already exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
sidecar_xmp: if set will write an XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
returns list of full paths to the exported files
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
when exporting metadata with exiftool or sidecar
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
render_options: an optional osxphotos.phototemplate.RenderOptions instance with options to pass to template renderer
Returns: list of photos exported
"""
exporter = PhotoExporter(self)
return exporter.export(
dest=dest,
filename=filename,
edited=edited,
live_photo=live_photo,
raw_photo=raw_photo,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
increment=increment,
sidecar_json=sidecar_json,
sidecar_exiftool=sidecar_exiftool,
sidecar_xmp=sidecar_xmp,
use_photos_export=use_photos_export,
timeout=timeout,
exiftool=exiftool,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
render_options=render_options,
)
def _get_album_uuids(self, project=False):
"""Return list of album UUIDs this photo is found in

View File

@ -1,10 +0,0 @@
"""
PhotoInfo class
Represents a single photo in the Photos library and provides access to the photo's attributes
PhotosDB.photos() returns a list of PhotoInfo objects
"""
from ._photoinfo_exifinfo import ExifInfo
from ._photoinfo_export import ExportResults
from ._photoinfo_scoreinfo import ScoreInfo
from .photoinfo import PhotoInfo, PhotoInfoNone

View File

@ -1,17 +0,0 @@
""" PhotoInfo methods to expose comments and likes for shared photos """
@property
def comments(self):
""" Returns list of Comment objects for any comments on the photo (sorted by date) """
try:
return self._db._db_comments_uuid[self.uuid]["comments"]
except:
return []
@property
def likes(self):
""" Returns list of Like objects for any likes on the photo (sorted by date) """
try:
return self._db._db_comments_uuid[self.uuid]["likes"]
except:
return []

View File

@ -1,94 +0,0 @@
""" PhotoInfo methods to expose EXIF info from the library """
import logging
from dataclasses import dataclass
from .._constants import _PHOTOS_4_VERSION
@dataclass(frozen=True)
class ExifInfo:
""" EXIF info associated with a photo from the Photos library """
flash_fired: bool
iso: int
metering_mode: int
sample_rate: int
track_format: int
white_balance: int
aperture: float
bit_rate: float
duration: float
exposure_bias: float
focal_length: float
fps: float
latitude: float
longitude: float
shutter_speed: float
camera_make: str
camera_model: str
codec: str
lens_model: str
@property
def exif_info(self):
""" Returns an ExifInfo object with the EXIF data for photo
Note: the returned EXIF data is the data Photos stores in the database on import;
ExifInfo does not provide access to the EXIF info in the actual image file
Some or all of the fields may be None
Only valid for Photos 5; on earlier database returns None
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"exif_info not implemented for this database version")
return None
try:
exif = self._db._db_exifinfo_uuid[self.uuid]
exif_info = ExifInfo(
iso=exif["ZISO"],
flash_fired=True if exif["ZFLASHFIRED"] == 1 else False,
metering_mode=exif["ZMETERINGMODE"],
sample_rate=exif["ZSAMPLERATE"],
track_format=exif["ZTRACKFORMAT"],
white_balance=exif["ZWHITEBALANCE"],
aperture=exif["ZAPERTURE"],
bit_rate=exif["ZBITRATE"],
duration=exif["ZDURATION"],
exposure_bias=exif["ZEXPOSUREBIAS"],
focal_length=exif["ZFOCALLENGTH"],
fps=exif["ZFPS"],
latitude=exif["ZLATITUDE"],
longitude=exif["ZLONGITUDE"],
shutter_speed=exif["ZSHUTTERSPEED"],
camera_make=exif["ZCAMERAMAKE"],
camera_model=exif["ZCAMERAMODEL"],
codec=exif["ZCODEC"],
lens_model=exif["ZLENSMODEL"],
)
except KeyError:
logging.debug(f"Could not find exif record for uuid {self.uuid}")
exif_info = ExifInfo(
iso=None,
flash_fired=None,
metering_mode=None,
sample_rate=None,
track_format=None,
white_balance=None,
aperture=None,
bit_rate=None,
duration=None,
exposure_bias=None,
focal_length=None,
fps=None,
latitude=None,
longitude=None,
shutter_speed=None,
camera_make=None,
camera_model=None,
codec=None,
lens_model=None,
)
return exif_info

View File

@ -1,34 +0,0 @@
""" Implementation for PhotoInfo.exiftool property which returns ExifTool object for a photo """
import logging
import os
from ..exiftool import ExifToolCaching, get_exiftool_path
@property
def exiftool(self):
""" Returns a ExifToolCaching (read-only instance of ExifTool) object for the photo.
Requires that exiftool (https://exiftool.org/) be installed
If exiftool not installed, logs warning and returns None
If photo path is missing, returns None
"""
try:
# return the memoized instance if it exists
return self._exiftool
except AttributeError:
try:
exiftool_path = self._db._exiftool_path or get_exiftool_path()
if self.path is not None and os.path.isfile(self.path):
exiftool = ExifToolCaching(self.path, exiftool=exiftool_path)
else:
exiftool = None
except FileNotFoundError:
# get_exiftool_path raises FileNotFoundError if exiftool not found
exiftool = None
logging.warning(
f"exiftool not in path; download and install from https://exiftool.org/"
)
self._exiftool = exiftool
return self._exiftool

File diff suppressed because it is too large Load Diff

View File

@ -1,119 +0,0 @@
""" PhotoInfo methods to expose computed score info from the library """
import logging
from dataclasses import dataclass
from .._constants import _PHOTOS_4_VERSION
@dataclass(frozen=True)
class ScoreInfo:
""" Computed photo score info associated with a photo from the Photos library """
overall: float
curation: float
promotion: float
highlight_visibility: float
behavioral: float
failure: float
harmonious_color: float
immersiveness: float
interaction: float
interesting_subject: float
intrusive_object_presence: float
lively_color: float
low_light: float
noise: float
pleasant_camera_tilt: float
pleasant_composition: float
pleasant_lighting: float
pleasant_pattern: float
pleasant_perspective: float
pleasant_post_processing: float
pleasant_reflection: float
pleasant_symmetry: float
sharply_focused_subject: float
tastefully_blurred: float
well_chosen_subject: float
well_framed_subject: float
well_timed_shot: float
@property
def score(self):
""" Computed score information for a photo
Returns:
ScoreInfo instance
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"score not implemented for this database version")
return None
try:
return self._scoreinfo # pylint: disable=access-member-before-definition
except AttributeError:
try:
scores = self._db._db_scoreinfo_uuid[self.uuid]
self._scoreinfo = ScoreInfo(
overall=scores["overall_aesthetic"],
curation=scores["curation"],
promotion=scores["promotion"],
highlight_visibility=scores["highlight_visibility"],
behavioral=scores["behavioral"],
failure=scores["failure"],
harmonious_color=scores["harmonious_color"],
immersiveness=scores["immersiveness"],
interaction=scores["interaction"],
interesting_subject=scores["interesting_subject"],
intrusive_object_presence=scores["intrusive_object_presence"],
lively_color=scores["lively_color"],
low_light=scores["low_light"],
noise=scores["noise"],
pleasant_camera_tilt=scores["pleasant_camera_tilt"],
pleasant_composition=scores["pleasant_composition"],
pleasant_lighting=scores["pleasant_lighting"],
pleasant_pattern=scores["pleasant_pattern"],
pleasant_perspective=scores["pleasant_perspective"],
pleasant_post_processing=scores["pleasant_post_processing"],
pleasant_reflection=scores["pleasant_reflection"],
pleasant_symmetry=scores["pleasant_symmetry"],
sharply_focused_subject=scores["sharply_focused_subject"],
tastefully_blurred=scores["tastefully_blurred"],
well_chosen_subject=scores["well_chosen_subject"],
well_framed_subject=scores["well_framed_subject"],
well_timed_shot=scores["well_timed_shot"],
)
return self._scoreinfo
except KeyError:
self._scoreinfo = ScoreInfo(
overall=0.0,
curation=0.0,
promotion=0.0,
highlight_visibility=0.0,
behavioral=0.0,
failure=0.0,
harmonious_color=0.0,
immersiveness=0.0,
interaction=0.0,
interesting_subject=0.0,
intrusive_object_presence=0.0,
lively_color=0.0,
low_light=0.0,
noise=0.0,
pleasant_camera_tilt=0.0,
pleasant_composition=0.0,
pleasant_lighting=0.0,
pleasant_pattern=0.0,
pleasant_perspective=0.0,
pleasant_post_processing=0.0,
pleasant_reflection=0.0,
pleasant_symmetry=0.0,
sharply_focused_subject=0.0,
tastefully_blurred=0.0,
well_chosen_subject=0.0,
well_framed_subject=0.0,
well_timed_shot=0.0,
)
return self._scoreinfo

39
osxphotos/scoreinfo.py Normal file
View File

@ -0,0 +1,39 @@
""" ScoreInfo class to expose computed score info from the library """
from dataclasses import dataclass
from ._constants import _PHOTOS_4_VERSION
@dataclass(frozen=True)
class ScoreInfo:
""" Computed photo score info associated with a photo from the Photos library """
overall: float
curation: float
promotion: float
highlight_visibility: float
behavioral: float
failure: float
harmonious_color: float
immersiveness: float
interaction: float
interesting_subject: float
intrusive_object_presence: float
lively_color: float
low_light: float
noise: float
pleasant_camera_tilt: float
pleasant_composition: float
pleasant_lighting: float
pleasant_pattern: float
pleasant_perspective: float
pleasant_post_processing: float
pleasant_reflection: float
pleasant_symmetry: float
sharply_focused_subject: float
tastefully_blurred: float
well_chosen_subject: float
well_framed_subject: float
well_timed_shot: float

View File

@ -1,88 +1,29 @@
""" 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
""" class for PhotoInfo exposing SearchInfo data such as labels
"""
from .._constants import (
from ._constants import (
_PHOTOS_4_VERSION,
SEARCH_CATEGORY_ACTIVITY,
SEARCH_CATEGORY_ALL_LOCALITY,
SEARCH_CATEGORY_BODY_OF_WATER,
SEARCH_CATEGORY_CITY,
SEARCH_CATEGORY_COUNTRY,
SEARCH_CATEGORY_HOLIDAY,
SEARCH_CATEGORY_LABEL,
SEARCH_CATEGORY_MEDIA_TYPES,
SEARCH_CATEGORY_MONTH,
SEARCH_CATEGORY_NEIGHBORHOOD,
SEARCH_CATEGORY_PLACE_NAME,
SEARCH_CATEGORY_STREET,
SEARCH_CATEGORY_ALL_LOCALITY,
SEARCH_CATEGORY_COUNTRY,
SEARCH_CATEGORY_SEASON,
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_STREET,
SEARCH_CATEGORY_VENUE,
SEARCH_CATEGORY_VENUE_TYPE,
SEARCH_CATEGORY_MEDIA_TYPES,
SEARCH_CATEGORY_YEAR,
)
@property
def search_info(self):
""" returns SearchInfo object for photo
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
except AttributeError:
self._search_info = SearchInfo(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
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info.labels
@property
def labels_normalized(self):
""" returns normalized list of labels applied to photo by Photos image categorization
only valid on Photos 5, on older libraries returns empty list
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return []
return self.search_info_normalized.labels
class SearchInfo:
"""Info about search terms such as machine learning labels that Photos knows about a photo"""
@ -92,7 +33,7 @@ class SearchInfo:
if photo._db._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError(
f"search info not implemented for this database version"
"search info not implemented for this database version"
)
self._photo = photo

View File

@ -13,6 +13,7 @@ import pytest
import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.photoexporter import PhotoExporter
from osxphotos.utils import _get_os_version
OS_VERSION = _get_os_version()
@ -1142,6 +1143,7 @@ def test_date_invalid():
"""Test date is invalid"""
# doesn't run correctly with the module-level fixture
from datetime import datetime, timedelta, timezone
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
@ -1396,7 +1398,7 @@ def test_exiftool_newlines_in_description(photosdb):
"""Test that exiftool handles newlines embedded in description, issue #393"""
photo = photosdb.get_photo(UUID_DICT["description_newlines"])
exif = photo._exiftool_dict()
exif = PhotoExporter(photo)._exiftool_dict()
assert photo.description.find("\n") > 0
assert exif["EXIF:ImageDescription"].find("\n") > 0

View File

@ -4291,7 +4291,7 @@ def test_export_error(monkeypatch):
def throw_error(*args, **kwargs):
raise ValueError("Argh!")
monkeypatch.setattr(osxphotos.PhotoInfo, "export2", throw_error)
monkeypatch.setattr(osxphotos.PhotoExporter, "export2", throw_error)
with runner.isolated_filesystem():
result = runner.invoke(
export,

View File

@ -2,7 +2,7 @@
import pytest
from osxphotos.photoinfo import ExifInfo
from osxphotos.exifinfo import ExifInfo
PHOTOS_DB_5 = "tests/Test-Cloud-10.15.1.photoslibrary"
PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary"

View File

@ -8,6 +8,7 @@ import pytest
import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.exiftool import get_exiftool_path
from osxphotos.photoexporter import PhotoExporter
from osxphotos.utils import dd_to_dms_str
# determine if exiftool installed so exiftool tests can be skipped
@ -401,7 +402,7 @@ def test_exiftool_json_sidecar(photosdb):
with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp:
json_expected = json.load(fp)[0]
json_got = photo._exiftool_json_sidecar()
json_got = PhotoExporter(photo)._exiftool_json_sidecar()
json_got = json.loads(json_got)[0]
assert json_got == json_expected
@ -417,7 +418,7 @@ def test_exiftool_json_sidecar_ignore_date_modified(photosdb):
) as fp:
json_expected = json.load(fp)[0]
json_got = photo._exiftool_json_sidecar(ignore_date_modified=True)
json_got = PhotoExporter(photo)._exiftool_json_sidecar(ignore_date_modified=True)
json_got = json.loads(json_got)[0]
assert json_got == json_expected
@ -448,7 +449,7 @@ def test_exiftool_json_sidecar_keyword_template_long(capsys, photosdb):
long_str = "x" * (_MAX_IPTC_KEYWORD_LEN + 1)
photos[0]._verbose = print
json_got = photos[0]._exiftool_json_sidecar(keyword_template=[long_str])
json_got = PhotoExporter(photos[0])._exiftool_json_sidecar(keyword_template=[long_str])
json_got = json.loads(json_got)[0]
captured = capsys.readouterr()
@ -483,7 +484,7 @@ def test_exiftool_json_sidecar_keyword_template(photosdb):
str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.json"), "r"
) as fp:
json_expected = json.load(fp)
json_got = photo._exiftool_json_sidecar(keyword_template=["{folder_album}"])
json_got = PhotoExporter(photo)._exiftool_json_sidecar(keyword_template=["{folder_album}"])
json_got = json.loads(json_got)
assert json_got == json_expected
@ -499,7 +500,7 @@ def test_exiftool_json_sidecar_use_persons_keyword(photosdb):
) as fp:
json_expected = json.load(fp)[0]
json_got = photo._exiftool_json_sidecar(use_persons_as_keywords=True)
json_got = PhotoExporter(photo)._exiftool_json_sidecar(use_persons_as_keywords=True)
json_got = json.loads(json_got)[0]
assert json_got == json_expected
@ -515,7 +516,7 @@ def test_exiftool_json_sidecar_use_albums_keywords(photosdb):
) as fp:
json_expected = json.load(fp)
json_got = photo._exiftool_json_sidecar(use_albums_as_keywords=True)
json_got = PhotoExporter(photo)._exiftool_json_sidecar(use_albums_as_keywords=True)
json_got = json.loads(json_got)
assert json_got == json_expected
@ -528,7 +529,7 @@ def test_exiftool_sidecar(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_no_tag_groups.json", "r") as fp:
json_expected = fp.read()
json_got = photo._exiftool_json_sidecar(tag_groups=False)
json_got = PhotoExporter(photo)._exiftool_json_sidecar(tag_groups=False)
assert json_got == json_expected
@ -554,7 +555,7 @@ def test_xmp_sidecar(photosdb):
with open(f"tests/sidecars/{uuid}.xmp", "r") as file:
xmp_expected = file.read()
xmp_got = photos[0]._xmp_sidecar(extension="jpg")
xmp_got = PhotoExporter(photos[0])._xmp_sidecar(extension="jpg")
assert xmp_got == xmp_expected
@ -568,7 +569,7 @@ def test_xmp_sidecar_extension(photosdb):
xmp_expected = file.read()
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
xmp_got = photos[0]._xmp_sidecar()
xmp_got = PhotoExporter(photos[0])._xmp_sidecar()
assert xmp_got == xmp_expected
@ -580,7 +581,7 @@ def test_xmp_sidecar_use_persons_keyword(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_persons_as_keywords.xmp") as fp:
xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar(use_persons_as_keywords=True, extension="jpg")
xmp_got = PhotoExporter(photo)._xmp_sidecar(use_persons_as_keywords=True, extension="jpg")
assert xmp_got == xmp_expected
@ -592,7 +593,7 @@ def test_xmp_sidecar_use_albums_keyword(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_albums_as_keywords.xmp") as fp:
xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar(use_albums_as_keywords=True, extension="jpg")
xmp_got = PhotoExporter(photo)._xmp_sidecar(use_albums_as_keywords=True, extension="jpg")
assert xmp_got == xmp_expected
@ -605,7 +606,7 @@ def test_xmp_sidecar_gps(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}.xmp") as fp:
xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar()
xmp_got = PhotoExporter(photo)._xmp_sidecar()
assert xmp_got == xmp_expected
@ -617,7 +618,7 @@ def test_xmp_sidecar_keyword_template(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.xmp") as fp:
xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar(
xmp_got = PhotoExporter(photo)._xmp_sidecar(
keyword_template=["{created.year}", "{folder_album}"], extension="jpg"
)
assert xmp_got == xmp_expected

View File

@ -2,6 +2,7 @@ import os
import pytest
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.photoexporter import PhotoExporter
skip_test = "OSXPHOTOS_TEST_CONVERT" not in os.environ
pytestmark = pytest.mark.skipif(
@ -43,7 +44,7 @@ def test_export_convert_raw_to_jpeg(photosdb):
dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["raw"]])
results = photos[0].export2(dest, convert_to_jpeg=True)
results = PhotoExporter(photos[0]).export2(dest, convert_to_jpeg=True)
got_dest = pathlib.Path(results.exported[0])
assert got_dest.is_file()
@ -60,7 +61,7 @@ def test_export_convert_heic_to_jpeg(photosdb):
dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["heic"]])
results = photos[0].export2(dest, convert_to_jpeg=True)
results = PhotoExporter(photos[0]).export2(dest, convert_to_jpeg=True)
got_dest = pathlib.Path(results.exported[0])
assert got_dest.is_file()
@ -87,7 +88,7 @@ def test_export_convert_live_heic_to_jpeg():
dest = tempdir.name
photo = photosdb.get_photo(UUID_LIVE_HEIC)
results = photo.export2(dest, convert_to_jpeg=True, live_photo=True)
results = PhotoExporter(photo).export2(dest, convert_to_jpeg=True, live_photo=True)
for name in NAMES_LIVE_HEIC:
assert f"{tempdir.name}/{name}" in results.exported

View File

@ -1,9 +1,11 @@
import json
import pathlib
import pytest
import json
import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON
import pathlib
from osxphotos.photoexporter import PhotoExporter
PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
PHOTOS_DB_PATH = "/Test-10.14.6.photoslibrary/database/photos.db"
@ -335,7 +337,7 @@ def test_exiftool_json_sidecar(photosdb):
with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp:
json_expected = json.load(fp)[0]
json_got = photo._exiftool_json_sidecar()
json_got = PhotoExporter(photo)._exiftool_json_sidecar()
json_got = json.loads(json_got)[0]
assert json_got == json_expected
@ -349,7 +351,7 @@ def test_xmp_sidecar(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_ext.xmp") as fp:
xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar(extension="jpg")
xmp_got = PhotoExporter(photo)._xmp_sidecar(extension="jpg")
assert xmp_got == xmp_expected
@ -362,7 +364,7 @@ def test_xmp_sidecar_keyword_template(photosdb):
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.xmp") as fp:
xmp_expected = fp.read()
xmp_got = photo._xmp_sidecar(
xmp_got = PhotoExporter(photo)._xmp_sidecar(
keyword_template=["{created.year}", "{folder_album}"], extension="jpg"
)

View File

@ -1,7 +1,7 @@
""" test ExportResults class """
import pytest
from osxphotos.photoinfo import ExportResults
from osxphotos.photoexporter import ExportResults
EXPORT_RESULT_ATTRIBUTES = [
"exported",

View File

@ -13,6 +13,7 @@ import pytest
import osxphotos
from osxphotos._constants import _UNKNOWN_PERSON
from osxphotos.photoexporter import PhotoExporter
from osxphotos.utils import _get_os_version
OS_VERSION = _get_os_version()
@ -1109,6 +1110,7 @@ def test_date_invalid():
"""Test date is invalid"""
# doesn't run correctly with the module-level fixture
from datetime import datetime, timedelta, timezone
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
@ -1349,7 +1351,7 @@ def test_exiftool_newlines_in_description(photosdb):
"""Test that exiftool handles newlines embedded in description, issue #393"""
photo = photosdb.get_photo(UUID_DICT["description_newlines"])
exif = photo._exiftool_dict()
exif = PhotoExporter(photo)._exiftool_dict()
assert photo.description.find("\n") > 0
assert exif["EXIF:ImageDescription"].find("\n") > 0

View File

@ -3,7 +3,7 @@
from math import isclose
import pytest
from osxphotos.photoinfo import ScoreInfo
from osxphotos.scoreinfo import ScoreInfo
PHOTOS_DB_5 = "tests/Test-10.15.5.photoslibrary"
PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary"

View File

@ -9,6 +9,7 @@ import osxphotos
from osxphotos._constants import SIDECAR_XMP
from osxphotos.exiftool import ExifTool, get_exiftool_path
from osxphotos.fileutil import FileUtil
from osxphotos.photoexporter import PhotoExporter
PHOTOS_DB_15_7 = "tests/Test-10.15.7.photoslibrary"
@ -39,7 +40,7 @@ def test_sidecar_xmp(photosdb):
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos")
dest = tempdir.name
photo = photosdb.get_photo(uuid)
photo.export2(dest, photo.original_filename, sidecar=SIDECAR_XMP)
PhotoExporter(photo).export2(dest, photo.original_filename, sidecar=SIDECAR_XMP)
filepath = str(pathlib.Path(dest) / photo.original_filename)
xmppath = filepath + ".xmp"
@ -53,6 +54,8 @@ def test_sidecar_xmp(photosdb):
test_xmp = str(pathlib.Path(dest) / "test.xmp")
FileUtil.copy(xmppath, test_xmp)
exif = ExifTool(test_xmp)
output, warning, error = exif.run_commands("-tagsfromfile", xmppath, "-all:all", test_xmp, no_file=True)
output, warning, error = exif.run_commands(
"-tagsfromfile", xmppath, "-all:all", test_xmp, no_file=True
)
assert not warning
assert not error