Compare commits

...

13 Commits

Author SHA1 Message Date
Rhet Turnbull
d2d56a7f71 Fix for burst images with pick type = 0, partial fix for #571 2022-01-06 22:46:16 -08:00
Rhet Turnbull
b4897ff1b5 version bump [skip ci] 2022-01-06 22:16:12 -08:00
Rhet Turnbull
661a573bf5 Fix for #570 2022-01-06 22:13:25 -08:00
Rhet Turnbull
0c9bd87602 More refactoring of export code, #462 2022-01-06 05:40:47 -08:00
Rhet Turnbull
896d888710 Updated CHANGELOG.md [skip ci] 2022-01-04 06:35:23 -08:00
Rhet Turnbull
76aee7f189 Export DB can now reside outside export directory, #568 2022-01-04 06:28:59 -08:00
Rhet Turnbull
147b30f973 More refactoring of export code, #462 2022-01-02 22:38:22 -08:00
Rhet Turnbull
a73dc72558 Refactored photoinfo, photoexporter; #462 2022-01-02 09:06:04 -08:00
Rhet Turnbull
c99cf5518d Updated CHANGELOG.md [skip ci] 2021-12-31 20:50:42 -08:00
Rhet Turnbull
1391675a3a Updated tests and docs 2021-12-31 20:31:15 -08:00
Rhet Turnbull
a3b2784f31 ImageConverter now uses generic context; #562 2021-12-31 17:34:41 -08:00
Rhet Turnbull
cbe79ee98c Updated docs [skip ci] 2021-12-31 09:45:42 -08:00
Rhet Turnbull
eb7a2988bf Updated CHANGELOG.md [skip ci] 2021-12-31 09:45:19 -08:00
42 changed files with 2910 additions and 2837 deletions

View File

@@ -4,6 +4,28 @@ 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.44.4](https://github.com/RhetTbull/osxphotos/compare/v0.44.3...v0.44.4)
> 4 January 2022
- Refactored photoinfo, photoexporter; #462 [`a73dc72`](https://github.com/RhetTbull/osxphotos/commit/a73dc72558b77152f4c90f143b6a60924b8905c8)
- More refactoring of export code, #462 [`147b30f`](https://github.com/RhetTbull/osxphotos/commit/147b30f97308db65868dc7a8d177d77ad0d0ad40)
- Export DB can now reside outside export directory, #568 [`76aee7f`](https://github.com/RhetTbull/osxphotos/commit/76aee7f189b4b32e2e263a4e798711713ed17a14)
#### [v0.44.3](https://github.com/RhetTbull/osxphotos/compare/v0.44.2...v0.44.3)
> 31 December 2021
- ImageConverter now uses generic context; #562 [`a3b2784`](https://github.com/RhetTbull/osxphotos/commit/a3b2784f3177a753b78965b8ca205ca9bbb08168)
- Updated tests and docs [`1391675`](https://github.com/RhetTbull/osxphotos/commit/1391675a3a45be0d6800a68c8bcc6d0d55d1ab7a)
- Updated docs [skip ci] [`cbe79ee`](https://github.com/RhetTbull/osxphotos/commit/cbe79ee98cae68e0789df275220f5a5870a8bd91)
#### [v0.44.2](https://github.com/RhetTbull/osxphotos/compare/v0.44.1...v0.44.2)
> 31 December 2021
- Bug fix for #559 [`42426b9`](https://github.com/RhetTbull/osxphotos/commit/42426b95ee786b2d53482d3d931a0b962a4db20d)
#### [v0.44.1](https://github.com/RhetTbull/osxphotos/compare/v0.44.0...v0.44.1)
> 31 December 2021

View File

@@ -1154,14 +1154,13 @@ Options:
You can run more than one function by
repeating the '--post-function' option with
different arguments. See Post Function below.
--exportdb EXPORTDB_FILE Specify alternate name for database file which
--exportdb EXPORTDB_FILE Specify alternate path 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.
directory. If --exportdb is specified, it
will be saved to the specified file.
--load-config <config file path>
Load options from file as written with --save-
config. This allows you to save a complex
@@ -1721,7 +1720,7 @@ Substitution Description
{lf} A line feed: '\n', alias for {newline}
{cr} A carriage return: '\r'
{crlf} a carriage return + line feed: '\r\n'
{osxphotos_version} The osxphotos version, e.g. '0.44.1'
{osxphotos_version} The osxphotos version, e.g. '0.44.6'
{osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for
@@ -3623,7 +3622,7 @@ The following template field substitutions are availabe for use the templating s
|{lf}|A line feed: '\n', alias for {newline}|
|{cr}|A carriage return: '\r'|
|{crlf}|a carriage return + line feed: '\r\n'|
|{osxphotos_version}|The osxphotos version, e.g. '0.44.1'|
|{osxphotos_version}|The osxphotos version, e.g. '0.44.6'|
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|{album}|Album(s) photo is contained in|
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|

View File

@@ -1,4 +1,4 @@
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: 58505ca56d322ccc59c38bc440ccc347
config: 12e2b2711a035185a2f8b8e500263a8d
tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@@ -1,6 +1,6 @@
var DOCUMENTATION_OPTIONS = {
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
VERSION: '0.44.1',
VERSION: '0.44.6',
LANGUAGE: 'None',
COLLAPSE_INDEX: false,
BUILDER: 'html',

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.44.1 documentation</title>
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.44.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &#8212; osxphotos 0.44.1 documentation</title>
<title>Index &#8212; osxphotos 0.44.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.44.1 documentation</title>
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.44.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>osxphotos &#8212; osxphotos 0.44.1 documentation</title>
<title>osxphotos &#8212; osxphotos 0.44.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -6,7 +6,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
<title>osxphotos package &#8212; osxphotos 0.44.1 documentation</title>
<title>osxphotos package &#8212; osxphotos 0.44.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &#8212; osxphotos 0.44.1 documentation</title>
<title>Search &#8212; osxphotos 0.44.6 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />

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

@@ -258,6 +258,7 @@ EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db"
# bit flags for burst images ("burstPickType")
BURST_PICK_TYPE_NONE = 0b0 # 0: sometimes used for single images with a burst UUID
BURST_NOT_SELECTED = 0b10 # 2: burst image is not selected
BURST_DEFAULT_PICK = 0b100 # 4: burst image is the one Photos picked to be key image before any selections made
BURST_SELECTED = 0b1000 # 8: burst image is selected

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.44.2"
__version__ = "0.44.6"

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
@@ -1059,10 +1060,9 @@ def cli(ctx, db, json_, debug):
metavar="EXPORTDB_FILE",
default=None,
help=(
"Specify alternate name for database file which stores state information for export and --update. "
"Specify alternate path 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."
"in the export directory. If --exportdb is specified, it will be saved to the specified file. "
),
type=click.Path(),
)
@@ -1572,25 +1572,23 @@ def export(
# 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():
if 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,
)
)
if pathlib.Path(exportdb).resolve().parent != pathlib.Path(dest):
click.echo(
click.style(
f"Warning: export database '{pathlib.Path(exportdb).resolve()}' is in a different directory than export destination '{dest}'",
fg=CLI_COLOR_WARNING,
)
)
# open export database and assign copy/link/unlink functions
export_db_path = os.path.join(dest, exportdb or OSXPHOTOS_EXPORT_DB)
export_db_path = exportdb or os.path.join(dest, 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)
@@ -1613,10 +1611,10 @@ def export(
click.confirm("Do you want to continue?", abort=True)
if dry_run:
export_db = ExportDBInMemory(export_db_path)
export_db = ExportDBInMemory(dbfile=export_db_path, export_dir=dest)
fileutil = FileUtilNoOp
else:
export_db = ExportDB(export_db_path)
export_db = ExportDB(dbfile=export_db_path, export_dir=dest)
fileutil = FileUtil
if verbose_:
@@ -2954,12 +2952,11 @@ def export_photo_to_directory(
tries += 1
error = 0
try:
export_results = photo.export2(
dest_path,
original_filename=filename,
exporter = PhotoExporter(photo)
export_results = exporter.export2(
dest=dest_path,
edited=edited,
original=export_original,
edited_filename=filename,
filename=filename,
sidecar=sidecar_flags,
sidecar_drop_ext=sidecar_drop_ext,
live_photo=export_live,
@@ -3466,7 +3463,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,
@@ -4205,8 +4202,12 @@ def repl(ctx, cli_obj, db, emacs):
from osxphotos import ExifTool, PhotoInfo, PhotosDB
from osxphotos.albuminfo import AlbumInfo
from osxphotos.momentinfo import MomentInfo
from osxphotos.photoexporter import ExportResults, PhotoExporter
from osxphotos.placeinfo import PlaceInfo
from osxphotos.queryoptions import QueryOptions
from osxphotos.scoreinfo import ScoreInfo
from osxphotos.searchinfo import SearchInfo
logger = logging.getLogger()
logger.disabled = True
@@ -4239,7 +4240,9 @@ def repl(ctx, cli_obj, db, emacs):
print(f"Found {len(photos)} photos in {tictoc:0.2f} seconds\n")
print("The following classes have been imported from osxphotos:")
print("- AlbumInfo, ExifTool, PhotoInfo, PhotosDB, PlaceInfo, QueryOptions\n")
print(
"- AlbumInfo, ExifTool, PhotoInfo, PhotoExporter, ExportResults, PhotosDB, PlaceInfo, QueryOptions, MomentInfo, ScoreInfo, SearchInfo\n"
)
print("The following variables are defined:")
print(f"- photosdb: PhotosDB() instance for {photosdb.library_path}")
print(

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

View File

@@ -1,5 +1,6 @@
""" Helper class for managing a database used by PhotoInfo.export for tracking state of exports and updates """
import datetime
import logging
import os
@@ -14,7 +15,7 @@ from ._constants import OSXPHOTOS_EXPORT_DB
from ._version import __version__
OSXPHOTOS_EXPORTDB_VERSION = "4.0"
OSXPHOTOS_ABOUT_STRING = f"Created by osxphotos version {__version__} (https://github.com/RhetTbull/osxphotos) on {str(datetime.datetime.now())}"
OSXPHOTOS_ABOUT_STRING = f"Created by osxphotos version {__version__} (https://github.com/RhetTbull/osxphotos) on {datetime.datetime.now()}"
class ExportDB_ABC(ABC):
@@ -171,7 +172,7 @@ class ExportDBNoOp(ExportDB_ABC):
return []
def get_detected_text_for_uuid(self, uuid):
return None
return None
def set_detected_text_for_uuid(self, uuid, json_text):
pass
@@ -193,15 +194,14 @@ class ExportDBNoOp(ExportDB_ABC):
class ExportDB(ExportDB_ABC):
"""Interface to sqlite3 database used to store state information for osxphotos export command"""
def __init__(self, dbfile):
def __init__(self, dbfile, export_dir):
"""dbfile: path to osxphotos export database file"""
self._dbfile = dbfile
# _path is parent of the database
# all files referenced by get_/set_uuid_for_file will be converted to
# relative paths to this parent _path
# export_dir is required as all files referenced by get_/set_uuid_for_file will be converted to
# relative paths to this path
# this allows the entire export tree to be moved to a new disk/location
# whilst preserving the UUID to filename mapping
self._path = pathlib.Path(dbfile).parent
self._path = export_dir
self._conn = self._open_export_db(dbfile)
self._insert_run_info()
@@ -214,14 +214,13 @@ class ExportDB(ExportDB_ABC):
try:
c = conn.cursor()
c.execute(
f"SELECT uuid FROM files WHERE filepath_normalized = ?", (filename,)
"SELECT uuid FROM files WHERE filepath_normalized = ?", (filename,)
)
results = c.fetchone()
uuid = results[0] if results else None
except Error as e:
logging.warning(e)
uuid = None
return uuid
def set_uuid_for_file(self, filename, uuid):
@@ -232,9 +231,10 @@ class ExportDB(ExportDB_ABC):
try:
c = conn.cursor()
c.execute(
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
(filename, filename_normalized, uuid),
)
conn.commit()
except Error as e:
logging.warning(e)
@@ -274,15 +274,14 @@ class ExportDB(ExportDB_ABC):
)
results = c.fetchone()
if results:
stats = results[0:3]
stats = results[:3]
mtime = int(stats[2]) if stats[2] is not None else None
stats = (stats[0], stats[1], mtime)
else:
stats = (None, None, None)
except Error as e:
logging.warning(e)
stats = (None, None, None)
stats = None, None, None
return stats
def set_stat_edited_for_file(self, filename, stats):
@@ -332,15 +331,14 @@ class ExportDB(ExportDB_ABC):
)
results = c.fetchone()
if results:
stats = results[0:3]
stats = results[:3]
mtime = int(stats[2]) if stats[2] is not None else None
stats = (stats[0], stats[1], mtime)
else:
stats = (None, None, None)
except Error as e:
logging.warning(e)
stats = (None, None, None)
stats = None, None, None
return stats
def set_stat_converted_for_file(self, filename, stats):
@@ -493,7 +491,10 @@ class ExportDB(ExportDB_ABC):
c = conn.cursor()
c.execute(
"INSERT OR REPLACE INTO detected_text(uuid, text_data) VALUES (?, ?);",
(uuid, text_json,),
(
uuid,
text_json,
),
)
conn.commit()
except Error as e:
@@ -517,9 +518,10 @@ class ExportDB(ExportDB_ABC):
try:
c = conn.cursor()
c.execute(
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
(filename, filename_normalized, uuid),
)
c.execute(
"UPDATE files "
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
@@ -582,7 +584,7 @@ class ExportDB(ExportDB_ABC):
)
results = c.fetchone()
if results:
stats = results[0:3]
stats = results[:3]
mtime = int(stats[2]) if stats[2] is not None else None
stats = (stats[0], stats[1], mtime)
else:
@@ -741,9 +743,10 @@ class ExportDB(ExportDB_ABC):
try:
c = conn.cursor()
c.execute(
f"INSERT INTO runs (datetime, python_path, script_name, args, cwd) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO runs (datetime, python_path, script_name, args, cwd) VALUES (?, ?, ?, ?, ?)",
(dt, python_path, cmd, args, cwd),
)
conn.commit()
except Error as e:
logging.warning(e)
@@ -755,14 +758,13 @@ class ExportDBInMemory(ExportDB):
modifying the on-disk version
"""
def __init__(self, dbfile):
def __init__(self, dbfile, export_dir):
self._dbfile = dbfile or f"./{OSXPHOTOS_EXPORT_DB}"
# _path is parent of the database
# all files referenced by get_/set_uuid_for_file will be converted to
# relative paths to this parent _path
# export_dir is required as all files referenced by get_/set_uuid_for_file will be converted to
# relative paths to this path
# this allows the entire export tree to be moved to a new disk/location
# whilst preserving the UUID to filename mapping
self._path = pathlib.Path(self._dbfile).parent
self._path = export_dir
self._conn = self._open_export_db(self._dbfile)
self._insert_run_info()

View File

@@ -17,25 +17,25 @@ from wurlitzer import pipes
class ImageConversionError(Exception):
"""Base class for exceptions in this module. """
"""Base class for exceptions in this module."""
pass
class ImageConverter:
""" Convert images to jpeg. This class is a singleton
which will re-use the Core Image CIContext to avoid
creating a new context for every conversion. """
"""Convert images to jpeg. This class is a singleton
which will re-use the Core Image CIContext to avoid
creating a new context for every conversion."""
def __new__(cls, *args, **kwargs):
""" create new object or return instance of already created singleton """
"""create new object or return instance of already created singleton"""
if not hasattr(cls, "instance") or not cls.instance:
cls.instance = super().__new__(cls)
return cls.instance
def __init__(self):
""" return existing singleton or create a new one """
"""return existing singleton or create a new one"""
if hasattr(self, "context"):
return
@@ -47,13 +47,10 @@ class ImageConverter:
"workingFormat": Quartz.kCIFormatRGBAh,
}
)
mtldevice = Metal.MTLCreateSystemDefaultDevice()
self.context = Quartz.CIContext.contextWithMTLDevice_options_(
mtldevice, context_options
)
self.context = Quartz.CIContext.contextWithOptions_(context_options)
def write_jpeg(self, input_path, output_path, compression_quality=1.0):
""" convert image to jpeg and write image to output_path
"""convert image to jpeg and write image to output_path
Args:
input_path: path to input image (e.g. '/path/to/import/file.CR2') as str or pathlib.Path
@@ -104,8 +101,11 @@ class ImageConverter:
if input_image is None:
raise ImageConversionError(f"Could not create CIImage for {input_path}")
output_colorspace = input_image.colorSpace() or Quartz.CGColorSpaceCreateWithName(
Quartz.CoreGraphics.kCGColorSpaceSRGB
output_colorspace = (
input_image.colorSpace()
or Quartz.CGColorSpaceCreateWithName(
Quartz.CoreGraphics.kCGColorSpaceSRGB
)
)
output_options = NSDictionary.dictionaryWithDictionary_(
@@ -123,4 +123,3 @@ class ImageConverter:
raise ImageConversionError(
f"Error converting file {input_path} to jpeg at {output_path}: {error}"
)

2135
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"""
@@ -750,8 +725,10 @@ class PhotoInfo:
self._uti_original = self.uti
elif self._db._photos_ver >= 7:
# Monterey+
self._uti_original = get_uti_for_extension(
pathlib.Path(self.original_filename).suffix
# there are some cases with UTI_original is None (photo imported with no extension) so fallback to UTI and hope it's right
self._uti_original = (
get_uti_for_extension(pathlib.Path(self.original_filename).suffix)
or self.uti
)
else:
self._uti_original = self._info["UTI_original"]
@@ -1050,7 +1027,7 @@ class PhotoInfo:
@property
def israw(self):
"""returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw"""
return "raw-image" in self.uti_original
return "raw-image" in self.uti_original if self.uti_original else False
@property
def raw_original(self):
@@ -1140,21 +1117,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 +1408,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

View File

@@ -30,6 +30,7 @@ import Photos
import Quartz
from Foundation import NSNotificationCenter, NSObject
from PyObjCTools import AppHelper
from wurlitzer import pipes
from .fileutil import FileUtil
from .uti import get_preferred_uti_extension
@@ -495,69 +496,74 @@ class PhotoAsset:
"""
with objc.autorelease_pool():
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
with pipes() as (out, err):
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
output_file = None
if self.isphoto:
# will hold exported image data and needs to be cleaned up at end
imagedata = None
if raw:
# export the raw component
resources = self._resources()
for resource in resources:
if resource.type() == Photos.PHAssetResourceTypeAlternatePhoto:
data = self._request_resource_data(resource)
ext = pathlib.Path(self.raw_filename).suffix[1:]
break
output_file = None
if self.isphoto:
# will hold exported image data and needs to be cleaned up at end
imagedata = None
if raw:
# export the raw component
resources = self._resources()
for resource in resources:
if (
resource.type()
== Photos.PHAssetResourceTypeAlternatePhoto
):
data = self._request_resource_data(resource)
suffix = pathlib.Path(self.raw_filename).suffix
ext = suffix[1:] if suffix else ""
break
else:
raise PhotoKitExportError(
"Could not get image data for RAW photo"
)
else:
raise PhotoKitExportError(
"Could not get image data for RAW photo"
)
else:
# TODO: if user has selected use RAW as original, this returns the RAW
# can get the jpeg with resource.type() == Photos.PHAssetResourceTypePhoto
imagedata = self._request_image_data(version=version)
if not imagedata.image_data:
raise PhotoKitExportError("Could not get image data")
ext = get_preferred_uti_extension(imagedata.uti)
data = imagedata.image_data
# TODO: if user has selected use RAW as original, this returns the RAW
# can get the jpeg with resource.type() == Photos.PHAssetResourceTypePhoto
imagedata = self._request_image_data(version=version)
if not imagedata.image_data:
raise PhotoKitExportError("Could not get image data")
ext = get_preferred_uti_extension(imagedata.uti)
data = imagedata.image_data
output_file = dest / f"{filename.stem}.{ext}"
output_file = dest / f"{filename.stem}.{ext}"
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
with open(output_file, "wb") as fd:
fd.write(data)
with open(output_file, "wb") as fd:
fd.write(data)
if imagedata:
del imagedata
elif self.ismovie:
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
if imagedata:
del imagedata
elif self.ismovie:
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
FileUtil.copy(path, output_file)
FileUtil.copy(path, output_file)
return [str(output_file)]
return [str(output_file)]
def _request_image_data(self, version=PHOTOS_VERSION_ORIGINAL):
"""Request image data and metadata for self._phasset
@@ -807,42 +813,46 @@ class VideoAsset(PhotoAsset):
"""
with objc.autorelease_pool():
if self.slow_mo and version == PHOTOS_VERSION_CURRENT:
return [
self._export_slow_mo(
dest, filename=filename, version=version, overwrite=overwrite
)
]
with pipes() as (out, err):
if self.slow_mo and version == PHOTOS_VERSION_CURRENT:
return [
self._export_slow_mo(
dest,
filename=filename,
version=version,
overwrite=overwrite,
)
]
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
output_file = None
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
output_file = None
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
del videodata
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
del videodata
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
FileUtil.copy(path, output_file)
FileUtil.copy(path, output_file)
return [str(output_file)]
return [str(output_file)]
def _export_slow_mo(
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
@@ -1053,64 +1063,69 @@ class LivePhotoAsset(PhotoAsset):
"""
with objc.autorelease_pool():
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
request = LivePhotoRequest.alloc().initWithManager_Asset_(
self._manager, self.phasset
)
resources = request.requestLivePhotoResources(version=version)
video_resource = None
photo_resource = None
for resource in resources:
if resource.type() == Photos.PHAssetResourceTypePairedVideo:
video_resource = resource
elif resource.type() == Photos.PHAssetMediaTypeImage:
photo_resource = resource
if not video_resource or not photo_resource:
raise PhotoKitExportError(
"Did not find photo/video resources for live photo"
with pipes() as (out, err):
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
photo_ext = get_preferred_uti_extension(
photo_resource.uniformTypeIdentifier()
)
photo_output_file = dest / f"{filename.stem}.{photo_ext}"
video_ext = get_preferred_uti_extension(
video_resource.uniformTypeIdentifier()
)
video_output_file = dest / f"{filename.stem}.{video_ext}"
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
if not overwrite:
photo_output_file = pathlib.Path(increment_filename(photo_output_file))
video_output_file = pathlib.Path(increment_filename(video_output_file))
request = LivePhotoRequest.alloc().initWithManager_Asset_(
self._manager, self.phasset
)
resources = request.requestLivePhotoResources(version=version)
exported = []
if photo:
data = self._request_resource_data(photo_resource)
# image_data = self.request_image_data(version=version)
with open(photo_output_file, "wb") as fd:
fd.write(data)
exported.append(str(photo_output_file))
del data
if video:
data = self._request_resource_data(video_resource)
with open(video_output_file, "wb") as fd:
fd.write(data)
exported.append(str(video_output_file))
del data
video_resource = None
photo_resource = None
for resource in resources:
if resource.type() == Photos.PHAssetResourceTypePairedVideo:
video_resource = resource
elif resource.type() == Photos.PHAssetMediaTypeImage:
photo_resource = resource
request.dealloc()
return exported
if not video_resource or not photo_resource:
raise PhotoKitExportError(
"Did not find photo/video resources for live photo"
)
photo_ext = get_preferred_uti_extension(
photo_resource.uniformTypeIdentifier()
)
photo_output_file = dest / f"{filename.stem}.{photo_ext}"
video_ext = get_preferred_uti_extension(
video_resource.uniformTypeIdentifier()
)
video_output_file = dest / f"{filename.stem}.{video_ext}"
if not overwrite:
photo_output_file = pathlib.Path(
increment_filename(photo_output_file)
)
video_output_file = pathlib.Path(
increment_filename(video_output_file)
)
exported = []
if photo:
data = self._request_resource_data(photo_resource)
# image_data = self.request_image_data(version=version)
with open(photo_output_file, "wb") as fd:
fd.write(data)
exported.append(str(photo_output_file))
del data
if video:
data = self._request_resource_data(video_resource)
with open(video_output_file, "wb") as fd:
fd.write(data)
exported.append(str(video_output_file))
del data
request.dealloc()
return exported
class PhotoLibrary:

View File

@@ -27,11 +27,11 @@ from .._constants import (
_PHOTO_TYPE,
_PHOTOS_3_VERSION,
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_TOP_LEVEL_ALBUMS,
_PHOTOS_4_ALBUM_TYPE_ALBUM,
_PHOTOS_4_ALBUM_TYPE_PROJECT,
_PHOTOS_4_ALBUM_TYPE_SLIDESHOW,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_TOP_LEVEL_ALBUMS,
_PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_FOLDER_KIND,
@@ -42,6 +42,7 @@ from .._constants import (
_TESTED_OS_VERSIONS,
_UNKNOWN_PERSON,
BURST_KEY,
BURST_PICK_TYPE_NONE,
BURST_SELECTED,
TIME_DELTA,
)
@@ -3062,6 +3063,7 @@ class PhotosDB:
if self._dbphotos[p]["burst"] and not (
self._dbphotos[p]["burstPickType"] & BURST_SELECTED
or self._dbphotos[p]["burstPickType"] & BURST_KEY
or self._dbphotos[p]["burstPickType"] == BURST_PICK_TYPE_NONE
):
# not a key/selected burst photo, don't include in returned results
continue

View File

@@ -375,7 +375,9 @@ class PhotoTemplate:
self.filepath = options.filepath
self.quote = options.quote
self.dest_path = options.dest_path
self.exportdb = options.exportdb or ExportDBInMemory(None)
self.exportdb = options.exportdb or ExportDBInMemory(
None, self.export_dir or "."
)
def render(
self,

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,98 +1,39 @@
""" 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 """
"""Info about search terms such as machine learning labels that Photos knows about a photo"""
def __init__(self, photo, normalized=False):
""" photo: PhotoInfo object
normalized: if True, all properties return normalized (lower case) results """
"""photo: PhotoInfo object
normalized: if True, all properties return normalized (lower case) results"""
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
@@ -107,27 +48,27 @@ class SearchInfo:
@property
def labels(self):
""" return list of labels associated with Photo """
"""return list of labels associated with Photo"""
return self._get_text_for_category(SEARCH_CATEGORY_LABEL)
@property
def place_names(self):
""" returns list of place names """
"""returns list of place names"""
return self._get_text_for_category(SEARCH_CATEGORY_PLACE_NAME)
@property
def streets(self):
""" returns list of street names """
"""returns list of street names"""
return self._get_text_for_category(SEARCH_CATEGORY_STREET)
@property
def neighborhoods(self):
""" returns list of neighborhoods """
"""returns list of neighborhoods"""
return self._get_text_for_category(SEARCH_CATEGORY_NEIGHBORHOOD)
@property
def locality_names(self):
""" returns list of other locality names """
"""returns list of other locality names"""
locality = []
for category in SEARCH_CATEGORY_ALL_LOCALITY:
locality += self._get_text_for_category(category)
@@ -135,74 +76,74 @@ class SearchInfo:
@property
def city(self):
""" returns city/town """
"""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 """
"""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 """
"""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 """
"""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 """
"""returns month name"""
month = self._get_text_for_category(SEARCH_CATEGORY_MONTH)
return month[0] if month else ""
@property
def year(self):
""" returns year """
"""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 """
"""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 """
"""returns list of holiday names"""
return self._get_text_for_category(SEARCH_CATEGORY_HOLIDAY)
@property
def activities(self):
""" returns list of activity names """
"""returns list of activity names"""
return self._get_text_for_category(SEARCH_CATEGORY_ACTIVITY)
@property
def season(self):
""" returns season name """
"""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 """
"""returns list of venue names"""
return self._get_text_for_category(SEARCH_CATEGORY_VENUE)
@property
def venue_types(self):
""" returns list of venue types """
"""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) """
"""returns list of media types (photo, video, panorama, etc)"""
types = []
for category in SEARCH_CATEGORY_MEDIA_TYPES:
types += self._get_text_for_category(category)
@@ -210,7 +151,7 @@ class SearchInfo:
@property
def all(self):
""" return all search info properties in a single list """
"""return all search info properties in a single list"""
all = (
self.labels
+ self.place_names
@@ -242,7 +183,7 @@ class SearchInfo:
return all
def asdict(self):
""" return dict of search info """
"""return dict of search info"""
return {
"labels": self.labels,
"place_names": self.place_names,
@@ -265,7 +206,7 @@ class SearchInfo:
}
def _get_text_for_category(self, category):
""" return list of text for a specified category ID """
"""return list of text for a specified category ID"""
if self._db_searchinfo:
content = "normalized_string" if self._normalized else "content_string"
return [

View File

@@ -591,6 +591,9 @@ def get_preferred_uti_extension(uti):
def get_uti_for_extension(extension):
"""get UTI for a given file extension"""
if not extension:
return None
# accepts extension with or without leading 0
if extension[0] == ".":
extension = extension[1:]

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,
@@ -5610,7 +5610,7 @@ def test_export_ignore_signature_sidecar():
# change the sidecar data in export DB
# should result in a new sidecar being exported but not the image itself
exportdb = osxphotos.export_db.ExportDB("./.osxphotos_export.db")
exportdb = osxphotos.export_db.ExportDB("./.osxphotos_export.db", ".")
for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES:
exportdb.set_sidecar_for_file(f"{filename}.xmp", "FOO", (0, 1, 2))
@@ -6163,16 +6163,6 @@ def test_export_exportdb():
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
)
def test_export_finder_tag_keywords():
"""test --finder-tag-keywords"""

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"
@@ -82,14 +82,14 @@ def photosdb():
def test_exif_info_v5(photosdb):
""" test exif_info """
"""test exif_info"""
for uuid in EXIF_DICT:
photo = photosdb.photos(uuid=[uuid], movies=True)[0]
assert photo.exif_info == EXIF_DICT[uuid]
def test_exif_info_v4():
""" test version 4, exif_info should be None """
"""test version 4, exif_info should be None"""
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_4)

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

@@ -22,7 +22,7 @@ def test_export_db():
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dbname = os.path.join(tempdir.name, ".osxphotos_export.db")
db = ExportDB(dbname)
db = ExportDB(dbname, tempdir.name)
assert os.path.isfile(dbname)
assert db.was_created
assert not db.was_upgraded
@@ -76,7 +76,7 @@ def test_export_db():
# close and re-open
db.close()
db = ExportDB(dbname)
db = ExportDB(dbname, tempdir.name)
assert not db.was_created
assert db.get_uuid_for_file(filepath2) == "BAR-FOO"
assert db.get_info_for_uuid("BAR-FOO") == INFO_DATA
@@ -131,7 +131,7 @@ def test_export_db_no_op():
db.set_detected_text_for_uuid("FOO-BAR", json.dumps([["foo", 0.5]]))
assert db.get_detected_text_for_uuid("FOO-BAR") is None
# test set_data which sets all at the same time
filepath2 = os.path.join(tempdir.name, "test2.jpg")
db.set_data(
@@ -171,7 +171,7 @@ def test_export_db_in_memory():
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dbname = os.path.join(tempdir.name, ".osxphotos_export.db")
db = ExportDB(dbname)
db = ExportDB(dbname, tempdir.name)
assert os.path.isfile(dbname)
filepath = os.path.join(tempdir.name, "test.JPG")
@@ -190,7 +190,7 @@ def test_export_db_in_memory():
db.close()
dbram = ExportDBInMemory(dbname)
dbram = ExportDBInMemory(dbname, tempdir.name)
assert not dbram.was_created
assert not dbram.was_upgraded
assert dbram.version == OSXPHOTOS_EXPORTDB_VERSION
@@ -232,7 +232,7 @@ def test_export_db_in_memory():
dbram.close()
# re-open on disk and verify no changes
db = ExportDB(dbname)
db = ExportDB(dbname, tempdir.name)
assert db.get_uuid_for_file(filepath_lower) == "FOO-BAR"
assert db.get_info_for_uuid("FOO-BAR") == INFO_DATA
assert db.get_exifdata_for_file(filepath) == EXIF_DATA
@@ -258,7 +258,9 @@ def test_export_db_in_memory_nofile():
filepath = os.path.join(tempdir.name, "test.JPG")
filepath_lower = os.path.join(tempdir.name, "test.jpg")
dbram = ExportDBInMemory(os.path.join(tempdir.name, "NOT_A_DATABASE_FILE.db"))
dbram = ExportDBInMemory(
os.path.join(tempdir.name, "NOT_A_DATABASE_FILE.db"), tempdir.name
)
assert dbram.was_created
assert not dbram.was_upgraded
assert dbram.version == OSXPHOTOS_EXPORTDB_VERSION

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