Compare commits

..

25 Commits

Author SHA1 Message Date
Rhet Turnbull
116cb662fb Added test for missing original_filename 2020-11-25 18:32:12 -08:00
Rhet Turnbull
db68defc44 version bump 2020-11-25 17:55:07 -08:00
Rhet Turnbull
7460bc88fc Add @jstrine as a contributor 2020-11-25 17:54:53 -08:00
Rhet Turnbull
dbbbbf10a8 Merge pull request #272 from jstrine/fix_xml_escaping
Add XML escaping to XMP sidecar export, thanks to @jstrine for the fix!
2020-11-25 17:52:22 -08:00
Rhet Turnbull
0633814ab2 Merge pull request #270 from jstrine/fix_gps_xmp
Fix EXIF GPS format for XMP sidecar, thanks to @jstrine for the fix!
2020-11-25 17:51:57 -08:00
Rhet Turnbull
df7d45659a Merge pull request #268 from jstrine/fix_path_none
Continue even if the original filename is None, thanks to @jstrine for the fix!
2020-11-25 17:51:38 -08:00
Jonathan Strine
cec266bba4 Fix tests again
Third times the charm to fix a find-replace error this time.
2020-11-25 19:51:09 -05:00
Jonathan Strine
d0d2e80800 Fix tests for apostrophe
Previous commit didn't reflect all locations and had a copy/paste error.
2020-11-25 19:45:21 -05:00
Jonathan Strine
aafdbea564 Fix test to accomodate for escaped apostrophe 2020-11-25 19:36:09 -05:00
Jonathan Strine
c42050a10c Escape characters which cause XML parsing issues 2020-11-25 19:31:51 -05:00
Jonathan Strine
c27cfb1223 Fix test for XMP sidecar with GPS info 2020-11-25 19:24:56 -05:00
Jonathan Strine
ad144da8a0 Use GPSCoordinate format for geolocation 2020-11-25 18:09:38 -05:00
Jonathan Strine
5352aec3b9 Continue even if the original filename is None
Some photos seemed to be missing the original_filename (returning None).
This fix prevents the traceback.
2020-11-25 17:00:22 -05:00
Rhet Turnbull
e951e5361e Exposed --use-photos-export and --use-photokit 2020-11-25 09:15:16 -08:00
Rhet Turnbull
f7bd1376e1 Updated CHANGELOG.md 2020-11-24 06:50:52 -08:00
Rhet Turnbull
26f96d582c Added photokit export as hidden --use-photokit option 2020-11-23 06:23:19 -08:00
Rhet Turnbull
8cb15d1555 Removed debug statement in _photoinfo_export 2020-11-18 22:03:23 -08:00
Rhet Turnbull
2d9429c8ee Fixed missing data file for photoscript 2020-11-14 14:18:47 -08:00
Rhet Turnbull
3b6dd08d2b Version bump, updated requirements 2020-11-14 13:37:46 -08:00
Rhet Turnbull
3c85f26f90 Moved AppleScript to photoscript 2020-11-14 13:34:50 -08:00
Rhet Turnbull
52c054f81f Updated CHANGELOG.md 2020-11-14 09:32:08 -08:00
Rhet Turnbull
8dc59cbc35 Fixed erroneous attempt to export edited with --download-missing 2020-11-12 06:51:36 -08:00
Rhet Turnbull
802e2f069a version bump 2020-11-12 06:18:56 -08:00
Rhet Turnbull
5d4d7d7db7 Fixed path for photos actually missing off disk 2020-11-12 06:18:28 -08:00
Rhet Turnbull
ea9b41bae4 Avoid copying db files if not necessary 2020-11-11 07:03:57 -08:00
32 changed files with 2092 additions and 107 deletions

View File

@@ -100,6 +100,15 @@
"contributions": [ "contributions": [
"code" "code"
] ]
},
{
"login": "jstrine",
"name": "Jonathan Strine",
"avatar_url": "https://avatars1.githubusercontent.com/u/33943447?v=4",
"profile": "https://github.com/jstrine",
"contributions": [
"code"
]
} }
], ],
"contributorsPerLine": 7 "contributorsPerLine": 7

View File

@@ -4,6 +4,53 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.36.20](https://github.com/RhetTbull/osxphotos/compare/v0.36.19...v0.36.20)
> 23 November 2020
- Added photokit export as hidden --use-photokit option [`26f96d5`](https://github.com/RhetTbull/osxphotos/commit/26f96d582c01ce9816b1f54f0e74c8570f133f7c)
#### [v0.36.19](https://github.com/RhetTbull/osxphotos/compare/v0.36.18...v0.36.19)
> 19 November 2020
- Removed debug statement in _photoinfo_export [`8cb15d1`](https://github.com/RhetTbull/osxphotos/commit/8cb15d15551094dcaf1b0ef32d6ac0273be7fd37)
#### [v0.36.18](https://github.com/RhetTbull/osxphotos/compare/v0.36.17...v0.36.18)
> 14 November 2020
- Moved AppleScript to photoscript [`3c85f26`](https://github.com/RhetTbull/osxphotos/commit/3c85f26f901645ce297685ccd639792757fbc995)
- Fixed missing data file for photoscript [`2d9429c`](https://github.com/RhetTbull/osxphotos/commit/2d9429c8eefabe6233fc580f65511c48ee6c01e5)
- Version bump, updated requirements [`3b6dd08`](https://github.com/RhetTbull/osxphotos/commit/3b6dd08d2bb2b20a55064bf24fe7ce788e7268ef)
#### [v0.36.17](https://github.com/RhetTbull/osxphotos/compare/v0.36.15...v0.36.17)
> 12 November 2020
- Fixed path for photos actually missing off disk [`5d4d7d7`](https://github.com/RhetTbull/osxphotos/commit/5d4d7d7db7ca1109b6230803fe777d7a30882efe)
- Fixed erroneous attempt to export edited with --download-missing [`8dc59cb`](https://github.com/RhetTbull/osxphotos/commit/8dc59cbc35c33e71d0d912f4139e855180ac4fbd)
- version bump [`802e2f0`](https://github.com/RhetTbull/osxphotos/commit/802e2f069a5f8b37ddc6b3b8ba07519ce10f88a7)
#### [v0.36.15](https://github.com/RhetTbull/osxphotos/compare/v0.36.14...v0.36.15)
> 11 November 2020
- Avoid copying db files if not necessary [`ea9b41b`](https://github.com/RhetTbull/osxphotos/commit/ea9b41bae41a05aad53454f67871c5e6c9a49f79)
#### [v0.36.14](https://github.com/RhetTbull/osxphotos/compare/v0.36.13...v0.36.14)
> 9 November 2020
- Fix for issue #247 [`38397b5`](https://github.com/RhetTbull/osxphotos/commit/38397b507b456169cf3be2d2dc6743ec8653feb3)
#### [v0.36.13](https://github.com/RhetTbull/osxphotos/compare/v0.36.11...v0.36.13)
> 9 November 2020
- Refactored phototemplate.py to add PATH_SEP option [`3636fcb`](https://github.com/RhetTbull/osxphotos/commit/3636fcbc76100d9898a59f24ed6e9b1965cc6022)
- More work on phototemplate.py to add inline expansion [`a6231e2`](https://github.com/RhetTbull/osxphotos/commit/a6231e29ff28b2c7dc3239445f41afcb35926a7a)
#### [v0.36.11](https://github.com/RhetTbull/osxphotos/compare/v0.36.10...v0.36.11) #### [v0.36.11](https://github.com/RhetTbull/osxphotos/compare/v0.36.10...v0.36.11)
> 8 November 2020 > 8 November 2020

View File

@@ -3,7 +3,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python package](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg) [![Python package](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#contributors-) [![All Contributors](https://img.shields.io/badge/all_contributors-11-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
- [OSXPhotos](#osxphotos) - [OSXPhotos](#osxphotos)
@@ -356,6 +356,16 @@ Options:
to a filesystem that doesn't support Mac OS to a filesystem that doesn't support Mac OS
extended attributes. Only use this if you extended attributes. Only use this if you
get an error while exporting. get an error while exporting.
--use-photos-export Force the use of AppleScript or PhotoKit to
export even if not missing (see also '--
download-missing' and '--use-photokit').
--use-photokit Use with '--download-missing' or '--use-
photos-export' to use direct Photos
interface instead of AppleScript to export.
Highly experimental alpha feature; does not
work with iTerm2 (use with Terminal.app).
This is faster and more reliable than the
default AppleScript interface.
-h, --help Show this message and exit. -h, --help Show this message and exit.
** Export ** ** Export **
@@ -2149,6 +2159,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/grundsch"><img src="https://avatars0.githubusercontent.com/u/3874928?v=4?s=100" width="100px;" alt=""/><br /><sub><b>grundsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=grundsch" title="Code">💻</a></td> <td align="center"><a href="https://github.com/grundsch"><img src="https://avatars0.githubusercontent.com/u/3874928?v=4?s=100" width="100px;" alt=""/><br /><sub><b>grundsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=grundsch" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/agprimatic"><img src="https://avatars1.githubusercontent.com/u/4685054?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ag Primatic</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=agprimatic" title="Code">💻</a></td> <td align="center"><a href="https://github.com/agprimatic"><img src="https://avatars1.githubusercontent.com/u/4685054?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ag Primatic</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=agprimatic" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/hhoeck"><img src="https://avatars1.githubusercontent.com/u/6313998?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Horst Höck</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hhoeck" title="Code">💻</a></td> <td align="center"><a href="https://github.com/hhoeck"><img src="https://avatars1.githubusercontent.com/u/6313998?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Horst Höck</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hhoeck" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jstrine"><img src="https://avatars1.githubusercontent.com/u/33943447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jonathan Strine</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jstrine" title="Code">💻</a></td>
</tr> </tr>
</table> </table>

View File

@@ -5,4 +5,4 @@
# If you need to install pyinstaller: # If you need to install pyinstaller:
# python3 -m pip install --upgrade pyinstaller # python3 -m pip install --upgrade pyinstaller
pyinstaller --onefile --hidden-import="pkg_resources.py2_warn" --name osxphotos --add-data osxphotos/templates/xmp_sidecar.mako:osxphotos/templates cli.py pyinstaller osxphotos.spec

48
osxphotos.spec Normal file
View File

@@ -0,0 +1,48 @@
# -*- mode: python ; coding: utf-8 -*-
# spec file for pyinstaller
# run `pyinstaller osxphotos.spec`
import os
import importlib
pathex = os.getcwd()
# include necessary data files
datas=[('osxphotos/templates/xmp_sidecar.mako', 'osxphotos/templates')]
package_imports = [['photoscript', ['photoscript.applescript']]]
for package, files in package_imports:
proot = os.path.dirname(importlib.import_module(package).__file__)
datas.extend((os.path.join(proot, f), package) for f in files)
block_cipher = None
a = Analysis(['cli.py'],
pathex=[pathex],
binaries=[],
datas=datas,
hiddenimports=['pkg_resources.py2_warn'],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='osxphotos',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True )

View File

@@ -28,6 +28,7 @@ from .export_db import ExportDB, ExportDBInMemory
from .fileutil import FileUtil, FileUtilNoOp from .fileutil import FileUtil, FileUtilNoOp
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .photoinfo import ExportResults from .photoinfo import ExportResults
from .photokit import check_photokit_authorization, request_photokit_authorization
from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
# global variable to control verbose output # global variable to control verbose output
@@ -1394,8 +1395,15 @@ def query(
"--use-photos-export", "--use-photos-export",
is_flag=True, is_flag=True,
default=False, default=False,
hidden=True, help="Force the use of AppleScript or PhotoKit to export even if not missing (see also '--download-missing' and '--use-photokit').",
help="Force the use of AppleScript to export even if not missing (see also --download-missing).", )
@click.option(
"--use-photokit",
is_flag=True,
default=False,
help="Use with '--download-missing' or '--use-photos-export' to use direct Photos interface instead of AppleScript to export. "
"Highly experimental alpha feature; does not work with iTerm2 (use with Terminal.app). "
"This is faster and more reliable than the default AppleScript interface.",
) )
@DB_ARGUMENT @DB_ARGUMENT
@click.argument("dest", nargs=1, type=click.Path(exists=True)) @click.argument("dest", nargs=1, type=click.Path(exists=True))
@@ -1487,6 +1495,7 @@ def export(
deleted, deleted,
deleted_only, deleted_only,
use_photos_export, use_photos_export,
use_photokit,
): ):
""" Export photos from the Photos database. """ Export photos from the Photos database.
Export path DEST is required. Export path DEST is required.
@@ -1537,6 +1546,18 @@ def export(
click.echo(cli.commands["export"].get_help(ctx), err=True) click.echo(cli.commands["export"].get_help(ctx), err=True)
return return
if use_photokit and not check_photokit_authorization():
click.echo(
"Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access."
)
request_photokit_authorization()
click.confirm("Have you granted access?")
if not check_photokit_authorization():
click.echo(
"Failed to get access to the Photos library which is needed with `--use-photokit`."
)
return
# initialize export flags # initialize export flags
# by default, will export all versions of photos unless skip flag is set # by default, will export all versions of photos unless skip flag is set
(export_edited, export_bursts, export_live, export_raw) = [ (export_edited, export_bursts, export_live, export_raw) = [
@@ -1733,6 +1754,7 @@ def export(
convert_to_jpeg=convert_to_jpeg, convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified, ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
) )
results_exported.extend(results.exported) results_exported.extend(results.exported)
results_new.extend(results.new) results_new.extend(results.new)
@@ -1783,6 +1805,7 @@ def export(
convert_to_jpeg=convert_to_jpeg, convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified, ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
) )
results_exported.extend(results.exported) results_exported.extend(results.exported)
results_new.extend(results.new) results_new.extend(results.new)
@@ -2290,6 +2313,7 @@ def export_photo(
convert_to_jpeg=False, convert_to_jpeg=False,
jpeg_quality=1.0, jpeg_quality=1.0,
ignore_date_modified=False, ignore_date_modified=False,
use_photokit=False,
): ):
""" Helper function for export that does the actual export """ Helper function for export that does the actual export
@@ -2341,10 +2365,10 @@ def export_photo(
space = " " if not verbose_ else "" space = " " if not verbose_ else ""
verbose(f"{space}Skipping missing photo {photo.original_filename}") verbose(f"{space}Skipping missing photo {photo.original_filename}")
return ExportResults([], [], [], [], [], []) return ExportResults([], [], [], [], [], [])
elif not os.path.exists(photo.path): elif photo.path is None:
space = " " if not verbose_ else "" space = " " if not verbose_ else ""
verbose( verbose(
f"{space}WARNING: file {photo.path} is missing but ismissing=False, " f"{space}WARNING: photo {photo.original_filename} ({photo.uuid}) is missing but ismissing=False, "
f"skipping {photo.original_filename}" f"skipping {photo.original_filename}"
) )
return ExportResults([], [], [], [], [], []) return ExportResults([], [], [], [], [], [])
@@ -2362,6 +2386,10 @@ def export_photo(
results_touched = [] results_touched = []
export_original = not (skip_original_if_edited and photo.hasadjustments) export_original = not (skip_original_if_edited and photo.hasadjustments)
# can't export edited if photo doesn't have edited versions
export_edited = export_edited if photo.hasadjustments else False
# slow_mo photos will always have hasadjustments=True even if not edited # slow_mo photos will always have hasadjustments=True even if not edited
if photo.hasadjustments and photo.path_edited is None: if photo.hasadjustments and photo.path_edited is None:
if photo.slow_mo: if photo.slow_mo:
@@ -2396,7 +2424,7 @@ def export_photo(
download_missing download_missing
and ( and (
photo.ismissing photo.ismissing
or not os.path.exists(photo.path) or photo.path is None
or (export_edited and photo.path_edited is None) or (export_edited and photo.path_edited is None)
) )
) )
@@ -2430,6 +2458,7 @@ def export_photo(
convert_to_jpeg=convert_to_jpeg, convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified, ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
) )
results_exported.extend(export_results.exported) results_exported.extend(export_results.exported)
@@ -2492,6 +2521,7 @@ def export_photo(
convert_to_jpeg=convert_to_jpeg, convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified, ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
) )
results_exported.extend(export_results_edited.exported) results_exported.extend(export_results_edited.exported)
@@ -2549,7 +2579,7 @@ def get_filenames_from_template(photo, filename_template, original_name):
) )
filenames = [f"{file_}{photo_ext}" for file_ in filenames] filenames = [f"{file_}{photo_ext}" for file_ in filenames]
else: else:
filenames = [photo.original_filename] if original_name else [photo.filename] filenames = [photo.original_filename] if (original_name and (photo.original_filename is not None)) else [photo.filename]
filenames = [sanitize_filename(filename) for filename in filenames] filenames = [sanitize_filename(filename) for filename in filenames]
return filenames return filenames

View File

@@ -1,4 +1,4 @@
""" version info """ """ version info """
__version__ = "0.36.14" __version__ = "0.36.22"

View File

@@ -1,6 +1,5 @@
""" FileUtil class with methods for copy, hardlink, unlink, etc. """ """ FileUtil class with methods for copy, hardlink, unlink, etc. """
import logging
import os import os
import pathlib import pathlib
import stat import stat
@@ -74,7 +73,6 @@ class FileUtilMacOS(FileUtilABC):
try: try:
os.link(src, dest) os.link(src, dest)
except Exception as e: except Exception as e:
logging.critical(f"os.link returned error: {e}")
raise e raise e
@classmethod @classmethod
@@ -92,7 +90,7 @@ class FileUtilMacOS(FileUtilABC):
if src is None or dest is None: if src is None or dest is None:
raise ValueError("src and dest must not be None", src, dest) raise ValueError("src and dest must not be None", src, dest)
if not os.path.isfile(src): if not os.path.exists(src):
raise FileNotFoundError("src file does not appear to exist", src) raise FileNotFoundError("src file does not appear to exist", src)
if norsrc: if norsrc:
@@ -104,9 +102,6 @@ class FileUtilMacOS(FileUtilABC):
try: try:
result = subprocess.run(command, check=True, stderr=subprocess.PIPE) result = subprocess.run(command, check=True, stderr=subprocess.PIPE)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logging.critical(
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
)
raise e raise e
return result.returncode return result.returncode

View File

@@ -1,8 +1,9 @@
""" utility functions for validating/sanitizing path components """ """ utility functions for validating/sanitizing path components """
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
import pathvalidate import pathvalidate
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
def sanitize_filepath(filepath): def sanitize_filepath(filepath):
""" sanitize a filepath """ """ sanitize a filepath """

View File

@@ -21,9 +21,10 @@ import re
import tempfile import tempfile
from collections import namedtuple # pylint: disable=syntax-error from collections import namedtuple # pylint: disable=syntax-error
import photoscript
from mako.template import Template from mako.template import Template
from .._applescript import AppleScript # from .._applescript import AppleScript
from .._constants import ( from .._constants import (
_MAX_IPTC_KEYWORD_LEN, _MAX_IPTC_KEYWORD_LEN,
_OSXPHOTOS_NONE_SENTINEL, _OSXPHOTOS_NONE_SENTINEL,
@@ -31,9 +32,15 @@ from .._constants import (
_UNKNOWN_PERSON, _UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME, _XMP_TEMPLATE_NAME,
) )
from ..export_db import ExportDBNoOp
from ..exiftool import ExifTool from ..exiftool import ExifTool
from ..export_db import ExportDBNoOp
from ..fileutil import FileUtil from ..fileutil import FileUtil
from ..photokit import (
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PhotoLibrary,
PhotoKitFetchFailed,
)
from ..utils import dd_to_dms_str, findfiles from ..utils import dd_to_dms_str, findfiles
ExportResults = namedtuple( ExportResults = namedtuple(
@@ -78,33 +85,33 @@ def _export_photo_uuid_applescript(
""" """
# setup the applescript to do the export # setup the applescript to do the export
export_scpt = AppleScript( # export_scpt = AppleScript(
""" # """
on export_by_uuid(theUUID, thePath, original, edited, theTimeOut) # on export_by_uuid(theUUID, thePath, original, edited, theTimeOut)
tell application "Photos" # tell application "Photos"
set thePath to thePath # set thePath to thePath
set theItem to media item id theUUID # set theItem to media item id theUUID
set theFilename to filename of theItem # set theFilename to filename of theItem
set itemList to {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 # 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) dest = pathlib.Path(dest)
if not dest.is_dir(): if not dest.is_dir():
@@ -115,30 +122,36 @@ def _export_photo_uuid_applescript(
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_") tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
# export original exported_files = []
filename = None filename = None
try: try:
filename = export_scpt.call( photo = photoscript.Photo(uuid)
"export_by_uuid", uuid, tmpdir.name, original, edited, timeout filename = photo.filename
) exported_files = photo.export(tmpdir.name, original=original, timeout=timeout)
# filename = export_scpt.call(
# "export_by_uuid", uuid, tmpdir.name, original, edited, timeout
# )
except Exception as e: except Exception as e:
logging.warning(f"Error exporting uuid {uuid}: {e}") logging.warning(f"Error exporting uuid {uuid}: {e}")
return None return None
if filename is not None: if exported_files and filename:
# need to find actual filename as sometimes Photos renames JPG to jpeg on export # 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) # may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
# TemporaryDirectory will cleanup on return # TemporaryDirectory will cleanup on return
filename_stem = pathlib.Path(filename).stem filename_stem = pathlib.Path(filename).stem
files = glob.glob(os.path.join(tmpdir.name, "*"))
exported_paths = [] exported_paths = []
for fname in files: for fname in exported_files:
path = pathlib.Path(fname) path = pathlib.Path(tmpdir.name) / fname
if len(files) > 1 and not live_photo and path.suffix.lower() == ".mov": 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 # it's the .mov part of live photo but not requested, so don't export
logging.debug(f"Skipping live photo file {path}") logging.debug(f"Skipping live photo file {path}")
continue continue
if len(files) > 1 and burst and path.stem != filename_stem: if len(exported_files) > 1 and burst and path.stem != filename_stem:
# skip any burst photo that's not the one we asked for # skip any burst photo that's not the one we asked for
logging.debug(f"Skipping burst photo file {path}") logging.debug(f"Skipping burst photo file {path}")
continue continue
@@ -310,6 +323,7 @@ def export2(
convert_to_jpeg=False, convert_to_jpeg=False,
jpeg_quality=1.0, jpeg_quality=1.0,
ignore_date_modified=False, ignore_date_modified=False,
use_photokit=False,
): ):
""" export photo, like export but with update and dry_run options """ export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised dest: must be valid destination path or exception raised
@@ -651,32 +665,73 @@ def export2(
# didn't get passed a filename, add _edited # didn't get passed a filename, add _edited
filestem = f"{dest.stem}{edited_identifier}" filestem = f"{dest.stem}{edited_identifier}"
dest = dest.parent / f"{filestem}.jpeg" dest = dest.parent / f"{filestem}.jpeg"
if use_photokit:
exported = _export_photo_uuid_applescript( photolib = PhotoLibrary()
self.uuid, photo = None
dest.parent, try:
filestem=filestem, photo = photolib.fetch_uuid(self.uuid)
original=False, except PhotoKitFetchFailed:
edited=True, # if failed to find UUID, might be a burst photo
live_photo=live_photo, if self.burst and self._info["burstUUID"]:
timeout=timeout, bursts = photolib.fetch_burst_uuid(
burst=self.burst, self._info["burstUUID"], all=True
dry_run=dry_run, )
) # PhotoKit UUIDs may contain "/L0/001" so only look at beginning
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
photo = photo[0] if photo else None
if photo:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT
)
else:
exported = []
else:
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=False,
edited=True,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
)
else: else:
# export original version and not edited # export original version and not edited
filestem = dest.stem filestem = dest.stem
exported = _export_photo_uuid_applescript( if use_photokit:
self.uuid, photolib = PhotoLibrary()
dest.parent, photo = None
filestem=filestem, try:
original=True, photo = photolib.fetch_uuid(self.uuid)
edited=False, except PhotoKitFetchFailed:
live_photo=live_photo, # if failed to find UUID, might be a burst photo
timeout=timeout, if self.burst and self._info["burstUUID"]:
burst=self.burst, bursts = photolib.fetch_burst_uuid(
dry_run=dry_run, self._info["burstUUID"], all=True
) )
# PhotoKit UUIDs may contain "/L0/001" so only look at beginning
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
photo = photo[0] if photo else None
if photo:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
)
else:
exported = []
else:
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=True,
edited=False,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
)
if exported: if exported:
if touch_file: if touch_file:
for exported_file in exported: for exported_file in exported:
@@ -1191,7 +1246,6 @@ def _exiftool_dict(
timeoriginal = date.strftime(f"%H:%M:%S{offsettime}") timeoriginal = date.strftime(f"%H:%M:%S{offsettime}")
exif["IPTC:TimeCreated"] = timeoriginal exif["IPTC:TimeCreated"] = timeoriginal
print(f"time = {timeoriginal}")
if self.date_modified is not None and not ignore_date_modified: if self.date_modified is not None and not ignore_date_modified:
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S") exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")

View File

@@ -164,6 +164,8 @@ class PhotoInfo:
photopath = os.path.join( photopath = os.path.join(
self._db._masters_path, self._info["imagePath"] self._db._masters_path, self._info["imagePath"]
) )
if not os.path.isfile(photopath):
photopath = None
self._path = photopath self._path = photopath
return photopath return photopath
@@ -175,6 +177,8 @@ class PhotoInfo:
self._info["directory"], self._info["directory"],
self._info["filename"], self._info["filename"],
) )
if not os.path.isfile(photopath):
photopath = None
self._path = photopath self._path = photopath
return photopath return photopath
@@ -188,6 +192,8 @@ class PhotoInfo:
self._info["directory"], self._info["directory"],
self._info["filename"], self._info["filename"],
) )
if not os.path.isfile(photopath):
photopath = None
self._path = photopath self._path = photopath
return photopath return photopath

1215
osxphotos/photokit.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@ from .._constants import (
from .._version import __version__ from .._version import __version__
from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
from ..fileutil import FileUtil
from ..personinfo import PersonInfo from ..personinfo import PersonInfo
from ..photoinfo import PhotoInfo from ..photoinfo import PhotoInfo
from ..utils import ( from ..utils import (
@@ -546,6 +547,33 @@ class PhotosDB:
return dest_path return dest_path
# NOTE: This method seems to cause problems with applescript
# Bummer...would'be been nice to avoid copying the DB
# def _link_db_file(self, fname):
# """ links the sqlite database file to a temp file """
# """ returns the name of the temp file """
# """ If sqlite shared memory and write-ahead log files exist, those are copied too """
# # required because python's sqlite3 implementation can't read a locked file
# # _, suffix = os.path.splitext(fname)
# dest_name = dest_path = ""
# try:
# dest_name = pathlib.Path(fname).name
# dest_path = os.path.join(self._tempdir_name, dest_name)
# FileUtil.hardlink(fname, dest_path)
# # link write-ahead log and shared memory files (-wal and -shm) files if they exist
# if os.path.exists(f"{fname}-wal"):
# FileUtil.hardlink(f"{fname}-wal", f"{dest_path}-wal")
# if os.path.exists(f"{fname}-shm"):
# FileUtil.hardlink(f"{fname}-shm", f"{dest_path}-shm")
# except:
# print("Error linking " + fname + " to " + dest_path, file=sys.stderr)
# raise Exception
# if _debug():
# logging.debug(dest_path)
# return dest_path
def _process_database4(self): def _process_database4(self):
""" process the Photos database to extract info """ process the Photos database to extract info
works on Photos version <= 4.0 """ works on Photos version <= 4.0 """

View File

@@ -12,7 +12,7 @@
% if desc is None: % if desc is None:
<dc:description></dc:description> <dc:description></dc:description>
% else: % else:
<dc:description>${desc}</dc:description> <dc:description>${desc | x}</dc:description>
% endif % endif
</%def> </%def>
@@ -20,7 +20,7 @@
% if title is None: % if title is None:
<dc:title></dc:title> <dc:title></dc:title>
% else: % else:
<dc:title>${title}</dc:title> <dc:title>${title | x}</dc:title>
% endif % endif
</%def> </%def>
@@ -30,7 +30,7 @@
<dc:subject> <dc:subject>
<rdf:Seq> <rdf:Seq>
% for subj in subject: % for subj in subject:
<rdf:li>${subj}</rdf:li> <rdf:li>${subj | x}</rdf:li>
% endfor % endfor
</rdf:Seq> </rdf:Seq>
</dc:subject> </dc:subject>
@@ -48,7 +48,7 @@
<Iptc4xmpExt:PersonInImage> <Iptc4xmpExt:PersonInImage>
<rdf:Bag> <rdf:Bag>
% for person in persons: % for person in persons:
<rdf:li>${person}</rdf:li> <rdf:li>${person | x}</rdf:li>
% endfor % endfor
</rdf:Bag> </rdf:Bag>
</Iptc4xmpExt:PersonInImage> </Iptc4xmpExt:PersonInImage>
@@ -60,7 +60,7 @@
<digiKam:TagsList> <digiKam:TagsList>
<rdf:Seq> <rdf:Seq>
% for keyword in keywords: % for keyword in keywords:
<rdf:li>${keyword}</rdf:li> <rdf:li>${keyword | x}</rdf:li>
% endfor % endfor
</rdf:Seq> </rdf:Seq>
</digiKam:TagsList> </digiKam:TagsList>
@@ -81,10 +81,8 @@
<%def name="gps_info(latitude, longitude)"> <%def name="gps_info(latitude, longitude)">
% if latitude is not None and longitude is not None: % if latitude is not None and longitude is not None:
<exif:GPSLongitudeRef>${"E" if longitude >= 0 else "W"}</exif:GPSLongitudeRef> <exif:GPSLongitude>${int(abs(longitude))},${(abs(longitude) % 1) * 60}${"E" if longitude >= 0 else "W"}</exif:GPSLongitude>
<exif:GPSLongitude>${abs(longitude)}</exif:GPSLongitude> <exif:GPSLatitude>${int(abs(latitude))},${(abs(latitude) % 1) * 60}${"N" if latitude >= 0 else "S"}</exif:GPSLatitude>
<exif:GPSLatitude>${abs(latitude)}</exif:GPSLatitude>
<exif:GPSLatitudeRef>${"N" if latitude >= 0 else "S"}</exif:GPSLatitudeRef>
% endif % endif
</%def> </%def>

View File

@@ -17,7 +17,6 @@ from plistlib import load as plistload
import CoreFoundation import CoreFoundation
import CoreServices import CoreServices
import objc import objc
from Foundation import *
from ._constants import UNICODE_FORMAT from ._constants import UNICODE_FORMAT
from .fileutil import FileUtil from .fileutil import FileUtil
@@ -57,10 +56,12 @@ def _debug():
""" returns True if debugging turned on (via _set_debug), otherwise, false """ """ returns True if debugging turned on (via _set_debug), otherwise, false """
return _DEBUG return _DEBUG
def noop(*args, **kwargs): def noop(*args, **kwargs):
""" do nothing (no operation) """ """ do nothing (no operation) """
pass pass
def _get_os_version(): def _get_os_version():
# returns tuple containing OS version # returns tuple containing OS version
# e.g. 10.13.6 = (10, 13, 6) # e.g. 10.13.6 = (10, 13, 6)
@@ -200,7 +201,7 @@ def get_last_library_path():
# pylint: disable=no-member # pylint: disable=no-member
# pylint: disable=undefined-variable # pylint: disable=undefined-variable
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData( photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
kCFAllocatorDefault, photosurlref, 0, None, None, None, None CoreFoundation.kCFAllocatorDefault, photosurlref, 0, None, None, None, None
) )
# the CFURLRef we got is a sruct that python treats as an array # the CFURLRef we got is a sruct that python treats as an array
@@ -361,9 +362,35 @@ def _db_is_locked(dbname):
def normalize_unicode(value): def normalize_unicode(value):
""" normalize unicode data """ """ normalize unicode data """
if value is not None: if value is None:
if not isinstance(value, str):
raise ValueError("value must be str")
return unicodedata.normalize(UNICODE_FORMAT, value)
else:
return None return None
if not isinstance(value, str):
raise ValueError("value must be str")
return unicodedata.normalize(UNICODE_FORMAT, value)
def increment_filename(filepath):
""" Return filename (1).ext, etc if filename.ext exists
If file exists in filename's parent folder with same stem as filename,
add (1), (2), etc. until a non-existing filename is found.
Args:
filepath: str; full path, including file name
Returns:
new filepath (or same if not incremented)
Note: This obviously is subject to race condition so using with caution.
"""
dest = pathlib.Path(str(filepath))
count = 1
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
dest_new = dest.stem
while dest_new.lower() in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
return str(dest)

View File

@@ -47,6 +47,7 @@ parso==0.6.2
pathspec==0.7.0 pathspec==0.7.0
pathvalidate==2.2.1 pathvalidate==2.2.1
pexpect==4.8.0 pexpect==4.8.0
photoscript==0.1.0
pickleshare==0.7.5 pickleshare==0.7.5
Pillow==7.2.0 Pillow==7.2.0
pkginfo==1.5.0.1 pkginfo==1.5.0.1

View File

@@ -79,6 +79,7 @@ setup(
"pathvalidate==2.2.1", "pathvalidate==2.2.1",
"dataclasses==0.7;python_version<'3.7'", "dataclasses==0.7;python_version<'3.7'",
"wurlitzer>=2.0.1", "wurlitzer>=2.0.1",
"photoscript>=0.1.0",
], ],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]}, entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True, include_package_data=True,

114
tests/tempdiskimage.py Normal file
View File

@@ -0,0 +1,114 @@
""" Create a temporary disk image on MacOS """
import pathlib
import platform
import subprocess
import tempfile
import time
class TempDiskImage:
""" Create and mount a temporary disk image """
def __init__(self, size=100, prefix=None):
""" Create and mount a temporary disk image.
Args:
size: int; size in MB of disk image, default = 100
prefix: str; optional prefix to prepend to name of the temporary disk image
name: str; name of the mounted volume, default = "TemporaryDiskImage"
Raises:
TypeError if size is not int
RunTimeError if not on MacOS
"""
if type(size) != int:
raise TypeError("size must be int")
system = platform.system()
if system != "Darwin":
raise RuntimeError("TempDiskImage only runs on MacOS")
self._tempdir = tempfile.TemporaryDirectory()
# hacky mktemp: this could create a race condition but unlikely given it's created in a TemporaryDirectory
prefix = "TemporaryDiskImage" if prefix is None else prefix
volume_name = f"{prefix}_{str(time.time()).replace('.','_')}_{str(time.perf_counter()).replace('.','_')}"
image_name = f"{volume_name}.dmg"
image_path = pathlib.Path(self._tempdir.name) / image_name
hdiutil = subprocess.run(
[
"/usr/bin/hdiutil",
"create",
"-size",
f"{size}m",
"-fs",
"HFS+",
"-volname",
volume_name,
image_path,
],
check=True,
text=True,
capture_output=True,
)
if "created" not in hdiutil.stdout:
raise OSError(f"Could not create DMG {image_path}")
self.path = image_path
self._mount_point, self.name = self._mount_image(self.path)
def _mount_image(self, image_path):
""" mount a DMG file and return path, returns (mount_point, path) """
hdiutil = subprocess.run(
["/usr/bin/hdiutil", "attach", image_path],
check=True,
text=True,
capture_output=True,
)
mount_point, path = None, None
for line in hdiutil.stdout.split("\n"):
line = line.strip()
if "Apple_HFS" not in line:
continue
output = line.split()
if len(output) < 3:
raise ValueError(f"Error mounting disk image {image_path}")
mount_point = output[0]
path = output[2]
break
return (mount_point, path)
def unmount(self):
try:
if self._mount_point:
hdiutil = subprocess.run(
["/usr/bin/hdiutil", "detach", self._mount_point],
check=True,
text=True,
capture_output=True,
)
self._mount_point = None
except AttributeError:
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.unmount()
if exc_type:
return False
if __name__ == "__main__":
# Create a temporary disk image, 50mb in size
img = TempDiskImage(size=50, prefix="MyDiskImage")
# Be sure to unmount it, image will be cleaned up automatically
img.unmount()
# Or use it as a context handler
# Default values are 100mb and prefix = "TemporaryDiskImage"
with TempDiskImage() as img:
print(f"image: {img.path}")
print(f"mounted at: {img.name}")

View File

@@ -863,6 +863,21 @@ def test_export_14(photosdb, caplog):
assert "Invalid destination suffix" not in caplog.text assert "Invalid destination suffix" not in caplog.text
def test_export_no_original_filename(photosdb):
# test export OK if original filename is null
# issue #267
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
photos[0]._info["original_filename"] = None
filename = f"{photos[0].uuid}.jpeg"
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)[0]
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
def test_eq(): def test_eq():
""" Test equality of two PhotoInfo objects """ """ Test equality of two PhotoInfo objects """

View File

@@ -11,9 +11,9 @@ pytestmark = pytest.mark.skipif(
PHOTOS_DB = "/Users/rhet/Pictures/Photos Library.photoslibrary" PHOTOS_DB = "/Users/rhet/Pictures/Photos Library.photoslibrary"
UUID_DICT = { UUID_DICT = {
"has_adjustments": "A8111956-E900-4DEC-9191-A04A87C07BC5", "has_adjustments": "2B2D5434-6D31-49E2-BF47-B973D34A317B",
"no_adjustments": "EA7BB55F-92F1-4818-94E3-E8DEDC6B2E31", "no_adjustments": "A8D646C3-89A9-4D74-8001-4EB46BA55B94",
"live": "9032C168-9319-40C0-8210-5ADC42F4C603", "live": "BFF29EBD-22DF-4FCF-9817-317E7104EA50",
} }

View File

@@ -1029,7 +1029,7 @@ def test_xmp_sidecar_gps():
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"> xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension> <photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description></dc:description> <dc:description></dc:description>
<dc:title>St. James's Park</dc:title> <dc:title>St. James&#39;s Park</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does --> <!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject> <dc:subject>
<rdf:Seq> <rdf:Seq>
@@ -1038,7 +1038,7 @@ def test_xmp_sidecar_gps():
<rdf:li>London</rdf:li> <rdf:li>London</rdf:li>
<rdf:li>United Kingdom</rdf:li> <rdf:li>United Kingdom</rdf:li>
<rdf:li>London 2018</rdf:li> <rdf:li>London 2018</rdf:li>
<rdf:li>St. James's Park</rdf:li> <rdf:li>St. James&#39;s Park</rdf:li>
</rdf:Seq> </rdf:Seq>
</dc:subject> </dc:subject>
<photoshop:DateCreated>2018-10-13T09:18:12.501000-04:00</photoshop:DateCreated> <photoshop:DateCreated>2018-10-13T09:18:12.501000-04:00</photoshop:DateCreated>
@@ -1055,7 +1055,7 @@ def test_xmp_sidecar_gps():
<rdf:li>London</rdf:li> <rdf:li>London</rdf:li>
<rdf:li>United Kingdom</rdf:li> <rdf:li>United Kingdom</rdf:li>
<rdf:li>London 2018</rdf:li> <rdf:li>London 2018</rdf:li>
<rdf:li>St. James's Park</rdf:li> <rdf:li>St. James&#39;s Park</rdf:li>
</rdf:Seq> </rdf:Seq>
</digiKam:TagsList> </digiKam:TagsList>
</rdf:Description> </rdf:Description>
@@ -1066,10 +1066,8 @@ def test_xmp_sidecar_gps():
</rdf:Description> </rdf:Description>
<rdf:Description rdf:about="" <rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'> xmlns:exif='http://ns.adobe.com/exif/1.0/'>
<exif:GPSLongitudeRef>W</exif:GPSLongitudeRef> <exif:GPSLongitude>0,7.908329999999999W</exif:GPSLongitude>
<exif:GPSLongitude>0.1318055</exif:GPSLongitude> <exif:GPSLatitude>51,30.21430019999997N</exif:GPSLatitude>
<exif:GPSLatitude>51.50357167</exif:GPSLatitude>
<exif:GPSLatitudeRef>N</exif:GPSLatitudeRef>
</rdf:Description> </rdf:Description>
</rdf:RDF> </rdf:RDF>
</x:xmpmeta>""" </x:xmpmeta>"""

View File

@@ -20,10 +20,10 @@ NAMES_DICT = {
"heic": "7783E8E6-9CAC-40F3-BE22-81FB7051C266.jpeg", "heic": "7783E8E6-9CAC-40F3-BE22-81FB7051C266.jpeg",
} }
UUID_LIVE_HEIC = "1337F3F6-5C9F-4FC7-80CC-BD9A5B928F72" UUID_LIVE_HEIC = "612CE30B-3D8F-417A-9B14-EC42CBA10ACC"
NAMES_LIVE_HEIC = [ NAMES_LIVE_HEIC = [
"1337F3F6-5C9F-4FC7-80CC-BD9A5B928F72.jpeg", "612CE30B-3D8F-417A-9B14-EC42CBA10ACC.jpeg",
"1337F3F6-5C9F-4FC7-80CC-BD9A5B928F72.mov", "612CE30B-3D8F-417A-9B14-EC42CBA10ACC.mov",
] ]

387
tests/test_photokit.py Normal file
View File

@@ -0,0 +1,387 @@
""" test photokit.py methods """
import os
import pathlib
import tempfile
import pytest
from osxphotos.photokit import (
LivePhotoAsset,
PhotoAsset,
PhotoLibrary,
VideoAsset,
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
)
skip_test = "OSXPHOTOS_TEST_EXPORT" not in os.environ
pytestmark = pytest.mark.skipif(
skip_test, reason="Skip if not running with author's personal library."
)
UUID_DICT = {
"plain_photo": {
"uuid": "A8D646C3-89A9-4D74-8001-4EB46BA55B94",
"filename": "IMG_8844.JPG",
},
"hdr": {"uuid": "DA87C6FF-60E8-4DCB-A21D-9C57595667F1", "filename": "IMG_6162.JPG"},
"selfie": {
"uuid": "316AEBE0-971D-4A33-833C-6BDBFF83469B",
"filename": "IMG_1929.JPG",
},
"video": {
"uuid": "5814D9DE-FAB6-473A-9C9A-5A73C6DD1AF5",
"filename": "IMG_9411.TRIM.MOV",
},
"hasadjustments": {
"uuid": "2B2D5434-6D31-49E2-BF47-B973D34A317B",
"filename": "IMG_2860.JPG",
"adjusted_size": 3012634,
"unadjusted_size": 2580058,
},
"slow_mo": {
"uuid": "DAABC6D9-1FBA-4485-AA39-0A2B100300B1",
"filename": "IMG_4055.MOV",
},
"live_photo": {
"uuid": "612CE30B-3D8F-417A-9B14-EC42CBA10ACC",
"filename": "IMG_3259.HEIC",
"filename_video": "IMG_3259.mov",
},
"burst": {
"uuid": "CD97EC84-71F0-40C6-BAC1-2BABEE305CAC",
"filename": "IMG_8196.JPG",
"burst_selected": 3,
"burst_all": 5,
},
}
def test_fetch_uuid():
""" test fetch_uuid """
uuid = UUID_DICT["plain_photo"]["uuid"]
filename = UUID_DICT["plain_photo"]["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert isinstance(photo, PhotoAsset)
def test_plain_photo():
""" test plain_photo """
uuid = UUID_DICT["plain_photo"]["uuid"]
filename = UUID_DICT["plain_photo"]["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert photo.original_filename == filename
assert photo.isphoto
assert not photo.ismovie
def test_hdr():
""" test hdr """
uuid = UUID_DICT["hdr"]["uuid"]
filename = UUID_DICT["hdr"]["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert photo.original_filename == filename
assert photo.hdr
def test_burst():
""" test burst and burstid """
test_dict = UUID_DICT["burst"]
uuid = test_dict["uuid"]
filename = test_dict["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert photo.original_filename == filename
assert photo.burst
assert photo.burstid
# def test_selfie():
# """ test selfie """
# uuid = UUID_DICT["selfie"]["uuid"]
# filename = UUID_DICT["selfie"]["filename"]
# lib = PhotoLibrary()
# photo = lib.fetch_uuid(uuid)
# assert photo.original_filename == filename
# assert photo.selfie
def test_video():
""" test ismovie """
uuid = UUID_DICT["video"]["uuid"]
filename = UUID_DICT["video"]["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert isinstance(photo, VideoAsset)
assert photo.original_filename == filename
assert photo.ismovie
assert not photo.isphoto
def test_slow_mo():
""" test slow_mo """
test_dict = UUID_DICT["slow_mo"]
uuid = test_dict["uuid"]
filename = test_dict["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert isinstance(photo, VideoAsset)
assert photo.original_filename == filename
assert photo.ismovie
assert photo.slow_mo
assert not photo.isphoto
### PhotoAsset
def test_export_photo_original():
""" test PhotoAsset.export """
test_dict = UUID_DICT["hasadjustments"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_ORIGINAL)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
assert export_path.stat().st_size == test_dict["unadjusted_size"]
def test_export_photo_unadjusted():
""" test PhotoAsset.export """
test_dict = UUID_DICT["hasadjustments"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_UNADJUSTED)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
assert export_path.stat().st_size == test_dict["unadjusted_size"]
def test_export_photo_current():
""" test PhotoAsset.export """
test_dict = UUID_DICT["hasadjustments"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
assert export_path.stat().st_size == test_dict["adjusted_size"]
### VideoAsset
def test_export_video_original():
""" test VideoAsset.export """
test_dict = UUID_DICT["video"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_ORIGINAL)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
def test_export_video_unadjusted():
""" test VideoAsset.export """
test_dict = UUID_DICT["video"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_UNADJUSTED)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
def test_export_video_current():
""" test VideoAsset.export """
test_dict = UUID_DICT["video"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_CURRENT)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
### Slow-Mo VideoAsset
def test_export_slow_mo_original():
""" test VideoAsset.export for slow mo video"""
test_dict = UUID_DICT["slow_mo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_ORIGINAL)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
def test_export_slow_mo_unadjusted():
""" test VideoAsset.export for slow mo video"""
test_dict = UUID_DICT["slow_mo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_UNADJUSTED)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
def test_export_slow_mo_current():
""" test VideoAsset.export for slow mo video"""
test_dict = UUID_DICT["slow_mo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_CURRENT)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["filename"]
assert export_path.stem == pathlib.Path(filename).stem
### LivePhotoAsset
def test_export_live_original():
""" test LivePhotoAsset.export """
test_dict = UUID_DICT["live_photo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_ORIGINAL)
for f in export_path:
filepath = pathlib.Path(f)
assert filepath.is_file()
filename = test_dict["filename"]
assert filepath.stem == pathlib.Path(filename).stem
def test_export_live_unadjusted():
""" test LivePhotoAsset.export """
test_dict = UUID_DICT["live_photo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_UNADJUSTED)
for file in export_path:
filepath = pathlib.Path(file)
assert filepath.is_file()
filename = test_dict["filename"]
assert filepath.stem == pathlib.Path(filename).stem
def test_export_live_current():
""" test LivePhotAsset.export """
test_dict = UUID_DICT["live_photo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, version=PHOTOS_VERSION_CURRENT)
for file in export_path:
filepath = pathlib.Path(file)
assert filepath.is_file()
filename = test_dict["filename"]
assert filepath.stem == pathlib.Path(filename).stem
def test_export_live_current_just_photo():
""" test LivePhotAsset.export """
test_dict = UUID_DICT["live_photo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, photo=True, video=False)
assert len(export_path) == 1
assert export_path[0].lower().endswith(".heic")
def test_export_live_current_just_video():
""" test LivePhotAsset.export """
test_dict = UUID_DICT["live_photo"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, photo=False, video=True)
assert len(export_path) == 1
assert export_path[0].lower().endswith(".mov")
def test_fetch_burst_uuid():
""" test fetch_burst_uuid """
test_dict = UUID_DICT["burst"]
uuid = test_dict["uuid"]
filename = test_dict["filename"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
bursts_selected = lib.fetch_burst_uuid(photo.burstid)
assert len(bursts_selected) == test_dict["burst_selected"]
assert isinstance(bursts_selected[0], PhotoAsset)
bursts_all = lib.fetch_burst_uuid(photo.burstid, all=True)
assert len(bursts_all) == test_dict["burst_all"]
assert isinstance(bursts_all[0], PhotoAsset)