changed interface for export, prepped for exiftool_json_sidecar
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Binary file not shown.
@@ -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/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user