Added --dry-run option to CLI export, closes #91
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
""" command line interface for osxphotos """
|
||||
import csv
|
||||
import datetime
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -20,15 +22,16 @@ from pathvalidate import (
|
||||
import osxphotos
|
||||
|
||||
from ._constants import _EXIF_TOOL_URL, _PHOTOS_4_VERSION, _UNKNOWN_PLACE
|
||||
from .datetime_formatter import DateTimeFormatter
|
||||
from ._version import __version__
|
||||
from .exiftool import get_exiftool_path
|
||||
from .fileutil import FileUtil, FileUtilNoOp
|
||||
from .photoinfo import ExportResults
|
||||
from .photoinfo.template import (
|
||||
TEMPLATE_SUBSTITUTIONS,
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
||||
)
|
||||
from .utils import _copy_file, create_path_by_date
|
||||
from ._export_db import ExportDB
|
||||
from ._export_db import ExportDB, ExportDBInMemory
|
||||
|
||||
# global variable to control verbose output
|
||||
# set via --verbose/-V
|
||||
@@ -111,9 +114,11 @@ class ExportCommand(click.Command):
|
||||
+ "should be treated as a backup, not a working copy where you intend to make changes. "
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text("Note: The number of files reported for export and the number actually exported "
|
||||
+"may differ due to live photos, associated RAW images, and edited photos which are reported "
|
||||
+"in the total photos exported.")
|
||||
formatter.write_text(
|
||||
"Note: The number of files reported for export and the number actually exported "
|
||||
+ "may differ due to live photos, associated RAW images, and edited photos which are reported "
|
||||
+ "in the total photos exported."
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"Implementation note: To determine which files need to be updated, "
|
||||
@@ -916,6 +921,11 @@ def query(
|
||||
is_flag=True,
|
||||
help="Only export new or updated files. See notes below on export and --update.",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
help="Dry run (test) the export but don't actually export any files; most useful with --verbose",
|
||||
)
|
||||
@click.option(
|
||||
"--export-as-hardlink",
|
||||
is_flag=True,
|
||||
@@ -1068,6 +1078,7 @@ def export(
|
||||
to_date,
|
||||
verbose_,
|
||||
update,
|
||||
dry_run,
|
||||
export_as_hardlink,
|
||||
overwrite,
|
||||
export_by_date,
|
||||
@@ -1190,8 +1201,15 @@ def export(
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
# open export database
|
||||
export_db = ExportDB(os.path.join(dest, OSXPHOTOS_EXPORT_DB))
|
||||
# open export database and assign copy/link/unlink functions
|
||||
if dry_run:
|
||||
export_db = ExportDBInMemory(os.path.join(dest, OSXPHOTOS_EXPORT_DB))
|
||||
# echo = functools.partial(click.echo, err=True)
|
||||
# fileutil = FileUtilNoOp(verbose=echo)
|
||||
fileutil = FileUtilNoOp
|
||||
else:
|
||||
export_db = ExportDB(os.path.join(dest, OSXPHOTOS_EXPORT_DB))
|
||||
fileutil = FileUtil
|
||||
|
||||
photos = _query(
|
||||
db=db,
|
||||
@@ -1269,26 +1287,28 @@ def export(
|
||||
with click.progressbar(photos) as bar:
|
||||
for p in bar:
|
||||
results = export_photo(
|
||||
p,
|
||||
dest,
|
||||
verbose_,
|
||||
export_by_date,
|
||||
sidecar,
|
||||
update,
|
||||
export_as_hardlink,
|
||||
overwrite,
|
||||
export_edited,
|
||||
original_name,
|
||||
export_live,
|
||||
download_missing,
|
||||
exiftool,
|
||||
directory,
|
||||
no_extended_attributes,
|
||||
export_raw,
|
||||
album_keyword,
|
||||
person_keyword,
|
||||
keyword_template,
|
||||
export_db,
|
||||
photo=p,
|
||||
dest=dest,
|
||||
verbose_=verbose_,
|
||||
export_by_date=export_by_date,
|
||||
sidecar=sidecar,
|
||||
update=update,
|
||||
export_as_hardlink=export_as_hardlink,
|
||||
overwrite=overwrite,
|
||||
export_edited=export_edited,
|
||||
original_name=original_name,
|
||||
export_live=export_live,
|
||||
download_missing=download_missing,
|
||||
exiftool=exiftool,
|
||||
directory=directory,
|
||||
no_extended_attributes=no_extended_attributes,
|
||||
export_raw=export_raw,
|
||||
album_keyword=album_keyword,
|
||||
person_keyword=person_keyword,
|
||||
keyword_template=keyword_template,
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
dry_run = dry_run,
|
||||
)
|
||||
results_exported.extend(results.exported)
|
||||
results_new.extend(results.new)
|
||||
@@ -1298,26 +1318,28 @@ def export(
|
||||
else:
|
||||
for p in photos:
|
||||
results = export_photo(
|
||||
p,
|
||||
dest,
|
||||
verbose_,
|
||||
export_by_date,
|
||||
sidecar,
|
||||
update,
|
||||
export_as_hardlink,
|
||||
overwrite,
|
||||
export_edited,
|
||||
original_name,
|
||||
export_live,
|
||||
download_missing,
|
||||
exiftool,
|
||||
directory,
|
||||
no_extended_attributes,
|
||||
export_raw,
|
||||
album_keyword,
|
||||
person_keyword,
|
||||
keyword_template,
|
||||
export_db,
|
||||
photo=p,
|
||||
dest=dest,
|
||||
verbose_=verbose_,
|
||||
export_by_date=export_by_date,
|
||||
sidecar=sidecar,
|
||||
update=update,
|
||||
export_as_hardlink=export_as_hardlink,
|
||||
overwrite=overwrite,
|
||||
export_edited=export_edited,
|
||||
original_name=original_name,
|
||||
export_live=export_live,
|
||||
download_missing=download_missing,
|
||||
exiftool=exiftool,
|
||||
directory=directory,
|
||||
no_extended_attributes=no_extended_attributes,
|
||||
export_raw=export_raw,
|
||||
album_keyword=album_keyword,
|
||||
person_keyword=person_keyword,
|
||||
keyword_template=keyword_template,
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
results_exported.extend(results.exported)
|
||||
results_new.extend(results.new)
|
||||
@@ -1715,26 +1737,28 @@ def _query(
|
||||
|
||||
|
||||
def export_photo(
|
||||
photo,
|
||||
dest,
|
||||
verbose_,
|
||||
export_by_date,
|
||||
sidecar,
|
||||
update,
|
||||
export_as_hardlink,
|
||||
overwrite,
|
||||
export_edited,
|
||||
original_name,
|
||||
export_live,
|
||||
download_missing,
|
||||
exiftool,
|
||||
directory,
|
||||
no_extended_attributes,
|
||||
export_raw,
|
||||
album_keyword,
|
||||
person_keyword,
|
||||
keyword_template,
|
||||
export_db,
|
||||
photo=None,
|
||||
dest=None,
|
||||
verbose_=None,
|
||||
export_by_date=None,
|
||||
sidecar=None,
|
||||
update=None,
|
||||
export_as_hardlink=None,
|
||||
overwrite=None,
|
||||
export_edited=None,
|
||||
original_name=None,
|
||||
export_live=None,
|
||||
download_missing=None,
|
||||
exiftool=None,
|
||||
directory=None,
|
||||
no_extended_attributes=None,
|
||||
export_raw=None,
|
||||
album_keyword=None,
|
||||
person_keyword=None,
|
||||
keyword_template=None,
|
||||
export_db=None,
|
||||
fileutil=FileUtil,
|
||||
dry_run=None,
|
||||
):
|
||||
""" Helper function for export that does the actual export
|
||||
photo: PhotoInfo object
|
||||
@@ -1755,6 +1779,9 @@ def export_photo(
|
||||
album_keyword: boolean; if True, exports album names as keywords in metadata
|
||||
person_keyword: boolean; if True, exports person names as keywords in metadata
|
||||
keyword_template: list of strings; if provided use rendered template strings as keywords
|
||||
export_db: export database instance compatible with ExportDB_ABC
|
||||
fileutil: file util class compatible with FileUtilABC
|
||||
dry_run: boolean; if True, doesn't actually export or update any files
|
||||
returns list of path(s) of exported photo or None if photo was missing
|
||||
"""
|
||||
global VERBOSE
|
||||
@@ -1791,8 +1818,10 @@ def export_photo(
|
||||
verbose(f"Exporting {photo.filename} as {filename}")
|
||||
|
||||
if export_by_date:
|
||||
date_created = photo.date.timetuple()
|
||||
dest_path = create_path_by_date(dest, date_created)
|
||||
date_created = DateTimeFormatter(photo.date)
|
||||
dest_path = os.path.join(dest, date_created.year, date_created.mm, date_created.dd)
|
||||
if not dry_run and not os.path.isdir(dest_path):
|
||||
os.makedirs(dest_path)
|
||||
dest_paths = [dest_path]
|
||||
elif directory:
|
||||
# got a directory template, render it and check results are valid
|
||||
@@ -1808,7 +1837,7 @@ def export_photo(
|
||||
dest_path = os.path.join(dest, dirname)
|
||||
if not is_valid_filepath(dest_path, platform="auto"):
|
||||
raise ValueError(f"Invalid file path: '{dest_path}'")
|
||||
if not os.path.isdir(dest_path):
|
||||
if not dry_run and not os.path.isdir(dest_path):
|
||||
os.makedirs(dest_path)
|
||||
dest_paths.append(dest_path)
|
||||
|
||||
@@ -1849,6 +1878,8 @@ def export_photo(
|
||||
keyword_template=keyword_template,
|
||||
update=update,
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
dry_run = dry_run,
|
||||
)
|
||||
|
||||
results_exported.extend(export_results.exported)
|
||||
@@ -1902,6 +1933,8 @@ def export_photo(
|
||||
keyword_template=keyword_template,
|
||||
update=update,
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
dry_run = dry_run,
|
||||
)
|
||||
|
||||
results_exported.extend(export_results_edited.exported)
|
||||
|
||||
@@ -9,6 +9,7 @@ import pathlib
|
||||
import sqlite3
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from io import StringIO
|
||||
from sqlite3 import Error
|
||||
|
||||
from ._version import __version__
|
||||
@@ -17,6 +18,7 @@ OSXPHOTOS_EXPORTDB_VERSION = "1.0"
|
||||
|
||||
|
||||
class ExportDB_ABC(ABC):
|
||||
""" abstract base class for ExportDB """
|
||||
@abstractmethod
|
||||
def get_uuid_for_file(self, filename):
|
||||
pass
|
||||
@@ -58,7 +60,7 @@ class ExportDB_ABC(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_data(self, file, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
pass
|
||||
|
||||
|
||||
@@ -95,7 +97,7 @@ class ExportDBNoOp(ExportDB_ABC):
|
||||
def set_exifdata_for_file(self, uuid, exifdata):
|
||||
pass
|
||||
|
||||
def set_data(self, file, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
pass
|
||||
|
||||
|
||||
@@ -302,7 +304,7 @@ class ExportDB(ExportDB_ABC):
|
||||
|
||||
logging.debug(f"set_exifdata: {filename}, {exifdata}")
|
||||
|
||||
def set_data(self, file, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
""" sets all the data for file and uuid at once
|
||||
calls set_uuid_for_file
|
||||
set_info_for_uuid
|
||||
@@ -310,8 +312,6 @@ class ExportDB(ExportDB_ABC):
|
||||
set_stat_exif_for_file
|
||||
set_exifdata_for_file
|
||||
"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
|
||||
self.set_uuid_for_file(filename, uuid)
|
||||
self.set_info_for_uuid(uuid, info_json)
|
||||
self.set_stat_orig_for_file(filename, orig_stat)
|
||||
@@ -437,3 +437,64 @@ class ExportDB(ExportDB_ABC):
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
|
||||
class ExportDBInMemory(ExportDB):
|
||||
""" In memory version of ExportDB
|
||||
Copies the on-disk database into memory so it may be operated on without
|
||||
modifying the on-disk verison
|
||||
"""
|
||||
|
||||
def init(self, dbfile):
|
||||
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
|
||||
# this allows the entire export tree to be moved to a new disk/location
|
||||
# whilst preserving the UUID to filename mappping
|
||||
self._path = pathlib.Path(dbfile).parent
|
||||
self._conn = self._open_export_db(dbfile)
|
||||
self._insert_run_info()
|
||||
|
||||
def _open_export_db(self, dbfile):
|
||||
""" open export database and return a db connection
|
||||
if dbfile does not exist, will create and initialize the database
|
||||
returns: connection to the database
|
||||
"""
|
||||
if not os.path.isfile(dbfile):
|
||||
logging.debug(f"dbfile {dbfile} doesn't exist, creating in memory version")
|
||||
conn = self._get_db_connection()
|
||||
if conn:
|
||||
self._create_db_tables(conn)
|
||||
else:
|
||||
raise Exception("Error getting connection to in-memory database")
|
||||
else:
|
||||
logging.debug(f"dbfile {dbfile} exists, opening it and copying to memory")
|
||||
try:
|
||||
conn = sqlite3.connect(dbfile)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
raise e
|
||||
|
||||
tempfile = StringIO()
|
||||
for line in conn.iterdump():
|
||||
tempfile.write("%s\n" % line)
|
||||
conn.close()
|
||||
tempfile.seek(0)
|
||||
|
||||
# Create a database in memory and import from tempfile
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.cursor().executescript(tempfile.read())
|
||||
conn.commit()
|
||||
|
||||
return conn
|
||||
|
||||
def _get_db_connection(self):
|
||||
""" return db connection to in memory database """
|
||||
try:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
conn = None
|
||||
|
||||
return conn
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Utilities for comparing files
|
||||
|
||||
Modified from CPython/Lib/filecmp.py
|
||||
|
||||
Functions:
|
||||
cmp_file(f1, s2) -> int
|
||||
file_sig(f1) -> Tuple[int, int, float]
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
|
||||
__all__ = ["cmp", "sig"]
|
||||
|
||||
|
||||
def cmp_file(f1, s2):
|
||||
"""Compare file f1 to signature s2.
|
||||
|
||||
Arguments:
|
||||
|
||||
f1 -- File name
|
||||
|
||||
s2 -- stats as returned by sig
|
||||
|
||||
Return value:
|
||||
|
||||
True if the files are the same, False otherwise.
|
||||
|
||||
This function uses a cache for past comparisons and the results,
|
||||
with cache entries invalidated if their stat information
|
||||
changes. The cache may be cleared by calling clear_cache().
|
||||
|
||||
"""
|
||||
|
||||
if not s2:
|
||||
return False
|
||||
|
||||
s1 = _sig(os.stat(f1))
|
||||
|
||||
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
|
||||
return False
|
||||
if s1 == s2:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sig(st):
|
||||
return (stat.S_IFMT(st.st_mode), st.st_size, st.st_mtime)
|
||||
|
||||
|
||||
def file_sig(f1):
|
||||
""" return os.stat signature for file f1 """
|
||||
return _sig(os.stat(f1))
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.29.4"
|
||||
__version__ = "0.29.5"
|
||||
|
||||
175
osxphotos/fileutil.py
Normal file
175
osxphotos/fileutil.py
Normal file
@@ -0,0 +1,175 @@
|
||||
""" FileUtil class with methods for copy, hardlink, unlink, etc. """
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class FileUtilABC(ABC):
|
||||
""" Abstract base class for FileUtil """
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def hardlink(cls, src, dest):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def copy(cls, src, dest, norsrc=False):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def unlink(cls, dest):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def cmp_sig(cls, file1, file2):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def file_sig(cls, file1):
|
||||
pass
|
||||
|
||||
|
||||
class FileUtilMacOS(FileUtilABC):
|
||||
""" Various file utilities """
|
||||
@classmethod
|
||||
def hardlink(cls, src, dest):
|
||||
""" Hardlinks a file from src path to dest path
|
||||
src: source path as string
|
||||
dest: destination path as string
|
||||
Raises exception if linking fails or either path is None """
|
||||
|
||||
if src is None or dest is None:
|
||||
raise ValueError("src and dest must not be None", src, dest)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError("src file does not appear to exist", src)
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
os.link(src, dest)
|
||||
except Exception as e:
|
||||
logging.critical(f"os.link returned error: {e}")
|
||||
raise e
|
||||
|
||||
@classmethod
|
||||
def copy(cls, src, dest, norsrc=False):
|
||||
""" Copies a file from src path to dest path
|
||||
src: source path as string
|
||||
dest: destination path as string
|
||||
norsrc: (bool) if True, uses --norsrc flag with ditto so it will not copy
|
||||
resource fork or extended attributes. May be useful on volumes that
|
||||
don't work with extended attributes (likely only certain SMB mounts)
|
||||
default is False
|
||||
Uses ditto to perform copy; will silently overwrite dest if it exists
|
||||
Raises exception if copy fails or either path is None """
|
||||
|
||||
if src is None or dest is None:
|
||||
raise ValueError("src and dest must not be None", src, dest)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError("src file does not appear to exist", src)
|
||||
|
||||
if norsrc:
|
||||
command = ["/usr/bin/ditto", "--norsrc", src, dest]
|
||||
else:
|
||||
command = ["/usr/bin/ditto", src, dest]
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
result = subprocess.run(command, check=True, stderr=subprocess.PIPE)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.critical(
|
||||
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
|
||||
)
|
||||
raise e
|
||||
|
||||
return result.returncode
|
||||
|
||||
@classmethod
|
||||
def unlink(cls, filepath):
|
||||
""" unlink filepath; if it's pathlib.Path, use Path.unlink, otherwise use os.unlink """
|
||||
if isinstance(filepath, pathlib.Path):
|
||||
filepath.unlink()
|
||||
else:
|
||||
os.unlink(filepath)
|
||||
|
||||
@classmethod
|
||||
def cmp_sig(cls, f1, s2):
|
||||
"""Compare file f1 to signature s2.
|
||||
Arguments:
|
||||
f1 -- File name
|
||||
s2 -- stats as returned by sig
|
||||
|
||||
Return value:
|
||||
True if the files are the same, False otherwise.
|
||||
"""
|
||||
|
||||
if not s2:
|
||||
return False
|
||||
|
||||
s1 = cls._sig(os.stat(f1))
|
||||
|
||||
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
|
||||
return False
|
||||
if s1 == s2:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def file_sig(cls, f1):
|
||||
""" return os.stat signature for file f1 """
|
||||
return cls._sig(os.stat(f1))
|
||||
|
||||
@staticmethod
|
||||
def _sig(st):
|
||||
return (stat.S_IFMT(st.st_mode), st.st_size, st.st_mtime)
|
||||
|
||||
|
||||
class FileUtil(FileUtilMacOS):
|
||||
""" Various file utilities """
|
||||
pass
|
||||
|
||||
class FileUtilNoOp(FileUtil):
|
||||
""" No-Op implementation of FileUtil for testing / dry-run mode
|
||||
all methods with exception of cmp_sig and file_cmp are no-op
|
||||
cmp_sig functions as FileUtil.cmp_sig does
|
||||
file_cmp returns mock data
|
||||
"""
|
||||
@staticmethod
|
||||
def noop(*args):
|
||||
pass
|
||||
|
||||
verbose = noop
|
||||
|
||||
def __new__(cls, verbose=None):
|
||||
if verbose:
|
||||
if callable(verbose):
|
||||
cls.verbose = verbose
|
||||
else:
|
||||
raise ValueError(f"verbose {verbose} not callable")
|
||||
return super(FileUtilNoOp, cls).__new__(cls)
|
||||
|
||||
@classmethod
|
||||
def hardlink(cls, src, dest):
|
||||
cls.verbose(f"hardlink: {src} {dest}")
|
||||
|
||||
@classmethod
|
||||
def copy(cls, src, dest, norsrc=False):
|
||||
cls.verbose(f"copy: {src} {dest}")
|
||||
|
||||
@classmethod
|
||||
def unlink(cls, dest):
|
||||
cls.verbose(f"unlink: {dest}")
|
||||
|
||||
@classmethod
|
||||
def file_sig(cls, file1):
|
||||
cls.verbose(f"file_sig: {file1}")
|
||||
return (42, 42, 42)
|
||||
@@ -1,4 +1,13 @@
|
||||
""" export methods for PhotoInfo """
|
||||
""" Export methods for PhotoInfo
|
||||
The following methods are defined and must be imported into PhotoInfo as instance methods:
|
||||
export
|
||||
export2
|
||||
_export_photo
|
||||
_write_exif_data
|
||||
_exiftool_json_sidecar
|
||||
_xmp_sidecar
|
||||
_write_sidecar
|
||||
"""
|
||||
|
||||
# TODO: should this be its own PhotoExporter class?
|
||||
|
||||
@@ -9,10 +18,12 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import tempfile
|
||||
from collections import namedtuple # pylint: disable=syntax-error
|
||||
|
||||
from mako.template import Template
|
||||
|
||||
from .._applescript import AppleScript
|
||||
from .._constants import (
|
||||
_MAX_IPTC_KEYWORD_LEN,
|
||||
_OSXPHOTOS_NONE_SENTINEL,
|
||||
@@ -20,21 +31,131 @@ from .._constants import (
|
||||
_UNKNOWN_PERSON,
|
||||
_XMP_TEMPLATE_NAME,
|
||||
)
|
||||
from ..exiftool import ExifTool
|
||||
from .._export_db import ExportDBNoOp
|
||||
from .._filecmp import cmp_file, file_sig
|
||||
from ..utils import (
|
||||
_copy_file,
|
||||
_export_photo_uuid_applescript,
|
||||
_hardlink_file,
|
||||
dd_to_dms_str,
|
||||
)
|
||||
from ..exiftool import ExifTool
|
||||
from ..fileutil import FileUtil
|
||||
from ..utils import dd_to_dms_str
|
||||
|
||||
ExportResults = namedtuple(
|
||||
"ExportResults", ["exported", "new", "updated", "skipped", "exif_updated"]
|
||||
)
|
||||
|
||||
|
||||
# _export_photo_uuid_applescript is not a class method, don't import this into PhotoInfo
|
||||
def _export_photo_uuid_applescript(
|
||||
uuid,
|
||||
dest,
|
||||
filestem=None,
|
||||
original=True,
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
timeout=120,
|
||||
burst=False,
|
||||
dry_run=False,
|
||||
):
|
||||
""" Export photo to dest path using applescript to control Photos
|
||||
If photo is a live photo, exports both the photo and associated .mov file
|
||||
uuid: UUID of photo to export
|
||||
dest: destination path to export to
|
||||
filestem: (string) if provided, exported filename will be named stem.ext
|
||||
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
|
||||
If not provided, file will be named with whatever name Photos uses
|
||||
If filestem.ext exists, it wil be overwritten
|
||||
original: (boolean) if True, export original image; default = True
|
||||
edited: (boolean) if True, export edited photo; default = False
|
||||
If photo not edited and edited=True, will still export the original image
|
||||
caller must verify image has been edited
|
||||
*Note*: must be called with either edited or original but not both,
|
||||
will raise error if called with both edited and original = True
|
||||
live_photo: (boolean) if True, export associated .mov live photo; default = False
|
||||
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
||||
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
||||
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
|
||||
Returns: list of paths to exported file(s) or None if export failed
|
||||
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
|
||||
has not been edited. This is due to how Photos Applescript interface works.
|
||||
"""
|
||||
|
||||
# setup the applescript to do the export
|
||||
export_scpt = AppleScript(
|
||||
"""
|
||||
on export_by_uuid(theUUID, thePath, original, edited, theTimeOut)
|
||||
tell application "Photos"
|
||||
set thePath to thePath
|
||||
set theItem to media item id theUUID
|
||||
set theFilename to filename of theItem
|
||||
set itemList to {theItem}
|
||||
|
||||
if original then
|
||||
with timeout of theTimeOut seconds
|
||||
export itemList to POSIX file thePath with using originals
|
||||
end timeout
|
||||
end if
|
||||
|
||||
if edited then
|
||||
with timeout of theTimeOut seconds
|
||||
export itemList to POSIX file thePath
|
||||
end timeout
|
||||
end if
|
||||
|
||||
return theFilename
|
||||
end tell
|
||||
|
||||
end export_by_uuid
|
||||
"""
|
||||
)
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir:
|
||||
raise ValueError(f"dest {dest} must be a directory")
|
||||
|
||||
if not original ^ edited:
|
||||
raise ValueError(f"edited or original must be True but not both")
|
||||
|
||||
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
|
||||
# export original
|
||||
filename = None
|
||||
try:
|
||||
filename = export_scpt.call(
|
||||
"export_by_uuid", uuid, tmpdir.name, original, edited, timeout
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"Error exporting uuid {uuid}: {e}")
|
||||
return None
|
||||
|
||||
if filename is not None:
|
||||
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
|
||||
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
|
||||
# TemporaryDirectory will cleanup on return
|
||||
filename_stem = pathlib.Path(filename).stem
|
||||
files = glob.glob(os.path.join(tmpdir.name, "*"))
|
||||
exported_paths = []
|
||||
for fname in files:
|
||||
path = pathlib.Path(fname)
|
||||
if len(files) > 1 and not live_photo and path.suffix.lower() == ".mov":
|
||||
# it's the .mov part of live photo but not requested, so don't export
|
||||
logging.debug(f"Skipping live photo file {path}")
|
||||
continue
|
||||
if len(files) > 1 and burst and path.stem != filename_stem:
|
||||
# skip any burst photo that's not the one we asked for
|
||||
logging.debug(f"Skipping burst photo file {path}")
|
||||
continue
|
||||
if filestem:
|
||||
# rename the file based on filestem, keeping original extension
|
||||
dest_new = dest / f"{filestem}{path.suffix}"
|
||||
else:
|
||||
# use the name Photos provided
|
||||
dest_new = dest / path.name
|
||||
logging.debug(f"exporting {path} to dest_new: {dest_new}")
|
||||
if not dry_run:
|
||||
FileUtil.copy(str(path), str(dest_new))
|
||||
exported_paths.append(str(dest_new))
|
||||
return exported_paths
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def export(
|
||||
self,
|
||||
dest,
|
||||
@@ -135,8 +256,10 @@ def export2(
|
||||
keyword_template=None,
|
||||
update=False,
|
||||
export_db=None,
|
||||
fileutil=FileUtil,
|
||||
dry_run=False,
|
||||
):
|
||||
""" export photo
|
||||
""" export photo, like export but with update and dry_run options
|
||||
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.
|
||||
@@ -171,12 +294,17 @@ def export2(
|
||||
not export the photo if the current version already exists in the destination
|
||||
export_db: (ExportDB_ABC); instance of a class that conforms to ExportDB_ABC with methods
|
||||
for getting/setting data related to exported files to compare update state
|
||||
fileutil: (FileUtilABC); class that conforms to FileUtilABC with various file utilities
|
||||
dry_run: (boolean, default=False); set to True to run in "dry run" mode
|
||||
|
||||
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
|
||||
where each field is a list of file paths
|
||||
|
||||
Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db,
|
||||
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
|
||||
"""
|
||||
|
||||
# if update, caller may pass function refs to get/set uuid for file being exported
|
||||
# and for setting/getting the PhotoInfo json info for an exported file
|
||||
# when called from export(), won't get an export_db, so use no-op version
|
||||
if export_db is None:
|
||||
export_db = ExportDBNoOp()
|
||||
|
||||
@@ -212,7 +340,7 @@ def export2(
|
||||
# verify destination is a valid path
|
||||
if dest is None:
|
||||
raise ValueError("Destination must not be None")
|
||||
elif not os.path.isdir(dest):
|
||||
elif not dry_run and not os.path.isdir(dest):
|
||||
raise FileNotFoundError("Invalid path passed to export")
|
||||
|
||||
if filename and len(filename) == 1:
|
||||
@@ -279,10 +407,6 @@ def export2(
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
|
||||
# TODO: need way to check if DB is missing, try to find the right photo anyway by seeing if they're the same and then updating
|
||||
# move the checks into "if not use_photos_export" block below
|
||||
# if use_photos_export is True then we'll export wether destination exists or not
|
||||
|
||||
# if overwrite==False and #increment==False, export should fail if file exists
|
||||
if dest.exists() and not update and not overwrite and not increment:
|
||||
raise FileExistsError(
|
||||
@@ -330,7 +454,7 @@ def export2(
|
||||
dest_uuid = self.uuid
|
||||
export_db.set_uuid_for_file(dest, self.uuid)
|
||||
export_db.set_info_for_uuid(self.uuid, self.json())
|
||||
export_db.set_stat_orig_for_file(dest, file_sig(dest))
|
||||
export_db.set_stat_orig_for_file(dest, fileutil.file_sig(dest))
|
||||
export_db.set_stat_exif_for_file(dest, (None, None, None))
|
||||
export_db.set_exifdata_for_file(dest, None)
|
||||
if dest_uuid != self.uuid:
|
||||
@@ -360,7 +484,7 @@ def export2(
|
||||
found_match = True
|
||||
export_db.set_uuid_for_file(file_, self.uuid)
|
||||
export_db.set_info_for_uuid(self.uuid, self.json())
|
||||
export_db.set_stat_orig_for_file(dest, file_sig(dest))
|
||||
export_db.set_stat_orig_for_file(dest, fileutil.file_sig(dest))
|
||||
export_db.set_stat_exif_for_file(dest, (None, None, None))
|
||||
export_db.set_exifdata_for_file(dest, None)
|
||||
break
|
||||
@@ -392,6 +516,7 @@ def export2(
|
||||
no_xattr,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
fileutil,
|
||||
)
|
||||
exported_files = results.exported
|
||||
update_new_files = results.new
|
||||
@@ -416,6 +541,7 @@ def export2(
|
||||
no_xattr,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
fileutil,
|
||||
)
|
||||
exported_files.extend(results.exported)
|
||||
update_new_files.extend(results.new)
|
||||
@@ -440,6 +566,7 @@ def export2(
|
||||
no_xattr,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
fileutil,
|
||||
)
|
||||
exported_files.extend(results.exported)
|
||||
update_new_files.extend(results.new)
|
||||
@@ -471,6 +598,7 @@ def export2(
|
||||
live_photo=live_photo,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
else:
|
||||
# export original version and not edited
|
||||
@@ -484,6 +612,7 @@ def export2(
|
||||
live_photo=live_photo,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
if exported is not None:
|
||||
@@ -504,11 +633,12 @@ def export2(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
)
|
||||
try:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
except Exception as e:
|
||||
logging.warning(f"Error writing json sidecar to {sidecar_filename}")
|
||||
raise e
|
||||
if not dry_run:
|
||||
try:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
except Exception as e:
|
||||
logging.warning(f"Error writing json sidecar to {sidecar_filename}")
|
||||
raise e
|
||||
|
||||
if sidecar_xmp:
|
||||
logging.debug("writing xmp_sidecar")
|
||||
@@ -518,11 +648,12 @@ def export2(
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
)
|
||||
try:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
except Exception as e:
|
||||
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
|
||||
raise e
|
||||
if not dry_run:
|
||||
try:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
except Exception as e:
|
||||
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
|
||||
raise e
|
||||
|
||||
# if exiftool, write the metadata
|
||||
if update:
|
||||
@@ -552,12 +683,13 @@ def export2(
|
||||
# didn't have old data, assume we need to write it
|
||||
# or files were different
|
||||
logging.debug(f"No exifdata for {exported_file}, writing it")
|
||||
self._write_exif_data(
|
||||
exported_file,
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
)
|
||||
if not dry_run:
|
||||
self._write_exif_data(
|
||||
exported_file,
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
)
|
||||
export_db.set_exifdata_for_file(
|
||||
exported_file,
|
||||
self._exiftool_json_sidecar(
|
||||
@@ -566,17 +698,20 @@ def export2(
|
||||
keyword_template=keyword_template,
|
||||
),
|
||||
)
|
||||
export_db.set_stat_exif_for_file(exported_file, file_sig(exported_file))
|
||||
export_db.set_stat_exif_for_file(
|
||||
exported_file, fileutil.file_sig(exported_file)
|
||||
)
|
||||
exif_files_updated.append(exported_file)
|
||||
elif exiftool and exif_files:
|
||||
for exported_file in exif_files:
|
||||
logging.debug(f"Writing exif data to {exported_file}")
|
||||
self._write_exif_data(
|
||||
exported_file,
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
)
|
||||
if not dry_run:
|
||||
self._write_exif_data(
|
||||
exported_file,
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
)
|
||||
export_db.set_exifdata_for_file(
|
||||
exported_file,
|
||||
self._exiftool_json_sidecar(
|
||||
@@ -585,7 +720,9 @@ def export2(
|
||||
keyword_template=keyword_template,
|
||||
),
|
||||
)
|
||||
export_db.set_stat_exif_for_file(exported_file, file_sig(exported_file))
|
||||
export_db.set_stat_exif_for_file(
|
||||
exported_file, fileutil.file_sig(exported_file)
|
||||
)
|
||||
exif_files_updated.append(exported_file)
|
||||
|
||||
return ExportResults(
|
||||
@@ -607,6 +744,7 @@ def _export_photo(
|
||||
no_xattr,
|
||||
export_as_hardlink,
|
||||
exiftool,
|
||||
fileutil=FileUtil,
|
||||
):
|
||||
""" Helper function for export()
|
||||
Does the actual copy or hardlink taking the appropriate
|
||||
@@ -621,6 +759,7 @@ def _export_photo(
|
||||
no_xattr: don't copy extended attributes
|
||||
export_as_hardlink: bool
|
||||
exiftool: bool
|
||||
fileutil: FileUtil class that conforms to fileutil.FileUtilABC
|
||||
Returns: ExportResults
|
||||
"""
|
||||
|
||||
@@ -637,12 +776,13 @@ def _export_photo(
|
||||
# not update, do the the hardlink
|
||||
if overwrite and dest.exists():
|
||||
# need to remove the destination first
|
||||
dest.unlink()
|
||||
# dest.unlink()
|
||||
fileutil.unlink(dest)
|
||||
logging.debug(f"Not update: export_as_hardlink linking file {src} {dest}")
|
||||
_hardlink_file(src, dest)
|
||||
fileutil.hardlink(src, dest)
|
||||
export_db.set_uuid_for_file(dest_str, self.uuid)
|
||||
export_db.set_info_for_uuid(self.uuid, self.json())
|
||||
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
|
||||
export_db.set_stat_orig_for_file(dest_str, fileutil.file_sig(dest_str))
|
||||
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
|
||||
export_db.set_exifdata_for_file(dest_str, None)
|
||||
exported_files.append(dest_str)
|
||||
@@ -657,11 +797,12 @@ def _export_photo(
|
||||
logging.debug(
|
||||
f"Update: removing existing file prior to export_as_hardlink {src} {dest}"
|
||||
)
|
||||
dest.unlink()
|
||||
_hardlink_file(src, dest)
|
||||
# dest.unlink()
|
||||
fileutil.unlink(dest)
|
||||
fileutil.hardlink(src, dest)
|
||||
export_db.set_uuid_for_file(dest_str, self.uuid)
|
||||
export_db.set_info_for_uuid(self.uuid, self.json())
|
||||
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
|
||||
export_db.set_stat_orig_for_file(dest_str, fileutil.file_sig(dest_str))
|
||||
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
|
||||
export_db.set_exifdata_for_file(dest_str, None)
|
||||
update_updated_files.append(dest_str)
|
||||
@@ -671,10 +812,10 @@ def _export_photo(
|
||||
logging.debug(
|
||||
f"Update: exporting new file with export_as_hardlink {src} {dest}"
|
||||
)
|
||||
_hardlink_file(src, dest)
|
||||
fileutil.hardlink(src, dest)
|
||||
export_db.set_uuid_for_file(dest_str, self.uuid)
|
||||
export_db.set_info_for_uuid(self.uuid, self.json())
|
||||
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
|
||||
export_db.set_stat_orig_for_file(dest_str, fileutil.file_sig(dest_str))
|
||||
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
|
||||
export_db.set_exifdata_for_file(dest_str, None)
|
||||
exported_files.append(dest_str)
|
||||
@@ -684,12 +825,13 @@ def _export_photo(
|
||||
# not update, do the the copy
|
||||
if overwrite and dest.exists():
|
||||
# need to remove the destination first
|
||||
dest.unlink()
|
||||
# dest.unlink()
|
||||
fileutil.unlink(dest)
|
||||
logging.debug(f"Not update: copying file {src} {dest}")
|
||||
_copy_file(src, dest_str, norsrc=no_xattr)
|
||||
fileutil.copy(src, dest_str, norsrc=no_xattr)
|
||||
export_db.set_uuid_for_file(dest_str, self.uuid)
|
||||
export_db.set_info_for_uuid(self.uuid, self.json())
|
||||
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
|
||||
export_db.set_stat_orig_for_file(dest_str, fileutil.file_sig(dest_str))
|
||||
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
|
||||
export_db.set_exifdata_for_file(dest_str, None)
|
||||
exported_files.append(dest_str)
|
||||
@@ -704,12 +846,12 @@ def _export_photo(
|
||||
logging.debug(f"Update: skipping identifical original files {src} {dest}")
|
||||
# call set_stat because code can reach this spot if no export DB but exporting a RAW or live photo
|
||||
# potentially re-writes the data in the database but ensures database is complete
|
||||
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
|
||||
export_db.set_stat_orig_for_file(dest_str, fileutil.file_sig(dest_str))
|
||||
update_skipped_files.append(dest_str)
|
||||
elif (
|
||||
dest_exists
|
||||
and exiftool
|
||||
and cmp_file(dest_str, export_db.get_stat_exif_for_file(dest_str))
|
||||
and fileutil.cmp_sig(dest_str, export_db.get_stat_exif_for_file(dest_str))
|
||||
and not dest.samefile(src)
|
||||
):
|
||||
# destination exists but is identical
|
||||
@@ -720,11 +862,12 @@ def _export_photo(
|
||||
logging.debug(f"Update: removing existing file prior to copy {src} {dest}")
|
||||
stat_src = os.stat(src)
|
||||
stat_dest = os.stat(dest)
|
||||
dest.unlink()
|
||||
_copy_file(src, dest_str, norsrc=no_xattr)
|
||||
# dest.unlink()
|
||||
fileutil.unlink(dest)
|
||||
fileutil.copy(src, dest_str, norsrc=no_xattr)
|
||||
export_db.set_uuid_for_file(dest_str, self.uuid)
|
||||
export_db.set_info_for_uuid(self.uuid, self.json())
|
||||
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
|
||||
export_db.set_stat_orig_for_file(dest_str, fileutil.file_sig(dest_str))
|
||||
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
|
||||
export_db.set_exifdata_for_file(dest_str, None)
|
||||
exported_files.append(dest_str)
|
||||
@@ -732,10 +875,10 @@ def _export_photo(
|
||||
else:
|
||||
# destination doesn't exist, copy the file
|
||||
logging.debug(f"Update: copying new file {src} {dest}")
|
||||
_copy_file(src, dest_str, norsrc=no_xattr)
|
||||
fileutil.copy(src, dest_str, norsrc=no_xattr)
|
||||
export_db.set_uuid_for_file(dest_str, self.uuid)
|
||||
export_db.set_info_for_uuid(self.uuid, self.json())
|
||||
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
|
||||
export_db.set_stat_orig_for_file(dest_str, fileutil.file_sig(dest_str))
|
||||
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
|
||||
export_db.set_exifdata_for_file(dest_str, None)
|
||||
exported_files.append(dest_str)
|
||||
|
||||
@@ -18,7 +18,7 @@ import CoreServices
|
||||
import objc
|
||||
from Foundation import *
|
||||
|
||||
from osxphotos._applescript import AppleScript
|
||||
from .fileutil import FileUtil
|
||||
|
||||
_DEBUG = False
|
||||
|
||||
@@ -118,60 +118,6 @@ def _dd_to_dms(dd):
|
||||
return int(deg_), int(min_), sec_
|
||||
|
||||
|
||||
def _hardlink_file(src, dest):
|
||||
""" Hardlinks a file from src path to dest path
|
||||
src: source path as string
|
||||
dest: destination path as string
|
||||
Raises exception if linking fails or either path is None """
|
||||
|
||||
if src is None or dest is None:
|
||||
raise ValueError("src and dest must not be None", src, dest)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError("src file does not appear to exist", src)
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
os.link(src, dest)
|
||||
except Exception as e:
|
||||
logging.critical(f"os.link returned error: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
def _copy_file(src, dest, norsrc=False):
|
||||
""" Copies a file from src path to dest path
|
||||
src: source path as string
|
||||
dest: destination path as string
|
||||
norsrc: (bool) if True, uses --norsrc flag with ditto so it will not copy
|
||||
resource fork or extended attributes. May be useful on volumes that
|
||||
don't work with extended attributes (likely only certain SMB mounts)
|
||||
default is False
|
||||
Uses ditto to perform copy; will silently overwrite dest if it exists
|
||||
Raises exception if copy fails or either path is None """
|
||||
|
||||
if src is None or dest is None:
|
||||
raise ValueError("src and dest must not be None", src, dest)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError("src file does not appear to exist", src)
|
||||
|
||||
if norsrc:
|
||||
command = ["/usr/bin/ditto", "--norsrc", src, dest]
|
||||
else:
|
||||
command = ["/usr/bin/ditto", src, dest]
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
result = subprocess.run(command, check=True, stderr=subprocess.PIPE)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.critical(
|
||||
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
|
||||
)
|
||||
raise e
|
||||
|
||||
return result.returncode
|
||||
|
||||
|
||||
def dd_to_dms_str(lat, lon):
|
||||
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
|
||||
""" lat: latitude in degrees """
|
||||
@@ -308,24 +254,6 @@ def list_photo_libraries():
|
||||
return lib_list
|
||||
|
||||
|
||||
def create_path_by_date(dest, dt):
|
||||
""" Creates a path in dest folder in form dest/YYYY/MM/DD/
|
||||
dest: valid path as str
|
||||
dt: datetime.timetuple() object
|
||||
Checks to see if path exists, if it does, do nothing and return path
|
||||
If path does not exist, creates it and returns path"""
|
||||
if not os.path.isdir(dest):
|
||||
raise FileNotFoundError(f"dest {dest} must be valid path")
|
||||
yyyy, mm, dd = dt[0:3]
|
||||
yyyy = str(yyyy).zfill(4)
|
||||
mm = str(mm).zfill(2)
|
||||
dd = str(dd).zfill(2)
|
||||
new_dest = os.path.join(dest, yyyy, mm, dd)
|
||||
if not os.path.isdir(new_dest):
|
||||
os.makedirs(new_dest)
|
||||
return new_dest
|
||||
|
||||
|
||||
def get_preferred_uti_extension(uti):
|
||||
""" get preferred extension for a UTI type
|
||||
uti: UTI str, e.g. 'public.jpeg'
|
||||
@@ -365,117 +293,6 @@ def findfiles(pattern, path_):
|
||||
# open_scpt.run()
|
||||
|
||||
|
||||
def _export_photo_uuid_applescript(
|
||||
uuid,
|
||||
dest,
|
||||
filestem=None,
|
||||
original=True,
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
timeout=120,
|
||||
burst=False,
|
||||
):
|
||||
""" Export photo to dest path using applescript to control Photos
|
||||
If photo is a live photo, exports both the photo and associated .mov file
|
||||
uuid: UUID of photo to export
|
||||
dest: destination path to export to
|
||||
filestem: (string) if provided, exported filename will be named stem.ext
|
||||
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
|
||||
If not provided, file will be named with whatever name Photos uses
|
||||
If filestem.ext exists, it wil be overwritten
|
||||
original: (boolean) if True, export original image; default = True
|
||||
edited: (boolean) if True, export edited photo; default = False
|
||||
If photo not edited and edited=True, will still export the original image
|
||||
caller must verify image has been edited
|
||||
*Note*: must be called with either edited or original but not both,
|
||||
will raise error if called with both edited and original = True
|
||||
live_photo: (boolean) if True, export associated .mov live photo; default = False
|
||||
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
||||
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
||||
Returns: list of paths to exported file(s) or None if export failed
|
||||
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
|
||||
has not been edited. This is due to how Photos Applescript interface works.
|
||||
"""
|
||||
|
||||
# setup the applescript to do the export
|
||||
export_scpt = AppleScript(
|
||||
"""
|
||||
on export_by_uuid(theUUID, thePath, original, edited, theTimeOut)
|
||||
tell application "Photos"
|
||||
set thePath to thePath
|
||||
set theItem to media item id theUUID
|
||||
set theFilename to filename of theItem
|
||||
set itemList to {theItem}
|
||||
|
||||
if original then
|
||||
with timeout of theTimeOut seconds
|
||||
export itemList to POSIX file thePath with using originals
|
||||
end timeout
|
||||
end if
|
||||
|
||||
if edited then
|
||||
with timeout of theTimeOut seconds
|
||||
export itemList to POSIX file thePath
|
||||
end timeout
|
||||
end if
|
||||
|
||||
return theFilename
|
||||
end tell
|
||||
|
||||
end export_by_uuid
|
||||
"""
|
||||
)
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir:
|
||||
raise ValueError(f"dest {dest} must be a directory")
|
||||
|
||||
if not original ^ edited:
|
||||
raise ValueError(f"edited or original must be True but not both")
|
||||
|
||||
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
|
||||
# export original
|
||||
filename = None
|
||||
try:
|
||||
filename = export_scpt.call(
|
||||
"export_by_uuid", uuid, tmpdir.name, original, edited, timeout
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning("Error exporting uuid %s: %s" % (uuid, str(e)))
|
||||
return None
|
||||
|
||||
if filename is not None:
|
||||
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
|
||||
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
|
||||
# TemporaryDirectory will cleanup on return
|
||||
filename_stem = pathlib.Path(filename).stem
|
||||
files = glob.glob(os.path.join(tmpdir.name, "*"))
|
||||
exported_paths = []
|
||||
for fname in files:
|
||||
path = pathlib.Path(fname)
|
||||
if len(files) > 1 and not live_photo and path.suffix.lower() == ".mov":
|
||||
# it's the .mov part of live photo but not requested, so don't export
|
||||
logging.debug(f"Skipping live photo file {path}")
|
||||
continue
|
||||
if len(files) > 1 and burst and path.stem != filename_stem:
|
||||
# skip any burst photo that's not the one we asked for
|
||||
logging.debug(f"Skipping burst photo file {path}")
|
||||
continue
|
||||
if filestem:
|
||||
# rename the file based on filestem, keeping original extension
|
||||
dest_new = dest / f"{filestem}{path.suffix}"
|
||||
else:
|
||||
# use the name Photos provided
|
||||
dest_new = dest / path.name
|
||||
logging.debug(f"exporting {path} to dest_new: {dest_new}")
|
||||
_copy_file(str(path), str(dest_new))
|
||||
exported_paths.append(str(dest_new))
|
||||
return exported_paths
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _open_sql_file(dbname):
|
||||
""" opens sqlite file dbname in read-only mode
|
||||
returns tuple of (connection, cursor) """
|
||||
@@ -527,7 +344,7 @@ def _db_is_locked(dbname):
|
||||
# except KeyError:
|
||||
# uuid_str = None
|
||||
# return uuid_str
|
||||
|
||||
|
||||
# def set_uuid_for_file(filepath, uuid):
|
||||
# """ sets the UUID associated with an exported file
|
||||
# filepath: path to exported photo
|
||||
|
||||
Reference in New Issue
Block a user