@@ -50,7 +50,7 @@ from osxphotos.datetime_formatter import DateTimeFormatter
|
|||||||
from osxphotos.debug import is_debug, set_debug
|
from osxphotos.debug import is_debug, set_debug
|
||||||
from osxphotos.exiftool import get_exiftool_path
|
from osxphotos.exiftool import get_exiftool_path
|
||||||
from osxphotos.export_db import ExportDB, ExportDBInMemory
|
from osxphotos.export_db import ExportDB, ExportDBInMemory
|
||||||
from osxphotos.fileutil import FileUtil, FileUtilNoOp
|
from osxphotos.fileutil import FileUtil, FileUtilNoOp, FileUtilShUtil
|
||||||
from osxphotos.path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
|
from osxphotos.path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
|
||||||
from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter
|
from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter
|
||||||
from osxphotos.photoinfo import PhotoInfoNone
|
from osxphotos.photoinfo import PhotoInfoNone
|
||||||
@@ -651,6 +651,15 @@ from .verbose import get_verbose_console, time_stamp, verbose_print
|
|||||||
"the Photos library.",
|
"the Photos library.",
|
||||||
type=click.Path(dir_okay=True, file_okay=False, exists=True),
|
type=click.Path(dir_okay=True, file_okay=False, exists=True),
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--alt-copy",
|
||||||
|
is_flag=True,
|
||||||
|
help="Use alternate copy method that may be more reliable for some "
|
||||||
|
"network attached storage (NAS) devices. Use --alt-copy if you experience "
|
||||||
|
"problems exporting to a NAS device or SMB volume. "
|
||||||
|
"Unlike the default copy method, --alt-copy does not support "
|
||||||
|
"copy-on-write on APFS volumes nor does it preserve filesystem metadata.",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--load-config",
|
"--load-config",
|
||||||
required=False,
|
required=False,
|
||||||
@@ -745,6 +754,7 @@ def export(
|
|||||||
added_in_last,
|
added_in_last,
|
||||||
album_keyword,
|
album_keyword,
|
||||||
album,
|
album,
|
||||||
|
alt_copy,
|
||||||
append,
|
append,
|
||||||
beta,
|
beta,
|
||||||
burst,
|
burst,
|
||||||
@@ -969,6 +979,7 @@ def export(
|
|||||||
add_skipped_to_album = cfg.add_skipped_to_album
|
add_skipped_to_album = cfg.add_skipped_to_album
|
||||||
album = cfg.album
|
album = cfg.album
|
||||||
album_keyword = cfg.album_keyword
|
album_keyword = cfg.album_keyword
|
||||||
|
alt_copy = cfg.alt_copy
|
||||||
append = cfg.append
|
append = cfg.append
|
||||||
beta = cfg.beta
|
beta = cfg.beta
|
||||||
burst = cfg.burst
|
burst = cfg.burst
|
||||||
@@ -1333,7 +1344,7 @@ def export(
|
|||||||
if ramdb
|
if ramdb
|
||||||
else ExportDB(dbfile=export_db_path, export_dir=dest)
|
else ExportDB(dbfile=export_db_path, export_dir=dest)
|
||||||
)
|
)
|
||||||
fileutil = FileUtil
|
fileutil = FileUtilShUtil if alt_copy else FileUtil
|
||||||
|
|
||||||
if verbose_:
|
if verbose_:
|
||||||
if export_db.was_created:
|
if export_db.was_created:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
import tempfile
|
import tempfile
|
||||||
import typing as t
|
import typing as t
|
||||||
@@ -12,7 +13,7 @@ import Foundation
|
|||||||
|
|
||||||
from .imageconverter import ImageConverter
|
from .imageconverter import ImageConverter
|
||||||
|
|
||||||
__all__ = ["FileUtilABC", "FileUtilMacOS", "FileUtil", "FileUtilNoOp"]
|
__all__ = ["FileUtilABC", "FileUtilMacOS", "FileUtilShUtil", "FileUtil", "FileUtilNoOp"]
|
||||||
|
|
||||||
|
|
||||||
class FileUtilABC(ABC):
|
class FileUtilABC(ABC):
|
||||||
@@ -25,7 +26,7 @@ class FileUtilABC(ABC):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def copy(cls, src, dest, norsrc=False):
|
def copy(cls, src, dest):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -250,6 +251,43 @@ class FileUtilMacOS(FileUtilABC):
|
|||||||
return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime))
|
return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime))
|
||||||
|
|
||||||
|
|
||||||
|
class FileUtilShUtil(FileUtilMacOS):
|
||||||
|
"""Various file utilities, uses shutil.copy to copy files instead of NSFileManager (#807)"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def copy(cls, src, dest):
|
||||||
|
"""Copies a file from src path to dest path using shutil.copy
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src: source path as string; must be a valid file path
|
||||||
|
dest: destination path as string
|
||||||
|
dest may be either directory or file; in either case, src file must not exist in dest
|
||||||
|
Note: src and dest may be either a string or a pathlib.Path object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if copy succeeded
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError if copy fails
|
||||||
|
TypeError if either path is None
|
||||||
|
"""
|
||||||
|
if not isinstance(src, pathlib.Path):
|
||||||
|
src = pathlib.Path(src)
|
||||||
|
|
||||||
|
if not isinstance(dest, pathlib.Path):
|
||||||
|
dest = pathlib.Path(dest)
|
||||||
|
|
||||||
|
if dest.is_dir():
|
||||||
|
dest /= src.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.copy(str(src), str(dest))
|
||||||
|
except Exception as e:
|
||||||
|
raise OSError(f"Error copying {src} to {dest}: {e}") from e
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class FileUtil(FileUtilMacOS):
|
class FileUtil(FileUtilMacOS):
|
||||||
"""Various file utilities"""
|
"""Various file utilities"""
|
||||||
|
|
||||||
@@ -280,7 +318,7 @@ class FileUtilNoOp(FileUtil):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def copy(cls, src, dest, norsrc=False):
|
def copy(cls, src, dest):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -1397,6 +1397,20 @@ def test_export():
|
|||||||
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES)
|
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_alt_copy():
|
||||||
|
"""test basic export with --alt-copy"""
|
||||||
|
runner = CliRunner()
|
||||||
|
cwd = os.getcwd()
|
||||||
|
# pylint: disable=not-context-manager
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
result = runner.invoke(
|
||||||
|
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--alt-copy", "-V"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
files = glob.glob("*")
|
||||||
|
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES)
|
||||||
|
|
||||||
|
|
||||||
def test_export_tmpdir():
|
def test_export_tmpdir():
|
||||||
"""test basic export with --tmpdir"""
|
"""test basic export with --tmpdir"""
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
@@ -4862,7 +4876,7 @@ def test_export_then_hardlink():
|
|||||||
|
|
||||||
|
|
||||||
def test_export_dry_run():
|
def test_export_dry_run():
|
||||||
"""test export with dry-run flag"""
|
"""test export with --dry-run flag"""
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
cwd = os.getcwd()
|
cwd = os.getcwd()
|
||||||
@@ -4881,6 +4895,27 @@ def test_export_dry_run():
|
|||||||
assert not os.path.isfile(normalize_fs_path(filepath))
|
assert not os.path.isfile(normalize_fs_path(filepath))
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_dry_run_alt_copy():
|
||||||
|
"""test export with --dry-run flag and --alt-copy"""
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
cwd = os.getcwd()
|
||||||
|
# pylint: disable=not-context-manager
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
result = runner.invoke(
|
||||||
|
export,
|
||||||
|
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--alt-copy", "--dry-run"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert (
|
||||||
|
f"Processed: {PHOTOS_NOT_IN_TRASH_LEN_15_7} photos, exported: {PHOTOS_NOT_IN_TRASH_LEN_15_7+PHOTOS_EDITED_15_7}, missing: 3, error: 0"
|
||||||
|
in result.output
|
||||||
|
)
|
||||||
|
for filepath in CLI_EXPORT_FILENAMES_DRY_RUN:
|
||||||
|
assert re.search(r"Exported.*" + f"{re.escape(filepath)}", result.output)
|
||||||
|
assert not os.path.isfile(normalize_fs_path(filepath))
|
||||||
|
|
||||||
|
|
||||||
def test_export_update_edits_dry_run():
|
def test_export_update_edits_dry_run():
|
||||||
"""test export then update after removing and editing files with dry-run flag"""
|
"""test export then update after removing and editing files with dry-run flag"""
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pathlib
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from osxphotos.fileutil import FileUtil
|
from osxphotos.fileutil import FileUtil, FileUtilShUtil
|
||||||
|
|
||||||
TEST_HEIC = "tests/test-images/IMG_3092.heic"
|
TEST_HEIC = "tests/test-images/IMG_3092.heic"
|
||||||
TEST_RAW = "tests/test-images/DSC03584.dng"
|
TEST_RAW = "tests/test-images/DSC03584.dng"
|
||||||
@@ -38,6 +38,33 @@ def test_copy_file_invalid():
|
|||||||
assert e.type == OSError
|
assert e.type == OSError
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_file_valid_shutil():
|
||||||
|
# copy file with valid src, dest with the shutil implementation
|
||||||
|
import os.path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
|
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
|
src = "tests/test-images/wedding.jpg"
|
||||||
|
result = FileUtilShUtil.copy(src, temp_dir.name)
|
||||||
|
assert result
|
||||||
|
assert os.path.isfile(os.path.join(temp_dir.name, "wedding.jpg"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_file_invalid_shutil():
|
||||||
|
# copy file with invalid src with the shutil implementation
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from osxphotos.fileutil import FileUtil
|
||||||
|
|
||||||
|
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||||
|
with pytest.raises(Exception) as e:
|
||||||
|
src = "tests/test-images/wedding_DOES_NOT_EXIST.jpg"
|
||||||
|
assert FileUtilShUtil.copy(src, temp_dir.name)
|
||||||
|
assert e.type == OSError
|
||||||
|
|
||||||
|
|
||||||
def test_hardlink_file_valid():
|
def test_hardlink_file_valid():
|
||||||
# hardlink file with valid src, dest
|
# hardlink file with valid src, dest
|
||||||
import os.path
|
import os.path
|
||||||
|
|||||||
Reference in New Issue
Block a user