changed interface for export, prepped for exiftool_json_sidecar

This commit is contained in:
Rhet Turnbull
2019-12-15 19:21:04 -08:00
parent b35e9d73ab
commit 1fe885962e
40 changed files with 235 additions and 60 deletions

View File

@@ -47,7 +47,7 @@
- [`hidden()`](#hidden)
- [`location()`](#location)
- [`to_json()`](#to_json)
- [`export(*args, edited=False, overwrite=False, increment=True)`](#exportargs-editedfalse-overwritefalse-incrementtrue)
- [`export(dest, *filename, edited=False, overwrite=False, increment=True)`](#exportdest-filename-editedfalse-overwritefalse-incrementtrue)
+ [Examples](#examples)
* [History](#history)
* [Contributing](#contributing)
@@ -490,10 +490,10 @@ Returns latitude and longitude as a tuple of floats (latitude, longitude). If l
#### `to_json()`
Returns a JSON representation of all photo info
#### `export(*args, edited=False, overwrite=False, increment=True)`
#### `export(dest, *filename, edited=False, overwrite=False, increment=True)`
Export photo from the Photos library to another destination on disk.
- First argument of *args must be valid destination path (or exception raised).
- Second argument of *args (optional): name of picture; if not provided, will use current filename
- First argument dest must be valid destination path (or exception raised).
- Second argument *filename (optional): name of picture; if not provided, will use current filename
- edited: boolean; if True (default=False), will export the edited version of the photo (or raise exception if no edited version)
- overwrite: boolean; if True (default=False), will overwrite files if they alreay exist
- increment: boolean; if True (default=True), will increment file name until a non-existant name is found

View File

@@ -4,6 +4,7 @@ import logging
import os.path
import pathlib
import platform
import re
import sqlite3
import subprocess
import sys
@@ -88,8 +89,8 @@ def _get_os_version():
def _check_file_exists(filename):
# returns true if file exists and is not a directory
# otherwise returns false
""" returns true if file exists and is not a directory
otherwise returns false """
filename = os.path.abspath(filename)
return os.path.exists(filename) and not os.path.isdir(filename)
@@ -112,6 +113,54 @@ def _get_resource_loc(model_id):
return folder_id, file_id
def _dd_to_dms(dd):
""" convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds """
""" return tuple of int(deg), int(min), float(sec) """
dd = float(dd)
negative = dd < 0
dd = abs(dd)
min_, sec_ = divmod(dd * 3600, 60)
deg_, min_ = divmod(min_, 60)
if negative:
if deg_ > 0:
deg_ = deg_ * -1
elif min_ > 0:
min_ = min_ * -1
else:
sec_ = sec_ * -1
return int(deg_), int(min_), sec_
def dd_to_dms_str(lat, lon):
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
""" lat: latitude in degrees """
""" lon: longitude in degrees """
""" returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """
""" this is the same format used by exiftool's json format """
# TODO: add this to readme
lat_deg, lat_min, lat_sec = _dd_to_dms(lat)
lon_deg, lon_min, lon_sec = _dd_to_dms(lon)
lat_hemisphere = "N"
if any([lat_deg < 0, lat_min < 0, lat_sec < 0]):
lat_hemisphere = "S"
lon_hemisphere = "E"
if any([lon_deg < 0, lon_min < 0, lon_sec < 0]):
lon_hemisphere = "W"
lat_str = (
f"{abs(lat_deg)} deg {abs(lat_min)}' {abs(lat_sec):.2f}\" {lat_hemisphere}"
)
lon_str = (
f"{abs(lon_deg)} deg {abs(lon_min)}' {abs(lon_sec):.2f}\" {lon_hemisphere}"
)
return lat_str, lon_str
def get_system_library_path():
""" return the path to the system Photos library as string """
""" only works on MacOS 10.15+ """
@@ -1464,7 +1513,7 @@ class PhotoInfo:
""" returns (latitude, longitude) as float in degrees or None """
return (self._latitude(), self._longitude())
def export(self, *args, edited=False, overwrite=False, increment=True):
def export(self, dest, *filename, edited=False, overwrite=False, increment=True):
""" export photo """
""" first argument must be valid destination path (or exception raised) """
""" second argument (optional): name of picture; if not provided, will use current filename """
@@ -1478,44 +1527,37 @@ class PhotoInfo:
# maybe dest, *filename?
# check arguments and get destination path and filename (if provided)
dest = None # destination path
filename = None # photo filename
if not args:
# need at least one arg (destination)
raise TypeError("Must pass destination as first argument")
if filename and len(filename) > 2:
raise TypeError(
"Too many positional arguments. Should be at most two: destination, filename."
)
else:
if len(args) > 2:
raise TypeError(
"Too many positional arguments. Should be at most two: destination, filename."
)
else:
# verify destination is a valid path
dest = args[0]
if dest is None:
raise ValueError("Destination must not be None")
elif not os.path.isdir(dest):
raise FileNotFoundError("Invalid path passed to export")
# verify destination is a valid path
if dest is None:
raise ValueError("Destination must not be None")
elif not os.path.isdir(dest):
raise FileNotFoundError("Invalid path passed to export")
if len(args) == 2:
# second arg is filename of picture
filename = args[1]
else:
# no filename provided so use the default
# if edited file requested, use filename but add _edited
# need to use file extension from edited file as Photos saves a jpeg once edited
if edited:
# verify we have a valid path_edited and use that to get filename
if not self.path_edited():
raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments()}"
)
edited_name = Path(self.path_edited()).name
edited_suffix = Path(edited_name).suffix
filename = (
Path(self.filename()).stem + "_edited" + edited_suffix
if filename and len(filename) == 1:
# second arg is filename of picture
filename = filename[0]
else:
# no filename provided so use the default
# if edited file requested, use filename but add _edited
# need to use file extension from edited file as Photos saves a jpeg once edited
if edited:
# verify we have a valid path_edited and use that to get filename
if not self.path_edited():
raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments()}"
)
else:
filename = self.filename()
edited_name = Path(self.path_edited()).name
edited_suffix = Path(edited_name).suffix
filename = (
Path(self.filename()).stem + "_edited" + edited_suffix
)
else:
filename = self.filename()
# get path to source file and verify it's not None and is valid file
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
@@ -1584,6 +1626,66 @@ class PhotoInfo:
return str(dest)
def _exiftool_json_sidecar(self):
""" return json string of EXIF details in exiftool sidecar format """
exif = {}
exif["FileName"] = self.filename()
if self.description():
exif["ImageDescription"] = self.description()
exif["Description"] = self.description()
if self.title():
exif["Title"] = self.title()
if self.keywords():
exif["TagsList"] = exif["Keywords"] = self.keywords()
if self.persons():
exif["PersonInImage"] = self.persons()
# if self.favorite():
# exif["Rating"] = 5
(lat, lon) = self.location()
if lat is not None and lon is not None:
lat_str, lon_str = dd_to_dms_str(lat, lon)
exif["GPSLatitude"] = lat_str
exif["GPSLongitude"] = lon_str
exif["GPSPosition"] = f"{lat_str}, {lon_str}"
lat_ref = "North" if lat >= 0 else "South"
lon_ref = "East" if lon >= 0 else "West"
exif["GPSLatitudeRef"] = lat_ref
exif["GPSLongitudeRef"] = lon_ref
# process date/time and timezone offset
date = self.date()
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
offsettime = date.strftime("%z")
# find timezone offset in format "-04:00"
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["DateTimeOriginal"] = datetimeoriginal
exif["OffsetTimeOriginal"] = offsettime
json_str = json.dumps([exif])
return json_str
def _write_sidecar_car(self, filename, json_str):
if not filename and not json_str:
raise (
ValueError(
f"filename {filename} and json_str {json_str} must not be None"
)
)
# TODO: catch exception?
f = open(filename, "w")
f.write(json_str)
f.close()
def _longitude(self):
""" Returns longitude, in degrees """
return self._info["longitude"]

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2019-08-24T02:50:48Z</date>
<date>2019-12-08T16:44:38Z</date>
</dict>
<key>PXPeopleScreenUnlocked</key>
<true/>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-12-07T16:40:40Z</date>
<date>2019-12-16T02:55:50Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-12-07T16:40:41Z</date>
<date>2019-12-16T02:55:50Z</date>
</dict>
</plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2019-12-07T16:40:32Z</date>
<date>2019-12-13T18:43:07Z</date>
</dict>
</plist>

View File

@@ -7,7 +7,7 @@
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<key>pid</key>
<integer>423</integer>
<integer>1794</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

View File

@@ -3,24 +3,24 @@
<plist version="1.0">
<dict>
<key>BackgroundHighlightCollection</key>
<date>2019-12-14T18:19:30Z</date>
<date>2019-12-15T18:49:56Z</date>
<key>BackgroundHighlightEnrichment</key>
<date>2019-12-14T18:19:29Z</date>
<date>2019-12-15T18:49:35Z</date>
<key>BackgroundJobAssetRevGeocode</key>
<date>2019-12-14T18:19:30Z</date>
<date>2019-12-15T20:55:19Z</date>
<key>BackgroundJobSearch</key>
<date>2019-12-14T18:19:30Z</date>
<date>2019-12-15T18:49:56Z</date>
<key>BackgroundPeopleSuggestion</key>
<date>2019-12-14T18:19:28Z</date>
<date>2019-12-15T18:49:35Z</date>
<key>BackgroundUserBehaviorProcessor</key>
<date>0000-12-30T00:00:00Z</date>
<date>2019-12-15T18:49:56Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2019-12-14T18:19:28Z</date>
<date>2019-12-15T20:55:19Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-12-14T18:19:28Z</date>
<date>2019-12-15T18:49:35Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-12-10T06:45:58Z</date>
<date>2019-12-15T20:55:19Z</date>
<key>SiriPortraitDonation</key>
<date>0000-12-30T00:00:00Z</date>
<date>2019-12-15T18:49:56Z</date>
</dict>
</plist>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>FaceIDModelLastGenerationKey</key>
<date>2019-12-10T06:45:58Z</date>
<date>2019-12-15T18:49:56Z</date>
<key>LastContactClassificationKey</key>
<date>2019-12-10T06:46:00Z</date>
<date>2019-12-15T18:49:58Z</date>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>IncrementalPersonProcessingStage</key>
<integer>6</integer>
<integer>0</integer>
<key>PersonBuilderLastMinimumFaceGroupSizeForCreatingMergeCandidates</key>
<integer>15</integer>
<key>PersonBuilderMergeCandidatesEnabled</key>

View File

@@ -57,6 +57,7 @@ UUID_DICT = {
"export": "D79B8D77-BFFC-460B-9312-034F2877D35B", # "Pumkins2.jpg"
}
def test_export_1():
# test basic export
# get an unedited image and export it using default filename
@@ -390,3 +391,75 @@ def test_export_13():
with pytest.raises(Exception) as e:
assert photos[0].export(dest)
assert e.type == type(FileNotFoundError())
def test_dd_to_dms_str_1():
import osxphotos
lat_str, lon_str = osxphotos.dd_to_dms_str(
34.559331096, 69.206499174
) # Kabul, 34°33'33.59" N 69°12'23.40" E
assert lat_str == "34 deg 33' 33.59\" N"
assert lon_str == "69 deg 12' 23.40\" E"
def test_dd_to_dms_str_2():
import osxphotos
lat_str, lon_str = osxphotos.dd_to_dms_str(
-34.601997592, -58.375665164
) # Buenos Aires, 34°36'7.19" S 58°22'32.39" W
assert lat_str == "34 deg 36' 7.19\" S"
assert lon_str == "58 deg 22' 32.39\" W"
def test_dd_to_dms_str_3():
import osxphotos
lat_str, lon_str = osxphotos.dd_to_dms_str(
-1.2666656, 36.7999968
) # Nairobi, 1°15'60.00" S 36°47'59.99" E
assert lat_str == "1 deg 15' 60.00\" S"
assert lon_str == "36 deg 47' 59.99\" E"
def test_dd_to_dms_str_4():
import osxphotos
lat_str, lon_str = osxphotos.dd_to_dms_str(
38.889248, -77.050636
) # DC: 38° 53' 21.2928" N, 77° 3' 2.2896" W
assert lat_str == "38 deg 53' 21.29\" N"
assert lon_str == "77 deg 3' 2.29\" W"
def test_exiftool_json_sidecar():
import osxphotos
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
json_expected = json.loads(
"""
[{"FileName": "DC99FBDD-7A52-4100-A5BB-344131646C30.jpeg",
"Title": "St. James\'s Park",
"TagsList": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"Keywords": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"GPSLatitude": "51 deg 30\' 12.86\\" N",
"GPSLongitude": "0 deg 7\' 54.50\\" W",
"GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
"GPSLatitudeRef": "North", "GPSLongitudeRef": "West",
"DateTimeOriginal": "2018:10:13 09:18:12", "OffsetTimeOriginal": "-04:00"}]
"""
)
json_got = photos[0]._exiftool_json_sidecar()
json_got = json.loads(json_got)
assert sorted(json_got[0].items()) == sorted(json_expected[0].items())