Added --tmpdir, #650 (#651)

This commit is contained in:
Rhet Turnbull 2022-03-02 06:58:23 -08:00 committed by GitHub
parent f132e9a843
commit d8802368fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 341 additions and 181 deletions

View File

@ -1212,6 +1212,14 @@ Options:
network or slow disk but could result in
losing update state information if the program
is interrupted or crashes.
--tmpdir DIR Specify alternate temporary directory. Default
is system temporary directory. osxphotos needs
to create a number of temporary files during
export. In some cases, particularly if the
Photos library is on an APFS volume that is
not the system volume, osxphotos may run
faster if you specify a temporary directory on
the same volume as the Photos library.
--load-config <config file path>
Load options from file as written with --save-
config. This allows you to save a complex
@ -1775,7 +1783,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.47.3'
{osxphotos_version} The osxphotos version, e.g. '0.47.4'
{osxphotos_cmd_line} The full command line used to run osxphotos
The following substitutions may result in multiple values. Thus if specified for
@ -3679,7 +3687,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.47.3'|
|{osxphotos_version}|The osxphotos version, e.g. '0.47.4'|
|{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|
@ -3828,6 +3836,7 @@ Attributes:
- use_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos even if not missing (see also use_photokit, download_missing)
- use_photokit (bool, default=False): if True, will use photokit to export photos when use_photos_export is True
- verbose (Callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
- tmpfile (str): optional path to use for temporary files
#### `ExportResults`

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: d2a6d9757aa80bb1bbdf6e4e35a7326b
config: 19cfb0a2639529f45c9adb1eaa8cab18
tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overview: module code &#8212; osxphotos 0.47.3 documentation</title>
<title>Overview: module code &#8212; osxphotos 0.47.4 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>
@ -89,7 +89,7 @@
&copy;2021, Rhet Turnbull.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>

View File

@ -4,7 +4,7 @@
*
* Sphinx stylesheet -- basic theme.
*
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
* :copyright: Copyright 2007-2022 by the Sphinx team, see AUTHORS.
* :license: BSD, see LICENSE for details.
*
*/
@ -757,6 +757,7 @@ span.pre {
-ms-hyphens: none;
-webkit-hyphens: none;
hyphens: none;
white-space: nowrap;
}
div[class*="highlight-"] {

View File

@ -4,7 +4,7 @@
*
* Sphinx JavaScript utilities for all documentation.
*
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
* :copyright: Copyright 2007-2022 by the Sphinx team, see AUTHORS.
* :license: BSD, see LICENSE for details.
*
*/
@ -264,6 +264,9 @@ var Documentation = {
hideSearchWords : function() {
$('#searchbox .highlight-link').fadeOut(300);
$('span.highlighted').removeClass('highlighted');
var url = new URL(window.location);
url.searchParams.delete('highlight');
window.history.replaceState({}, '', url);
},
/**

View File

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

View File

@ -5,7 +5,7 @@
* This script contains the language-specific data used by searchtools.js,
* namely the list of stopwords, stemmer, scorer and splitter.
*
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
* :copyright: Copyright 2007-2022 by the Sphinx team, see AUTHORS.
* :license: BSD, see LICENSE for details.
*
*/

View File

@ -4,7 +4,7 @@
*
* Sphinx JavaScript utilities for the full-text search.
*
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
* :copyright: Copyright 2007-2022 by the Sphinx team, see AUTHORS.
* :license: BSD, see LICENSE for details.
*
*/

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.47.3 documentation</title>
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.47.4 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>
@ -94,7 +94,7 @@
&copy;2021, Rhet Turnbull.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|

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.47.3 documentation</title>
<title>Index &#8212; osxphotos 0.47.4 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>
@ -528,7 +528,7 @@
&copy;2021, Rhet Turnbull.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>

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.47.3 documentation</title>
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.47.4 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>
@ -355,7 +355,7 @@ Alternatively, you can also run the command line utility like this: <code class=
&copy;2021, Rhet Turnbull.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|

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.47.3 documentation</title>
<title>osxphotos &#8212; osxphotos 0.47.4 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>
@ -92,7 +92,7 @@
&copy;2021, Rhet Turnbull.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|

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.47.3 documentation</title>
<title>osxphotos package &#8212; osxphotos 0.47.4 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>
@ -975,7 +975,7 @@ Returns None if no associated RAW image</p>
&copy;2021, Rhet Turnbull.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|

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.47.3 documentation</title>
<title>Search &#8212; osxphotos 0.47.4 documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
@ -111,7 +111,7 @@
&copy;2021, Rhet Turnbull.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.4.0</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>

View File

@ -1,3 +1,3 @@
""" version info """
__version__ = "0.47.3"
__version__ = "0.47.4"

View File

@ -562,6 +562,16 @@ from .param_types import ExportDBType, FunctionCall
"may improve performance when exporting over a network or slow disk but could result in "
"losing update state information if the program is interrupted or crashes.",
)
@click.option(
"--tmpdir",
metavar="DIR",
help="Specify alternate temporary directory. Default is system temporary directory. "
"osxphotos needs to create a number of temporary files during export. In some cases, "
"particularly if the Photos library is on an APFS volume that is not the system volume, "
"osxphotos may run faster if you specify a temporary directory on the same volume as "
"the Photos library.",
type=click.Path(dir_okay=True, file_okay=False, exists=True),
)
@click.option(
"--load-config",
required=False,
@ -756,6 +766,7 @@ def export(
add_missing_to_album,
exportdb,
ramdb,
tmpdir,
load_config,
save_config,
config_only,
@ -792,7 +803,10 @@ def export(
to modify this behavior.
"""
if is_debug():
# capture locals for use with ConfigOptions before changing any of them
locals_ = locals()
if debug:
set_debug(True)
osxphotos._set_debug(True)
@ -819,7 +833,7 @@ def export(
# do so below after load_config and save_config are handled.
cfg = ConfigOptions(
"export",
locals(),
locals_,
ignore=["ctx", "cli_obj", "dest", "load_config", "save_config", "config_only"],
)
@ -956,6 +970,7 @@ def export(
skip_uuid_from_file = cfg.skip_uuid_from_file
slow_mo = cfg.slow_mo
strip = cfg.strip
tmpdir = cfg.tmpdir
time_lapse = cfg.time_lapse
timestamp = cfg.timestamp
title = cfg.title
@ -1414,6 +1429,7 @@ def export(
use_photokit=use_photokit,
use_photos_export=use_photos_export,
verbose_=verbose_,
tmpdir=tmpdir,
)
if post_function:
@ -1660,6 +1676,7 @@ def export_photo(
preview_if_missing=False,
photo_num=1,
num_photos=1,
tmpdir=None,
):
"""Helper function for export that does the actual export
@ -1707,6 +1724,7 @@ def export_photo(
update: bool, only export updated photos
use_photos_export: bool; if True forces the use of AppleScript to export even if photo not missing
verbose_: callable for verbose output
tmpdir: optional str; temporary directory to use for export
Returns:
list of path(s) of exported photo or None if photo was missing
@ -1871,6 +1889,7 @@ def export_photo(
use_photos_export=use_photos_export,
use_photokit=use_photokit,
verbose_=verbose_,
tmpdir=tmpdir,
)
if export_edited and photo.hasadjustments:
@ -1984,6 +2003,7 @@ def export_photo(
use_photos_export=use_photos_export,
use_photokit=use_photokit,
verbose_=verbose_,
tmpdir=tmpdir,
)
return results
@ -2068,6 +2088,7 @@ def export_photo_to_directory(
use_photos_export,
use_photokit,
verbose_,
tmpdir,
):
"""Export photo to directory dest_path"""
@ -2130,6 +2151,7 @@ def export_photo_to_directory(
use_photokit=use_photokit,
use_photos_export=use_photos_export,
verbose=verbose_,
tmpdir=tmpdir,
)
exporter = PhotoExporter(photo)
export_results = exporter.export(

View File

@ -3,9 +3,10 @@
import os
import pathlib
import stat
import subprocess
import sys
import tempfile
import typing as t
from abc import ABC, abstractmethod
from tempfile import TemporaryDirectory
import Foundation
@ -67,6 +68,13 @@ class FileUtilABC(ABC):
def rename(cls, src, dest):
pass
@classmethod
@abstractmethod
def tmpdir(
cls, prefix: t.Optional[str] = None, dir: t.Optional[str] = None
) -> tempfile.TemporaryDirectory:
pass
class FileUtilMacOS(FileUtilABC):
"""Various file utilities"""
@ -84,11 +92,10 @@ class FileUtilMacOS(FileUtilABC):
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:
raise e
raise e from e
@classmethod
def copy(cls, src, dest):
@ -222,6 +229,17 @@ class FileUtilMacOS(FileUtilABC):
os.rename(str(src), str(dest))
return dest
@classmethod
def tmpdir(
cls, prefix: t.Optional[str] = None, dir: t.Optional[str] = None
) -> tempfile.TemporaryDirectory:
"""Securely creates a temporary directory using the same rules as mkdtemp().
The resulting object can be used as a context manager.
On completion of the context or destruction of the temporary directory object,
the newly created temporary directory and all its contents are removed from the filesystem.
"""
return TemporaryDirectory(prefix=prefix, dir=dir)
@staticmethod
def _sig(st):
"""return tuple of (mode, size, mtime) of file based on os.stat
@ -240,7 +258,7 @@ class FileUtil(FileUtilMacOS):
class FileUtilNoOp(FileUtil):
"""No-Op implementation of FileUtil for testing / dry-run mode
all methods with exception of cmp, cmp_file_sig and file_cmp are no-op
all methods with exception of tmpdir, cmp, cmp_file_sig and file_cmp are no-op
cmp and cmp_file_sig functions as FileUtil methods do
file_cmp returns mock data
"""
@ -291,3 +309,15 @@ class FileUtilNoOp(FileUtil):
@classmethod
def rename(cls, src, dest):
cls.verbose(f"rename: {src}, {dest}")
@classmethod
def tmpdir(
cls, prefix: t.Optional[str] = None, dir: t.Optional[str] = None
) -> tempfile.TemporaryDirectory:
"""Securely creates a temporary directory using the same rules as mkdtemp().
The resulting object can be used as a context manager.
On completion of the context or destruction of the temporary directory object,
the newly created temporary directory and all its contents are removed from the filesystem.
"""
cls.verbose(f"tmpdir: {dir}")
return TemporaryDirectory(prefix=prefix, dir=dir)

View File

@ -10,9 +10,9 @@ import os
import pathlib
import re
import tempfile
import typing as t
from collections import namedtuple # pylint: disable=syntax-error
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Callable, List, Optional, Tuple
import photoscript
from mako.template import Template
@ -55,7 +55,7 @@ __all__ = [
"rename_jpeg_files",
]
if TYPE_CHECKING:
if t.TYPE_CHECKING:
from .photoinfo import PhotoInfo
# retry if download_missing/use_photos_export fails the first time (which sometimes it does)
@ -74,11 +74,11 @@ class ExportOptions:
Attributes:
convert_to_jpeg (bool): if True, converts non-jpeg images to jpeg
description_template (str): optional template string that will be rendered for use as photo description
description_template (str): t.Optional template string that will be rendered for use as photo description
download_missing: (bool, default=False): if True will attempt to export photo via applescript interaction with Photos if missing (see also use_photokit, use_photos_export)
dry_run: (bool, default=False): set to True to run in "dry run" mode
edited: (bool, default=False): if True will export the edited version of the photo otherwise exports the original version
exiftool_flags (list of str): optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
exiftool_flags (list of str): t.Optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
exiftool: (bool, default = False): if True, will use exiftool to write metadata to export file
export_as_hardlink: (bool, default=False): if True, will hardlink files instead of copying them
export_db: (ExportDB): instance of a class that conforms to ExportDB with methods for getting/setting data related to exported files to compare update state
@ -97,10 +97,10 @@ class ExportOptions:
merge_exif_persons (bool): if True, merged persons found in file's exif data (requires exiftool)
overwrite (bool, default=False): if True will overwrite files if they already exist
persons (bool): if True, include persons in exported metadata
preview_suffix (str): optional string to append to end of filename for preview images
preview_suffix (str): t.Optional string to append to end of filename for preview images
preview (bool): if True, also exports preview image
raw_photo (bool, default=False): if True, will also export the associated RAW photo
render_options (RenderOptions): optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
render_options (RenderOptions): t.Optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
replace_keywords (bool): if True, keyword_template replaces any keywords, otherwise it's additive
sidecar_drop_ext (bool, default=False): if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json')
sidecar: bit field (int): set to one or more of SIDECAR_XMP, SIDECAR_JSON, SIDECAR_EXIFTOOL
@ -117,27 +117,28 @@ class ExportOptions:
use_persons_as_keywords (bool, default = False): if True, will include person names in keywords when exporting metadata with exiftool or sidecar
use_photos_export (bool, default=False): if True will attempt to export photo via applescript interaction with Photos even if not missing (see also use_photokit, download_missing)
use_photokit (bool, default=False): if True, will use photokit to export photos when use_photos_export is True
verbose (Callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
verbose (callable): optional callable function to use for printing verbose text during processing; if None (default), does not print output.
tmpdir: (str, default=None): Optional directory to use for temporary files, if None (default) uses system tmp directory
"""
convert_to_jpeg: bool = False
description_template: Optional[str] = None
description_template: t.Optional[str] = None
download_missing: bool = False
dry_run: bool = False
edited: bool = False
exiftool_flags: Optional[List] = None
exiftool_flags: t.Optional[t.List] = None
exiftool: bool = False
export_as_hardlink: bool = False
export_db: Optional[ExportDB] = None
export_db: t.Optional[ExportDB] = None
face_regions: bool = True
fileutil: Optional[FileUtil] = None
fileutil: t.Optional[FileUtil] = None
force_update: bool = False
ignore_date_modified: bool = False
ignore_signature: bool = False
increment: bool = True
jpeg_ext: Optional[str] = None
jpeg_ext: t.Optional[str] = None
jpeg_quality: float = 1.0
keyword_template: Optional[List[str]] = None
keyword_template: t.Optional[t.List[str]] = None
live_photo: bool = False
location: bool = True
merge_exif_keywords: bool = False
@ -147,7 +148,7 @@ class ExportOptions:
preview_suffix: str = DEFAULT_PREVIEW_SUFFIX
preview: bool = False
raw_photo: bool = False
render_options: Optional[RenderOptions] = None
render_options: t.Optional[RenderOptions] = None
replace_keywords: bool = False
sidecar_drop_ext: bool = False
sidecar: int = 0
@ -159,7 +160,8 @@ class ExportOptions:
use_persons_as_keywords: bool = False
use_photokit: bool = False
use_photos_export: bool = False
verbose: Optional[Callable] = None
verbose: t.Optional[t.Callable] = None
tmpdir: t.Optional[str] = None
def asdict(self):
return asdict(self)
@ -176,13 +178,13 @@ class StagedFiles:
def __init__(
self,
original: Optional[str] = None,
original_live: Optional[str] = None,
edited: Optional[str] = None,
edited_live: Optional[str] = None,
preview: Optional[str] = None,
raw: Optional[str] = None,
error: Optional[List[str]] = None,
original: t.Optional[str] = None,
original_live: t.Optional[str] = None,
edited: t.Optional[str] = None,
edited_live: t.Optional[str] = None,
preview: t.Optional[str] = None,
raw: t.Optional[str] = None,
error: t.Optional[t.List[str]] = None,
):
self.original = original
self.original_live = original_live
@ -359,23 +361,21 @@ class ExportResults:
class PhotoExporter:
def __init__(self, photo: "PhotoInfo"):
def __init__(self, photo: "PhotoInfo", tmpdir: t.Optional[str] = None):
self.photo = photo
self._render_options = RenderOptions()
self._verbose = self.photo._verbose
# temp directory for staging downloaded missing files
self._temp_dir = tempfile.TemporaryDirectory(
prefix=f"osxphotos_photo_exporter_{self.photo.uuid}_"
)
self._temp_dir_path = pathlib.Path(self._temp_dir.name)
self._temp_dir = None
self._temp_dir_path = None
self.fileutil = FileUtil
def export(
self,
dest,
filename=None,
options: Optional[ExportOptions] = None,
options: t.Optional[ExportOptions] = None,
) -> ExportResults:
"""export photo
@ -389,7 +389,7 @@ class PhotoExporter:
in which case export will use the extension provided by Photos upon export.
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
options (ExportOptions): optional ExportOptions instance
options (ExportOptions): t.Optional ExportOptions instance
Returns: ExportResults instance
@ -399,6 +399,9 @@ class PhotoExporter:
options = options or ExportOptions()
# temp dir must be initialized before any of the methods called by export() are called
self._init_temp_dir(options)
verbose = options.verbose or self._verbose
if verbose and not callable(verbose):
raise TypeError("verbose must be callable")
@ -554,7 +557,23 @@ class PhotoExporter:
return all_results
def _touch_files(self, touch_files: List, options: ExportOptions) -> ExportResults:
def _init_temp_dir(self, options: ExportOptions):
"""Initialize (if necessary) the object's temporary directory.
Args:
options: ExportOptions object
"""
if self._temp_dir is not None:
return
fileutil = options.fileutil or FileUtil
self._temp_dir = fileutil.tmpdir(prefix="osxphotos_export_", dir=options.tmpdir)
self._temp_dir_path = pathlib.Path(self._temp_dir.name)
return
def _touch_files(
self, touch_files: t.List, options: ExportOptions
) -> ExportResults:
"""touch file date/time to match photo creation date/time; only touches files if needed"""
fileutil = options.fileutil
touch_results = []
@ -731,21 +750,6 @@ class PhotoExporter:
if options.live_photo and self.photo.live_photo:
staged.edited_live = self.photo.path_edited_live_photo
if options.exiftool and not options.dry_run and not options.export_as_hardlink:
# copy files to temp dir for exiftool to process before export
# not needed for download_missing or use_photokit as those files already staged to temp dir
for file_type in [
"raw",
"preview",
"original",
"original_live",
"edited",
"edited_live",
]:
staged_file = getattr(staged, file_type)
if staged_file:
setattr(staged, file_type, self._copy_to_temp_file(staged_file))
# download any missing files
if options.download_missing:
live_photo = staged.edited_live if options.edited else staged.original_live
@ -904,7 +908,7 @@ class PhotoExporter:
results = StagedFiles()
try:
exported = _export_photo_uuid_applescript(
exported = self._export_photo_uuid_applescript(
self.photo.uuid,
dest.parent,
filestem=dest.stem,
@ -955,7 +959,7 @@ class PhotoExporter:
def _should_convert_to_jpeg(
self, dest: pathlib.Path, options: ExportOptions
) -> Tuple[pathlib.Path, ExportOptions]:
) -> t.Tuple[pathlib.Path, ExportOptions]:
"""Determine if a file really should be converted to jpeg or not
and return the new destination and ExportOptions instance with the convert_to_jpeg flag set appropriately
"""
@ -1090,6 +1094,15 @@ class PhotoExporter:
if options.exiftool:
# if exiftool, write the metadata
# need to copy the file to a temp file before writing metadata
src = pathlib.Path(src)
tmp_file = increment_filename(
self._temp_dir_path / f"{src.stem}_exiftool{src.suffix}"
)
fileutil.copy(src, tmp_file)
# point src to the tmp_file so that the original source is not modified
# and the export grabs the new file
src = tmp_file
exif_results = self._write_exif_metadata_to_file(
src, dest, options=options
)
@ -1138,6 +1151,105 @@ class PhotoExporter:
return results
def _export_photo_uuid_applescript(
self,
uuid: str,
dest: str,
filestem=None,
original=True,
edited=False,
live_photo=False,
timeout=120,
burst=False,
dry_run=False,
overwrite=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
Args:
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
Raises: ExportError if error during export
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.
"""
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError(f"dest {dest} must be a directory")
if not original ^ edited:
raise ValueError("edited or original must be True but not both")
# export to a subdirectory of tmpdir
tmpdir = self.fileutil.tmpdir("osxphotos_applescript_export_", dir=self._temp_dir_path)
exported_files = []
filename = None
try:
# I've seen intermittent failures with the PhotoScript export so retry if
# export doesn't return anything
retries = 0
while not exported_files and retries < MAX_PHOTOSCRIPT_RETRIES:
photo = photoscript.Photo(uuid)
filename = photo.filename
exported_files = photo.export(
tmpdir.name, original=original, timeout=timeout
)
retries += 1
except Exception as e:
raise ExportError(e)
if not exported_files or not filename:
# nothing got exported
raise ExportError(f"Could not export photo {uuid} ({lineno(__file__)})")
# 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
exported_paths = []
for fname in exported_files:
path = pathlib.Path(tmpdir.name) / fname
if len(exported_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
continue
if len(exported_files) > 1 and burst and path.stem != filename_stem:
# skip any burst photo that's not the one we asked for
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
if not dry_run:
if overwrite and dest_new.exists():
FileUtil.unlink(dest_new)
FileUtil.copy(str(path), str(dest_new))
exported_paths.append(str(dest_new))
return exported_paths
def _write_sidecar_files(
self,
dest: pathlib.Path,
@ -1366,7 +1478,9 @@ class PhotoExporter:
return exiftool.warning, exiftool.error
def _exiftool_dict(
self, options: Optional[ExportOptions] = None, filename: Optional[str] = None
self,
options: t.Optional[ExportOptions] = None,
filename: t.Optional[str] = None,
):
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
@ -1668,9 +1782,9 @@ class PhotoExporter:
def _exiftool_json_sidecar(
self,
options: Optional[ExportOptions] = None,
options: t.Optional[ExportOptions] = None,
tag_groups: bool = True,
filename: Optional[str] = None,
filename: t.Optional[str] = None,
):
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
@ -1721,13 +1835,15 @@ class PhotoExporter:
return json.dumps([exif])
def _xmp_sidecar(
self, options: Optional[ExportOptions] = None, extension: Optional[str] = None
self,
options: t.Optional[ExportOptions] = None,
extension: t.Optional[str] = None,
):
"""returns string for XMP sidecar
Args:
options (ExportOptions): options for export
extension (Optional[str]): which extension to use for SidecarForExtension property
extension (t.Optional[str]): which extension to use for SidecarForExtension property
"""
options = options or ExportOptions()
@ -1859,101 +1975,6 @@ def hexdigest(strval):
return h.hexdigest()
def _export_photo_uuid_applescript(
uuid,
dest,
filestem=None,
original=True,
edited=False,
live_photo=False,
timeout=120,
burst=False,
dry_run=False,
overwrite=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
Args:
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
Raises: ExportError if error during export
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.
"""
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError(f"dest {dest} must be a directory")
if not original ^ edited:
raise ValueError("edited or original must be True but not both")
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
exported_files = []
filename = None
try:
# I've seen intermittent failures with the PhotoScript export so retry if
# export doesn't return anything
retries = 0
while not exported_files and retries < MAX_PHOTOSCRIPT_RETRIES:
photo = photoscript.Photo(uuid)
filename = photo.filename
exported_files = photo.export(
tmpdir.name, original=original, timeout=timeout
)
retries += 1
except Exception as e:
raise ExportError(e)
if not exported_files or not filename:
# nothing got exported
raise ExportError(f"Could not export photo {uuid} ({lineno(__file__)})")
# 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
exported_paths = []
for fname in exported_files:
path = pathlib.Path(tmpdir.name) / fname
if len(exported_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
continue
if len(exported_files) > 1 and burst and path.stem != filename_stem:
# skip any burst photo that's not the one we asked for
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
if not dry_run:
if overwrite and dest_new.exists():
FileUtil.unlink(dest_new)
FileUtil.copy(str(path), str(dest_new))
exported_paths.append(str(dest_new))
return exported_paths
def _check_export_suffix(src, dest, edited):
"""Helper function for exporting photos to check file extensions of destination path.

View File

@ -1384,7 +1384,7 @@ def test_query_exif_case_insensitive(exiftag, exifvalue, uuid_expected):
def test_export():
"""test basic export"""
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
@ -1395,6 +1395,22 @@ def test_export():
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES)
def test_export_tmpdir():
"""test basic export with --tmpdir"""
runner = CliRunner()
cwd = os.getcwd()
tmpdir = TemporaryDirectory()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--tmpdir", tmpdir.name],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES)
def test_export_uuid_from_file():
"""Test export with --uuid-from-file"""
@ -1811,6 +1827,40 @@ def test_export_exiftool():
assert exif[key] == CLI_EXIFTOOL[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_tmpdir():
"""test --exiftool with --tmpdir"""
runner = CliRunner()
cwd = os.getcwd()
tmpdir = TemporaryDirectory()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_EXIFTOOL:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--exiftool",
"--uuid",
f"{uuid}",
"--tmpdir",
tmpdir.name,
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted([CLI_EXIFTOOL[uuid]["File:FileName"]])
exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict()
for key in CLI_EXIFTOOL[uuid]:
if type(exif[key]) == list:
assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key])
else:
assert exif[key] == CLI_EXIFTOOL[uuid][key]
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_export_exiftool_template_change():
"""Test --exiftool when template changes with --update, #630"""
@ -6503,7 +6553,7 @@ def test_export_download_missing_preview():
"OSXPHOTOS_TEST_EXPORT" not in os.environ,
reason="Skip if not running on author's personal library.",
)
def test_export_download_missing_preview_applesccript():
def test_export_download_missing_preview_applescript():
"""test --download-missing --preview and applescript download, #564"""
runner = CliRunner()

View File

@ -1,8 +1,12 @@
""" test FileUtil """
import os
import pathlib
import pytest
from osxphotos.fileutil import FileUtil
TEST_HEIC = "tests/test-images/IMG_3092.heic"
TEST_RAW = "tests/test-images/DSC03584.dng"
@ -11,6 +15,7 @@ def test_copy_file_valid():
# copy file with valid src, dest
import os.path
import tempfile
from osxphotos.fileutil import FileUtil
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
@ -23,6 +28,7 @@ def test_copy_file_valid():
def test_copy_file_invalid():
# copy file with invalid src
import tempfile
from osxphotos.fileutil import FileUtil
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
@ -36,6 +42,7 @@ def test_hardlink_file_valid():
# hardlink file with valid src, dest
import os.path
import tempfile
from osxphotos.fileutil import FileUtil
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
@ -49,6 +56,7 @@ def test_hardlink_file_valid():
def test_unlink_file():
import os.path
import tempfile
from osxphotos.fileutil import FileUtil
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
@ -63,6 +71,7 @@ def test_unlink_file():
def test_rmdir():
import os.path
import tempfile
from osxphotos.fileutil import FileUtil
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
@ -77,9 +86,10 @@ def test_rmdir():
reason="Skip if running in Github actions, no GPU.",
)
def test_convert_to_jpeg():
""" test convert_to_jpeg """
"""test convert_to_jpeg"""
import pathlib
import tempfile
from osxphotos.fileutil import FileUtil
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
@ -95,9 +105,10 @@ def test_convert_to_jpeg():
reason="Skip if running in Github actions, no GPU.",
)
def test_convert_to_jpeg_quality():
""" test convert_to_jpeg with compression_quality """
"""test convert_to_jpeg with compression_quality"""
import pathlib
import tempfile
from osxphotos.fileutil import FileUtil
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
@ -113,6 +124,7 @@ def test_rename_file():
# rename file with valid src, dest
import pathlib
import tempfile
from osxphotos.fileutil import FileUtil
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
@ -125,3 +137,15 @@ def test_rename_file():
assert pathlib.Path(dest2).exists()
assert not pathlib.Path(dest).exists()
def test_tempdir():
"""Test FileUtil.tmpdir"""
tmpdir = FileUtil.tmpdir()
assert pathlib.Path(tmpdir.name).is_dir()
def test_tempdir_context_mgr():
"""Test Fileutil.tmpdir as context manager"""
with FileUtil.tmpdir() as tmpdir_name:
assert pathlib.Path(tmpdir_name).is_dir()
assert not pathlib.Path(tmpdir_name).is_dir()