diff --git a/README.md b/README.md index b216e8d1..5c71f94a 100644 --- a/README.md +++ b/README.md @@ -892,7 +892,7 @@ Returns True if photo is a panorama, otherwise False. #### `json()` Returns a JSON representation of all photo info -#### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False)` +#### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, no_xattr=False)` Export photo from the Photos library to another destination on disk. - dest: must be valid destination path as str (or exception raised). @@ -906,6 +906,7 @@ Export photo from the Photos library to another destination on disk. - use_photos_export: boolean; (default=False), if True will attempt to export photo via applescript interaction with Photos; useful for forcing download of missing photos. This only works if the Photos library being used is the default library (last opened by Photos) as applescript will directly interact with whichever library Photos is currently using. - timeout: (int, default=120) timeout in seconds used with use_photos_export - exiftool: (boolean, default = False) if True, will use [exiftool](https://exiftool.org/) to write metadata directly to the exported photo; exiftool must be installed and in the system path +- no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes Returns: list of paths to exported files. More than one file could be exported, for example if live_photo=True, both the original imaage and the associated .mov file will be exported diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 167add1f..1a74bb4a 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -912,6 +912,14 @@ def query( help="Optional template for specifying name of output directory in the form '{name,DEFAULT}'. " "See below for additional details on templating system.", ) +@click.option( + "--no-extended-attributes", + is_flag=True, + default=False, + help="Don't copy extended attributes when exporting. You only need this if exporting " + "to a filesystem that doesn't support Mac OS extended attributes. Only use this if you get " + "an error while exporting.", +) @DB_ARGUMENT @click.argument("dest", nargs=1, type=click.Path(exists=True)) @click.pass_obj @@ -975,6 +983,7 @@ def export( directory, place, no_place, + no_extended_attributes, ): """ Export photos from the Photos database. Export path DEST is required. @@ -1117,6 +1126,7 @@ def export( download_missing, exiftool, directory, + no_extended_attributes, ) else: for p in photos: @@ -1133,6 +1143,7 @@ def export( download_missing, exiftool, directory, + no_extended_attributes, ) if export_paths: click.echo(f"Exported {p.filename} to {export_paths}") @@ -1491,6 +1502,7 @@ def export_photo( download_missing, exiftool, directory, + no_extended_attributes, ): """ Helper function for export that does the actual export photo: PhotoInfo object @@ -1505,6 +1517,7 @@ def export_photo( download_missing: attempt download of missing iCloud photos exiftool: use exiftool to write EXIF metadata directly to exported photo directory: template used to determine output directory + no_extended_attributes: boolean; if True, exports photo without preserving extended attributes returns list of path(s) of exported photo or None if photo was missing """ @@ -1586,6 +1599,7 @@ def export_photo( overwrite=overwrite, use_photos_export=use_photos_export, exiftool=exiftool, + no_xattr=no_extended_attributes, )[0] photo_paths.append(photo_path) @@ -1620,6 +1634,7 @@ def export_photo( edited=True, use_photos_export=use_photos_export, exiftool=exiftool, + no_xattr=no_extended_attributes, ) return photo_paths diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 33a2d568..86bc46e9 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.25.0" +__version__ = "0.25.1" diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index 103f9ab0..d723cb3e 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -522,6 +522,7 @@ class PhotoInfo: use_photos_export=False, timeout=120, exiftool=False, + no_xattr=False, ): """ export photo dest: must be valid destination path (or exception raised) @@ -545,6 +546,7 @@ class PhotoInfo: use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos timeout: (int, default=120) timeout in seconds used with use_photos_export exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file + no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes returns list of full paths to the exported files """ # list of all files exported during this call to export @@ -667,7 +669,7 @@ class PhotoInfo: ) # copy the file, _copy_file uses ditto to preserve Mac extended attributes - _copy_file(src, dest) + _copy_file(src, dest, norsrc=no_xattr) exported_files.append(str(dest)) # copy live photo associated .mov if requested @@ -679,7 +681,7 @@ class PhotoInfo: logging.debug( f"Exporting live photo video of {filename} as {live_name.name}" ) - _copy_file(src_live, str(live_name)) + _copy_file(src_live, str(live_name), norsrc=no_xattr) exported_files.append(str(live_name)) else: logging.warning(f"Skipping missing live movie for {filename}") diff --git a/osxphotos/utils.py b/osxphotos/utils.py index be521dd1..ed1f72a3 100644 --- a/osxphotos/utils.py +++ b/osxphotos/utils.py @@ -114,10 +114,14 @@ def _dd_to_dms(dd): return int(deg_), int(min_), sec_ -def _copy_file(src, dest): +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 """ @@ -125,19 +129,24 @@ def _copy_file(src, dest): raise ValueError("src and dest must not be None", src, dest) if not os.path.isfile(src): - raise ValueError("src file does not appear to exist", 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: - subprocess.run( - ["/usr/bin/ditto", src, dest], check=True, stderr=subprocess.PIPE - ) + 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 """ diff --git a/tests/test_export_catalina_10_15_1.py b/tests/test_export_catalina_10_15_1.py index f55db19b..fc8b06d0 100644 --- a/tests/test_export_catalina_10_15_1.py +++ b/tests/test_export_catalina_10_15_1.py @@ -374,6 +374,28 @@ def test_export_13(): assert e.type == type(FileNotFoundError()) +def test_export_no_xattr(): + # test basic export with no_xattr=True + # get an unedited image and export it using default filename + import os + import os.path + import tempfile + + import osxphotos + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photos = photosdb.photos(uuid=[UUID_DICT["export"]]) + + filename = photos[0].filename + expected_dest = os.path.join(dest, filename) + got_dest = photos[0].export(dest, no_xattr=True)[0] + + assert got_dest == expected_dest + assert os.path.isfile(got_dest) + + def test_dd_to_dms_str_1(): import osxphotos @@ -517,4 +539,3 @@ def test_xmp_sidecar(): for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines): assert line_expected == line_got - diff --git a/tests/test_utils.py b/tests/test_utils.py index f2c83a6c..a534b76b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -51,3 +51,41 @@ def test_db_is_locked_unlocked(): import osxphotos assert not osxphotos.utils._db_is_locked(DB_UNLOCKED_10_15) + + +def test_copy_file_valid(): + # _copy_file with valid src, dest + import os.path + import tempfile + from osxphotos.utils import _copy_file + + temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_") + src = "tests/test-images/wedding.jpg" + result = _copy_file(src, temp_dir.name) + assert result == 0 + assert os.path.isfile(os.path.join(temp_dir.name, "wedding.jpg")) + + +def test_copy_file_invalid(): + # _copy_file with invalid src + import tempfile + from osxphotos.utils import _copy_file + + temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_") + src = "tests/test-images/wedding_DOES_NOT_EXIST.jpg" + with pytest.raises(Exception) as e: + assert _copy_file(src, temp_dir.name) + assert e.type == FileNotFoundError + + +def test_copy_file_norsrc(): + # _copy_file with --norsrc + import os.path + import tempfile + from osxphotos.utils import _copy_file + + temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_") + src = "tests/test-images/wedding.jpg" + result = _copy_file(src, temp_dir.name, norsrc=True) + assert result == 0 + assert os.path.isfile(os.path.join(temp_dir.name, "wedding.jpg"))