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) - [`hidden()`](#hidden)
- [`location()`](#location) - [`location()`](#location)
- [`to_json()`](#to_json) - [`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) + [Examples](#examples)
* [History](#history) * [History](#history)
* [Contributing](#contributing) * [Contributing](#contributing)
@@ -490,10 +490,10 @@ Returns latitude and longitude as a tuple of floats (latitude, longitude). If l
#### `to_json()` #### `to_json()`
Returns a JSON representation of all photo info 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. Export photo from the Photos library to another destination on disk.
- First argument of *args must be valid destination path (or exception raised). - First argument dest must be valid destination path (or exception raised).
- Second argument of *args (optional): name of picture; if not provided, will use current filename - 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) - 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 - 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 - 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 os.path
import pathlib import pathlib
import platform import platform
import re
import sqlite3 import sqlite3
import subprocess import subprocess
import sys import sys
@@ -88,8 +89,8 @@ def _get_os_version():
def _check_file_exists(filename): def _check_file_exists(filename):
# returns true if file exists and is not a directory """ returns true if file exists and is not a directory
# otherwise returns false otherwise returns false """
filename = os.path.abspath(filename) filename = os.path.abspath(filename)
return os.path.exists(filename) and not os.path.isdir(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 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(): def get_system_library_path():
""" return the path to the system Photos library as string """ """ return the path to the system Photos library as string """
""" only works on MacOS 10.15+ """ """ only works on MacOS 10.15+ """
@@ -1464,7 +1513,7 @@ class PhotoInfo:
""" returns (latitude, longitude) as float in degrees or None """ """ returns (latitude, longitude) as float in degrees or None """
return (self._latitude(), self._longitude()) 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 """ """ export photo """
""" first argument must be valid destination path (or exception raised) """ """ first argument must be valid destination path (or exception raised) """
""" second argument (optional): name of picture; if not provided, will use current filename """ """ second argument (optional): name of picture; if not provided, will use current filename """
@@ -1478,44 +1527,37 @@ class PhotoInfo:
# maybe dest, *filename? # maybe dest, *filename?
# check arguments and get destination path and filename (if provided) # check arguments and get destination path and filename (if provided)
dest = None # destination path if filename and len(filename) > 2:
filename = None # photo filename raise TypeError(
if not args: "Too many positional arguments. Should be at most two: destination, filename."
# need at least one arg (destination) )
raise TypeError("Must pass destination as first argument")
else: else:
if len(args) > 2: # verify destination is a valid path
raise TypeError( if dest is None:
"Too many positional arguments. Should be at most two: destination, filename." raise ValueError("Destination must not be None")
) elif not os.path.isdir(dest):
else: raise FileNotFoundError("Invalid path passed to export")
# 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")
if len(args) == 2: if filename and len(filename) == 1:
# second arg is filename of picture # second arg is filename of picture
filename = args[1] filename = filename[0]
else: else:
# no filename provided so use the default # no filename provided so use the default
# if edited file requested, use filename but add _edited # if edited file requested, use filename but add _edited
# need to use file extension from edited file as Photos saves a jpeg once edited # need to use file extension from edited file as Photos saves a jpeg once edited
if edited: if edited:
# verify we have a valid path_edited and use that to get filename # verify we have a valid path_edited and use that to get filename
if not self.path_edited(): if not self.path_edited():
raise FileNotFoundError( raise FileNotFoundError(
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments()}" 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
) )
else: edited_name = Path(self.path_edited()).name
filename = self.filename() 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 # 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? # TODO: how to handle ismissing or not hasadjustments and edited=True cases?
@@ -1584,6 +1626,66 @@ class PhotoInfo:
return str(dest) 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): def _longitude(self):
""" Returns longitude, in degrees """ """ Returns longitude, in degrees """
return self._info["longitude"] return self._info["longitude"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,6 +57,7 @@ UUID_DICT = {
"export": "D79B8D77-BFFC-460B-9312-034F2877D35B", # "Pumkins2.jpg" "export": "D79B8D77-BFFC-460B-9312-034F2877D35B", # "Pumkins2.jpg"
} }
def test_export_1(): def test_export_1():
# test basic export # test basic export
# get an unedited image and export it using default filename # get an unedited image and export it using default filename
@@ -390,3 +391,75 @@ def test_export_13():
with pytest.raises(Exception) as e: with pytest.raises(Exception) as e:
assert photos[0].export(dest) assert photos[0].export(dest)
assert e.type == type(FileNotFoundError()) 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())