Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69cd236712 | ||
|
|
4cce9d4939 | ||
|
|
cfb07cbfaf | ||
|
|
1eff6bae9e | ||
|
|
435da2a5dd | ||
|
|
ed3a9711dc | ||
|
|
1bc0926948 |
13
CHANGELOG.md
@@ -4,6 +4,19 @@ 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.37.3](https://github.com/RhetTbull/osxphotos/compare/v0.37.2...v0.37.3)
|
||||||
|
|
||||||
|
> 30 November 2020
|
||||||
|
|
||||||
|
- Removed --use-photokit authorization check, issue 278 [`ed3a971`](https://github.com/RhetTbull/osxphotos/commit/ed3a9711dc0805aed1aacc30e01eeb9c1077d9e1)
|
||||||
|
|
||||||
|
#### [v0.37.2](https://github.com/RhetTbull/osxphotos/compare/v0.37.1...v0.37.2)
|
||||||
|
|
||||||
|
> 29 November 2020
|
||||||
|
|
||||||
|
- Catch errors in export_photo [`d9dcf09`](https://github.com/RhetTbull/osxphotos/commit/d9dcf0917a541725d1e472e7f918733e4e2613d0)
|
||||||
|
- Added --missing to export, see issue #277 [`25eacc7`](https://github.com/RhetTbull/osxphotos/commit/25eacc7caddd6721232b3f77a02532fcd35f7836)
|
||||||
|
|
||||||
#### [v0.37.1](https://github.com/RhetTbull/osxphotos/compare/v0.37.0...v0.37.1)
|
#### [v0.37.1](https://github.com/RhetTbull/osxphotos/compare/v0.37.0...v0.37.1)
|
||||||
|
|
||||||
> 28 November 2020
|
> 28 November 2020
|
||||||
|
|||||||
@@ -2206,8 +2206,6 @@ This package works by creating a copy of the sqlite3 database that photos uses t
|
|||||||
|
|
||||||
If apple changes the database format this will likely break.
|
If apple changes the database format this will likely break.
|
||||||
|
|
||||||
Apple does provide a framework ([PhotoKit](https://developer.apple.com/documentation/photokit?language=objc)) for querying the user's Photos library and I attempted to create the functionality in this package using this framework but unfortunately PhotoKit does not provide access to much of the needed metadata (such as Faces/Persons) and Apple's System Integrity Protection (SIP) made the interface unreliable. If you'd like to experiment with the PhotoKit interface, here's some sample [code](https://gist.github.com/RhetTbull/41cc85e5bdeb30f761147ce32fba5c94). While copying the sqlite file is a bit kludgy, it allows osxphotos to provide access to all available metadata.
|
|
||||||
|
|
||||||
For additional details about how osxphotos is implemented or if you would like to extend the code, see the [wiki](https://github.com/RhetTbull/osxphotos/wiki).
|
For additional details about how osxphotos is implemented or if you would like to extend the code, see the [wiki](https://github.com/RhetTbull/osxphotos/wiki).
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|||||||
@@ -59,13 +59,13 @@ def normalize_unicode(value):
|
|||||||
|
|
||||||
|
|
||||||
def get_photos_db(*db_options):
|
def get_photos_db(*db_options):
|
||||||
""" Return path to photos db, select first non-None db_options
|
"""Return path to photos db, select first non-None db_options
|
||||||
If no db_options are non-None, try to find library to use in
|
If no db_options are non-None, try to find library to use in
|
||||||
the following order:
|
the following order:
|
||||||
- last library opened
|
- last library opened
|
||||||
- system library
|
- system library
|
||||||
- ~/Pictures/Photos Library.photoslibrary
|
- ~/Pictures/Photos Library.photoslibrary
|
||||||
- failing above, returns None
|
- failing above, returns None
|
||||||
"""
|
"""
|
||||||
if db_options:
|
if db_options:
|
||||||
for db in db_options:
|
for db in db_options:
|
||||||
@@ -919,8 +919,8 @@ def list_libraries(ctx, cli_obj, json_):
|
|||||||
|
|
||||||
|
|
||||||
def _list_libraries(json_=False, error=True):
|
def _list_libraries(json_=False, error=True):
|
||||||
""" Print list of Photos libraries found on the system.
|
"""Print list of Photos libraries found on the system.
|
||||||
If json_ == True, print output as JSON (default = False) """
|
If json_ == True, print output as JSON (default = False)"""
|
||||||
|
|
||||||
photo_libs = osxphotos.utils.list_photo_libraries()
|
photo_libs = osxphotos.utils.list_photo_libraries()
|
||||||
sys_lib = osxphotos.utils.get_system_library_path()
|
sys_lib = osxphotos.utils.get_system_library_path()
|
||||||
@@ -1053,9 +1053,9 @@ def query(
|
|||||||
has_likes,
|
has_likes,
|
||||||
no_likes,
|
no_likes,
|
||||||
):
|
):
|
||||||
""" Query the Photos database using 1 or more search options;
|
"""Query the Photos database using 1 or more search options;
|
||||||
if more than one option is provided, they are treated as "AND"
|
if more than one option is provided, they are treated as "AND"
|
||||||
(e.g. search for photos matching all options).
|
(e.g. search for photos matching all options).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# if no query terms, show help and return
|
# if no query terms, show help and return
|
||||||
@@ -1267,6 +1267,79 @@ def query(
|
|||||||
"Note: this does not skip raw photos if the raw photo does not have an associated jpeg image "
|
"Note: this does not skip raw photos if the raw photo does not have an associated jpeg image "
|
||||||
"(e.g. the raw file was imported to Photos without a jpeg preview).",
|
"(e.g. the raw file was imported to Photos without a jpeg preview).",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--current-name",
|
||||||
|
is_flag=True,
|
||||||
|
help="Use photo's current filename instead of original filename for export. "
|
||||||
|
"Note: Starting with Photos 5, all photos are renamed upon import. By default, "
|
||||||
|
"photos are exported with the the original name they had before import.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--convert-to-jpeg",
|
||||||
|
is_flag=True,
|
||||||
|
help="Convert all non-jpeg images (e.g. raw, HEIC, PNG, etc) "
|
||||||
|
"to JPEG upon export. Only works if your Mac has a GPU.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--jpeg-quality",
|
||||||
|
type=click.FloatRange(0.0, 1.0),
|
||||||
|
default=1.0,
|
||||||
|
help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. "
|
||||||
|
"A value of 1.0 specifies best quality, "
|
||||||
|
"a value of 0.0 specifies maximum compression. "
|
||||||
|
"Defaults to 1.0.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--download-missing",
|
||||||
|
is_flag=True,
|
||||||
|
help="Attempt to download missing photos from iCloud. The current implementation uses Applescript "
|
||||||
|
"to interact with Photos to export the photo which will force Photos to download from iCloud if "
|
||||||
|
"the photo does not exist on disk. This will be slow and will require internet connection. "
|
||||||
|
"This obviously only works if the Photos library is synched to iCloud. "
|
||||||
|
"Note: --download-missing does not currently export all burst images; "
|
||||||
|
"only the primary photo will be exported--associated burst images will be skipped.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--sidecar",
|
||||||
|
default=None,
|
||||||
|
multiple=True,
|
||||||
|
metavar="FORMAT",
|
||||||
|
type=click.Choice(["xmp", "json"], case_sensitive=False),
|
||||||
|
help="Create sidecar for each photo exported; valid FORMAT values: xmp, json; "
|
||||||
|
f"--sidecar json: create JSON sidecar useable by exiftool ({_EXIF_TOOL_URL}) "
|
||||||
|
"The sidecar file can be used to apply metadata to the file with exiftool, for example: "
|
||||||
|
'"exiftool -j=photoname.jpg.json photoname.jpg" '
|
||||||
|
"The sidecar file is named in format photoname.ext.json "
|
||||||
|
"--sidecar xmp: create XMP sidecar used by Adobe Lightroom, etc."
|
||||||
|
"The sidecar file is named in format photoname.ext.xmp"
|
||||||
|
"The XMP sidecar exports the following tags: Description, Title, Keywords/Tags, "
|
||||||
|
"Subject (set to Keywords + PersonInImage), PersonInImage, CreateDate, ModifyDate, "
|
||||||
|
"GPSLongitude. "
|
||||||
|
"For a list of tags exported in the JSON sidecar, see --exiftool.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--exiftool",
|
||||||
|
is_flag=True,
|
||||||
|
help="Use exiftool to write metadata directly to exported photos. "
|
||||||
|
"To use this option, exiftool must be installed and in the path. "
|
||||||
|
"exiftool may be installed from https://exiftool.org/. "
|
||||||
|
"Cannot be used with --export-as-hardlink. Writes the following metadata: "
|
||||||
|
"EXIF:ImageDescription, XMP:Description (see also --description-template); "
|
||||||
|
"XMP:Title; XMP:TagsList, IPTC:Keywords (see also --keyword-template, --person-keyword, --album-keyword); "
|
||||||
|
"XMP:Subject (set to keywords + person in image to mirror Photos' behavior); "
|
||||||
|
"XMP:PersonInImage; EXIF:GPSLatitudeRef; EXIF:GPSLongitudeRef; EXIF:GPSLatitude; EXIF:GPSLongitude; "
|
||||||
|
"EXIF:GPSPosition; EXIF:DateTimeOriginal; EXIF:OffsetTimeOriginal; "
|
||||||
|
"EXIF:ModifyDate (see --ignore-date-modified); IPTC:DateCreated; IPTC:TimeCreated; "
|
||||||
|
"(video files only): QuickTime:CreationDate (UTC); QuickTime:ModifyDate (UTC) (see also --ignore-date-modified); "
|
||||||
|
"QuickTime:GPSCoordinates; UserData:GPSCoordinates.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ignore-date-modified",
|
||||||
|
is_flag=True,
|
||||||
|
help="If used with --exiftool or --sidecar, will ignore the photo "
|
||||||
|
"modification date and set EXIF:ModifyDate to EXIF:DateTimeOriginal; "
|
||||||
|
"this is consistent with how Photos handles the EXIF:ModifyDate tag.",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--person-keyword",
|
"--person-keyword",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
@@ -1303,67 +1376,6 @@ def query(
|
|||||||
'--description-template "{descr} exported with osxphotos on {today.date}" '
|
'--description-template "{descr} exported with osxphotos on {today.date}" '
|
||||||
"See Templating System below.",
|
"See Templating System below.",
|
||||||
)
|
)
|
||||||
@click.option(
|
|
||||||
"--current-name",
|
|
||||||
is_flag=True,
|
|
||||||
help="Use photo's current filename instead of original filename for export. "
|
|
||||||
"Note: Starting with Photos 5, all photos are renamed upon import. By default, "
|
|
||||||
"photos are exported with the the original name they had before import.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--convert-to-jpeg",
|
|
||||||
is_flag=True,
|
|
||||||
help="Convert all non-jpeg images (e.g. raw, HEIC, PNG, etc) "
|
|
||||||
"to JPEG upon export. Only works if your Mac has a GPU.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--jpeg-quality",
|
|
||||||
type=click.FloatRange(0.0, 1.0),
|
|
||||||
default=1.0,
|
|
||||||
help="Value in range 0.0 to 1.0 to use with --convert-to-jpeg. "
|
|
||||||
"A value of 1.0 specifies best quality, "
|
|
||||||
"a value of 0.0 specifies maximum compression. "
|
|
||||||
"Defaults to 1.0.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--sidecar",
|
|
||||||
default=None,
|
|
||||||
multiple=True,
|
|
||||||
metavar="FORMAT",
|
|
||||||
type=click.Choice(["xmp", "json"], case_sensitive=False),
|
|
||||||
help="Create sidecar for each photo exported; valid FORMAT values: xmp, json; "
|
|
||||||
f"--sidecar json: create JSON sidecar useable by exiftool ({_EXIF_TOOL_URL}) "
|
|
||||||
"The sidecar file can be used to apply metadata to the file with exiftool, for example: "
|
|
||||||
'"exiftool -j=photoname.json photoname.jpg" '
|
|
||||||
"The sidecar file is named in format photoname.json "
|
|
||||||
"--sidecar xmp: create XMP sidecar used by Adobe Lightroom, etc."
|
|
||||||
"The sidecar file is named in format photoname.xmp",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--download-missing",
|
|
||||||
is_flag=True,
|
|
||||||
help="Attempt to download missing photos from iCloud. The current implementation uses Applescript "
|
|
||||||
"to interact with Photos to export the photo which will force Photos to download from iCloud if "
|
|
||||||
"the photo does not exist on disk. This will be slow and will require internet connection. "
|
|
||||||
"This obviously only works if the Photos library is synched to iCloud. "
|
|
||||||
"Note: --download-missing does not currently export all burst images; "
|
|
||||||
"only the primary photo will be exported--associated burst images will be skipped.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--exiftool",
|
|
||||||
is_flag=True,
|
|
||||||
help="Use exiftool to write metadata directly to exported photos. "
|
|
||||||
"To use this option, exiftool must be installed and in the path. "
|
|
||||||
"exiftool may be installed from https://exiftool.org/. "
|
|
||||||
"Cannot be used with --export-as-hardlink.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--ignore-date-modified",
|
|
||||||
is_flag=True,
|
|
||||||
help="If used with --exiftool or --sidecar, will ignore the photo "
|
|
||||||
"modification date and set EXIF:ModifyDate to EXIF:DateTimeOriginal; "
|
|
||||||
"this is consistent with how Photos handles the EXIF:ModifyDate tag.",
|
|
||||||
)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--directory",
|
"--directory",
|
||||||
metavar="DIRECTORY",
|
metavar="DIRECTORY",
|
||||||
@@ -1519,16 +1531,16 @@ def export(
|
|||||||
use_photokit,
|
use_photokit,
|
||||||
report,
|
report,
|
||||||
):
|
):
|
||||||
""" Export photos from the Photos database.
|
"""Export photos from the Photos database.
|
||||||
Export path DEST is required.
|
Export path DEST is required.
|
||||||
Optionally, query the Photos database using 1 or more search options;
|
Optionally, query the Photos database using 1 or more search options;
|
||||||
if more than one option is provided, they are treated as "AND"
|
if more than one option is provided, they are treated as "AND"
|
||||||
(e.g. search for photos matching all options).
|
(e.g. search for photos matching all options).
|
||||||
If no query options are provided, all photos will be exported.
|
If no query options are provided, all photos will be exported.
|
||||||
By default, all versions of all photos will be exported including edited
|
By default, all versions of all photos will be exported including edited
|
||||||
versions, live photo movies, burst photos, and associated raw images.
|
versions, live photo movies, burst photos, and associated raw images.
|
||||||
See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options
|
See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options
|
||||||
to modify this behavior.
|
to modify this behavior.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
global VERBOSE
|
global VERBOSE
|
||||||
@@ -1587,17 +1599,17 @@ def export(
|
|||||||
)
|
)
|
||||||
raise click.Abort()
|
raise click.Abort()
|
||||||
|
|
||||||
if use_photokit and not check_photokit_authorization():
|
# if use_photokit and not check_photokit_authorization():
|
||||||
click.echo(
|
# click.echo(
|
||||||
"Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access."
|
# "Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access."
|
||||||
)
|
# )
|
||||||
request_photokit_authorization()
|
# request_photokit_authorization()
|
||||||
click.confirm("Have you granted access?")
|
# click.confirm("Have you granted access?")
|
||||||
if not check_photokit_authorization():
|
# if not check_photokit_authorization():
|
||||||
click.echo(
|
# click.echo(
|
||||||
"Failed to get access to the Photos library which is needed with `--use-photokit`."
|
# "Failed to get access to the Photos library which is needed with `--use-photokit`."
|
||||||
)
|
# )
|
||||||
return
|
# 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
|
||||||
@@ -2107,10 +2119,10 @@ def _query(
|
|||||||
has_likes=False,
|
has_likes=False,
|
||||||
no_likes=False,
|
no_likes=False,
|
||||||
):
|
):
|
||||||
""" run a query against PhotosDB to extract the photos based on user supply criteria
|
"""run a query against PhotosDB to extract the photos based on user supply criteria
|
||||||
used by query and export commands
|
used by query and export commands
|
||||||
arguments must be passed in same order as query and export
|
arguments must be passed in same order as query and export
|
||||||
if either is modified, need to ensure all three functions are updated """
|
if either is modified, need to ensure all three functions are updated"""
|
||||||
|
|
||||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose)
|
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose)
|
||||||
if deleted or deleted_only:
|
if deleted or deleted_only:
|
||||||
@@ -2401,7 +2413,7 @@ def export_photo(
|
|||||||
ignore_date_modified=False,
|
ignore_date_modified=False,
|
||||||
use_photokit=False,
|
use_photokit=False,
|
||||||
):
|
):
|
||||||
""" Helper function for export that does the actual export
|
"""Helper function for export that does the actual export
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
photo: PhotoInfo object
|
photo: PhotoInfo object
|
||||||
@@ -2708,7 +2720,7 @@ def export_photo(
|
|||||||
|
|
||||||
|
|
||||||
def get_filenames_from_template(photo, filename_template, original_name):
|
def get_filenames_from_template(photo, filename_template, original_name):
|
||||||
""" get list of export filenames for a photo
|
"""get list of export filenames for a photo
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
photo: a PhotoInfo instance
|
photo: a PhotoInfo instance
|
||||||
@@ -2744,7 +2756,7 @@ def get_filenames_from_template(photo, filename_template, original_name):
|
|||||||
|
|
||||||
|
|
||||||
def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run):
|
def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run):
|
||||||
""" get list of directories to export a photo into, creates directories if they don't exist
|
"""get list of directories to export a photo into, creates directories if they don't exist
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
photo: a PhotoInstance object
|
photo: a PhotoInstance object
|
||||||
@@ -2792,7 +2804,7 @@ def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run):
|
|||||||
|
|
||||||
|
|
||||||
def find_files_in_branch(pathname, filename):
|
def find_files_in_branch(pathname, filename):
|
||||||
""" Search a directory branch to find file(s) named filename
|
"""Search a directory branch to find file(s) named filename
|
||||||
The branch searched includes all folders below pathname and
|
The branch searched includes all folders below pathname and
|
||||||
the parent tree of pathname but not pathname itself.
|
the parent tree of pathname but not pathname itself.
|
||||||
|
|
||||||
@@ -2829,7 +2841,7 @@ def find_files_in_branch(pathname, filename):
|
|||||||
|
|
||||||
|
|
||||||
def load_uuid_from_file(filename):
|
def load_uuid_from_file(filename):
|
||||||
""" Load UUIDs from file. Does not validate UUIDs.
|
"""Load UUIDs from file. Does not validate UUIDs.
|
||||||
Format is 1 UUID per line, any line beginning with # is ignored.
|
Format is 1 UUID per line, any line beginning with # is ignored.
|
||||||
Whitespace is stripped.
|
Whitespace is stripped.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
""" version info """
|
""" version info """
|
||||||
|
|
||||||
__version__ = "0.37.2"
|
__version__ = "0.37.4"
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
""" datetime utilities """
|
""" datetime.datetime helper functions for converting to/from UTC """
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
|
||||||
def get_local_tz(dt):
|
def get_local_tz(dt):
|
||||||
""" return local timezone as datetime.timezone tzinfo for dt
|
""" Return local timezone as datetime.timezone tzinfo for dt
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dt: datetime.datetime
|
dt: datetime.datetime
|
||||||
@@ -21,21 +21,18 @@ def get_local_tz(dt):
|
|||||||
raise ValueError("dt must be naive datetime.datetime object")
|
raise ValueError("dt must be naive datetime.datetime object")
|
||||||
|
|
||||||
|
|
||||||
def datetime_remove_tz(dt):
|
|
||||||
""" remove timezone from a datetime.datetime object
|
|
||||||
dt: datetime.datetime object with tzinfo
|
|
||||||
returns: dt without any timezone info (naive datetime object) """
|
|
||||||
|
|
||||||
if type(dt) != datetime.datetime:
|
|
||||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
|
||||||
|
|
||||||
return dt.replace(tzinfo=None)
|
|
||||||
|
|
||||||
|
|
||||||
def datetime_has_tz(dt):
|
def datetime_has_tz(dt):
|
||||||
""" return True if datetime dt has tzinfo else False
|
""" Return True if datetime dt has tzinfo else False
|
||||||
|
|
||||||
|
Args:
|
||||||
dt: datetime.datetime
|
dt: datetime.datetime
|
||||||
returns True if dt is timezone aware, else False """
|
|
||||||
|
Returns:
|
||||||
|
True if dt is timezone aware, else False
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not a datetime.datetime object
|
||||||
|
"""
|
||||||
|
|
||||||
if type(dt) != datetime.datetime:
|
if type(dt) != datetime.datetime:
|
||||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
@@ -43,11 +40,90 @@ def datetime_has_tz(dt):
|
|||||||
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
|
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
|
||||||
|
|
||||||
|
|
||||||
def datetime_naive_to_local(dt):
|
def datetime_tz_to_utc(dt):
|
||||||
""" convert naive (timezone unaware) datetime.datetime
|
""" Convert datetime.datetime object with timezone to UTC timezone
|
||||||
to aware timezone in local timezone
|
|
||||||
|
Args:
|
||||||
|
dt: datetime.datetime object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime.datetime in UTC timezone
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not datetime.datetime object
|
||||||
|
ValueError if dt does not have timeone information
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(dt) != datetime.datetime:
|
||||||
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
|
|
||||||
|
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
|
||||||
|
return dt.replace(tzinfo=dt.tzinfo).astimezone(tz=datetime.timezone.utc)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"dt does not have timezone info")
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_remove_tz(dt):
|
||||||
|
""" Remove timezone from a datetime.datetime object
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: datetime.datetime object with tzinfo
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dt without any timezone info (naive datetime object)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not a datetime.datetime object
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(dt) != datetime.datetime:
|
||||||
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
|
|
||||||
|
return dt.replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_naive_to_utc(dt):
|
||||||
|
""" Convert naive (timezone unaware) datetime.datetime
|
||||||
|
to aware timezone in UTC timezone
|
||||||
|
|
||||||
|
Args:
|
||||||
dt: datetime.datetime without timezone
|
dt: datetime.datetime without timezone
|
||||||
returns: datetime.datetime with local timezone """
|
|
||||||
|
Returns:
|
||||||
|
datetime.datetime with UTC timezone
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not a datetime.datetime object
|
||||||
|
ValueError if dt is not a naive/timezone unaware object
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(dt) != datetime.datetime:
|
||||||
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
|
|
||||||
|
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
|
||||||
|
# has timezone info
|
||||||
|
raise ValueError(
|
||||||
|
"dt must be naive/timezone unaware: "
|
||||||
|
f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tzinfo.utcoffset(dt)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return dt.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_naive_to_local(dt):
|
||||||
|
""" Convert naive (timezone unaware) datetime.datetime
|
||||||
|
to aware timezone in local timezone
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: datetime.datetime without timezone
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime.datetime with local timezone
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not a datetime.datetime object
|
||||||
|
ValueError if dt is not a naive/timezone unaware object
|
||||||
|
"""
|
||||||
|
|
||||||
if type(dt) != datetime.datetime:
|
if type(dt) != datetime.datetime:
|
||||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
@@ -60,3 +136,26 @@ def datetime_naive_to_local(dt):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return dt.replace(tzinfo=get_local_tz(dt))
|
return dt.replace(tzinfo=get_local_tz(dt))
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_utc_to_local(dt):
|
||||||
|
""" Convert datetime.datetime object in UTC timezone to local timezone
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: datetime.datetime object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime.datetime in local timezone
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError if dt is not a datetime.datetime object
|
||||||
|
ValueError if dt is not in UTC timezone
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(dt) != datetime.datetime:
|
||||||
|
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||||
|
|
||||||
|
if dt.tzinfo is not datetime.timezone.utc:
|
||||||
|
raise ValueError(f"{dt} must be in UTC timezone: timezone = {dt.tzinfo}")
|
||||||
|
|
||||||
|
return dt.replace(tzinfo=datetime.timezone.utc).astimezone(tz=None)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from .._constants import (
|
|||||||
_UNKNOWN_PERSON,
|
_UNKNOWN_PERSON,
|
||||||
_XMP_TEMPLATE_NAME,
|
_XMP_TEMPLATE_NAME,
|
||||||
)
|
)
|
||||||
|
from ..datetime_utils import datetime_tz_to_utc
|
||||||
from ..exiftool import ExifTool
|
from ..exiftool import ExifTool
|
||||||
from ..export_db import ExportDBNoOp
|
from ..export_db import ExportDBNoOp
|
||||||
from ..fileutil import FileUtil
|
from ..fileutil import FileUtil
|
||||||
@@ -82,27 +83,27 @@ def _export_photo_uuid_applescript(
|
|||||||
burst=False,
|
burst=False,
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
):
|
):
|
||||||
""" Export photo to dest path using applescript to control Photos
|
"""Export photo to dest path using applescript to control Photos
|
||||||
If photo is a live photo, exports both the photo and associated .mov file
|
If photo is a live photo, exports both the photo and associated .mov file
|
||||||
uuid: UUID of photo to export
|
uuid: UUID of photo to export
|
||||||
dest: destination path to export to
|
dest: destination path to export to
|
||||||
filestem: (string) if provided, exported filename will be named stem.ext
|
filestem: (string) if provided, exported filename will be named stem.ext
|
||||||
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
|
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
|
||||||
If not provided, file will be named with whatever name Photos uses
|
If not provided, file will be named with whatever name Photos uses
|
||||||
If filestem.ext exists, it wil be overwritten
|
If filestem.ext exists, it wil be overwritten
|
||||||
original: (boolean) if True, export original image; default = True
|
original: (boolean) if True, export original image; default = True
|
||||||
edited: (boolean) if True, export edited photo; default = False
|
edited: (boolean) if True, export edited photo; default = False
|
||||||
If photo not edited and edited=True, will still export the original image
|
If photo not edited and edited=True, will still export the original image
|
||||||
caller must verify image has been edited
|
caller must verify image has been edited
|
||||||
*Note*: must be called with either edited or original but not both,
|
*Note*: must be called with either edited or original but not both,
|
||||||
will raise error if called with both edited and original = True
|
will raise error if called with both edited and original = True
|
||||||
live_photo: (boolean) if True, export associated .mov live photo; default = False
|
live_photo: (boolean) if True, export associated .mov live photo; default = False
|
||||||
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
||||||
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
||||||
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
|
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
|
||||||
Returns: list of paths to exported file(s) or None if export failed
|
Returns: list of paths to exported file(s) or None if export failed
|
||||||
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
|
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
|
||||||
has not been edited. This is due to how Photos Applescript interface works.
|
has not been edited. This is due to how Photos Applescript interface works.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# setup the applescript to do the export
|
# setup the applescript to do the export
|
||||||
@@ -249,43 +250,43 @@ def export(
|
|||||||
keyword_template=None,
|
keyword_template=None,
|
||||||
description_template=None,
|
description_template=None,
|
||||||
):
|
):
|
||||||
""" export photo
|
"""export photo
|
||||||
dest: must be valid destination path (or exception raised)
|
dest: must be valid destination path (or exception raised)
|
||||||
filename: (optional): name of exported picture; if not provided, will use current filename
|
filename: (optional): name of exported picture; if not provided, will use current filename
|
||||||
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
|
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
|
||||||
For example, if photo is .CR2 file, edited image may be .jpeg.
|
For example, if photo is .CR2 file, edited image may be .jpeg.
|
||||||
If you provide an extension different than what the actual file is,
|
If you provide an extension different than what the actual file is,
|
||||||
export will print a warning but will export the photo using the
|
export will print a warning but will export the photo using the
|
||||||
incorrect file extension (unless use_photos_export is true, in which case export will
|
incorrect file extension (unless use_photos_export is true, in which case export will
|
||||||
use the extension provided by Photos upon export; in this case, an incorrect extension is
|
use the extension provided by Photos upon export; in this case, an incorrect extension is
|
||||||
silently ignored).
|
silently ignored).
|
||||||
e.g. to get the extension of the edited photo,
|
e.g. to get the extension of the edited photo,
|
||||||
reference PhotoInfo.path_edited
|
reference PhotoInfo.path_edited
|
||||||
edited: (boolean, default=False); if True will export the edited version of the photo
|
edited: (boolean, default=False); if True will export the edited version of the photo
|
||||||
(or raise exception if no edited version)
|
(or raise exception if no edited version)
|
||||||
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
|
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
|
||||||
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
|
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
|
||||||
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
|
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
|
||||||
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
|
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
|
||||||
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
|
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
|
||||||
if overwrite=False and increment=False, export will fail if destination file already exists
|
if overwrite=False and increment=False, export will fail if destination file already exists
|
||||||
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
|
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
|
||||||
sidecar filename will be dest/filename.json
|
sidecar filename will be dest/filename.json
|
||||||
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
|
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
|
||||||
sidecar filename will be dest/filename.xmp
|
sidecar filename will be dest/filename.xmp
|
||||||
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
|
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
|
||||||
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||||
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
||||||
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
|
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
|
||||||
returns list of full paths to the exported files
|
returns list of full paths to the exported files
|
||||||
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
|
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
|
||||||
when exporting metadata with exiftool or sidecar
|
when exporting metadata with exiftool or sidecar
|
||||||
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
|
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
|
||||||
when exporting metadata with exiftool or sidecar
|
when exporting metadata with exiftool or sidecar
|
||||||
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
|
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
|
||||||
description_template: string; optional template string that will be rendered for use as photo description
|
description_template: string; optional template string that will be rendered for use as photo description
|
||||||
returns: list of photos exported
|
returns: list of photos exported
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Implementation note: calls export2 to actually do the work
|
# Implementation note: calls export2 to actually do the work
|
||||||
|
|
||||||
@@ -344,56 +345,56 @@ def export2(
|
|||||||
use_photokit=False,
|
use_photokit=False,
|
||||||
verbose=None,
|
verbose=None,
|
||||||
):
|
):
|
||||||
""" 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
|
||||||
filename: (optional): name of exported picture; if not provided, will use current filename
|
filename: (optional): name of exported picture; if not provided, will use current filename
|
||||||
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
|
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
|
||||||
For example, if photo is .CR2 file, edited image may be .jpeg.
|
For example, if photo is .CR2 file, edited image may be .jpeg.
|
||||||
If you provide an extension different than what the actual file is,
|
If you provide an extension different than what the actual file is,
|
||||||
will export the photo using the incorrect file extension (unless use_photos_export is true,
|
will export the photo using the incorrect file extension (unless use_photos_export is true,
|
||||||
in which case export will use the extension provided by Photos upon export.
|
in which case export will use the extension provided by Photos upon export.
|
||||||
e.g. to get the extension of the edited photo,
|
e.g. to get the extension of the edited photo,
|
||||||
reference PhotoInfo.path_edited
|
reference PhotoInfo.path_edited
|
||||||
edited: (boolean, default=False); if True will export the edited version of the photo
|
edited: (boolean, default=False); if True will export the edited version of the photo
|
||||||
(or raise exception if no edited version)
|
(or raise exception if no edited version)
|
||||||
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
|
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
|
||||||
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
|
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
|
||||||
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
|
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
|
||||||
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
|
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
|
||||||
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
|
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
|
||||||
if overwrite=False and increment=False, export will fail if destination file already exists
|
if overwrite=False and increment=False, export will fail if destination file already exists
|
||||||
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
|
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
|
||||||
sidecar filename will be dest/filename.json
|
sidecar filename will be dest/filename.json
|
||||||
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
|
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
|
||||||
sidecar filename will be dest/filename.xmp
|
sidecar filename will be dest/filename.xmp
|
||||||
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
|
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
|
||||||
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||||
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
||||||
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
|
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
|
||||||
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
|
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
|
||||||
when exporting metadata with exiftool or sidecar
|
when exporting metadata with exiftool or sidecar
|
||||||
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
|
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
|
||||||
when exporting metadata with exiftool or sidecar
|
when exporting metadata with exiftool or sidecar
|
||||||
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
|
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
|
||||||
description_template: string; optional template string that will be rendered for use as photo description
|
description_template: string; optional template string that will be rendered for use as photo description
|
||||||
update: (boolean, default=False); if True export will run in update mode, that is, it will
|
update: (boolean, default=False); if True export will run in update mode, that is, it will
|
||||||
not export the photo if the current version already exists in the destination
|
not export the photo if the current version already exists in the destination
|
||||||
export_db: (ExportDB_ABC); instance of a class that conforms to ExportDB_ABC with methods
|
export_db: (ExportDB_ABC); instance of a class that conforms to ExportDB_ABC with methods
|
||||||
for getting/setting data related to exported files to compare update state
|
for getting/setting data related to exported files to compare update state
|
||||||
fileutil: (FileUtilABC); class that conforms to FileUtilABC with various file utilities
|
fileutil: (FileUtilABC); class that conforms to FileUtilABC with various file utilities
|
||||||
dry_run: (boolean, default=False); set to True to run in "dry run" mode
|
dry_run: (boolean, default=False); set to True to run in "dry run" mode
|
||||||
touch_file: (boolean, default=False); if True, sets file's modification time upon photo date
|
touch_file: (boolean, default=False); if True, sets file's modification time upon photo date
|
||||||
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
|
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
|
||||||
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
|
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
|
||||||
ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
|
ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
|
||||||
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
||||||
|
|
||||||
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
|
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
|
||||||
where each field is a list of file paths
|
where each field is a list of file paths
|
||||||
|
|
||||||
Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db,
|
Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db,
|
||||||
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
|
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# NOTE: This function is very complex and does a lot of things.
|
# NOTE: This function is very complex and does a lot of things.
|
||||||
# Don't modify this code if you don't fully understand everything it does.
|
# Don't modify this code if you don't fully understand everything it does.
|
||||||
@@ -956,7 +957,7 @@ def _export_photo(
|
|||||||
edited=False,
|
edited=False,
|
||||||
jpeg_quality=1.0,
|
jpeg_quality=1.0,
|
||||||
):
|
):
|
||||||
""" Helper function for export()
|
"""Helper function for export()
|
||||||
Does the actual copy or hardlink taking the appropriate
|
Does the actual copy or hardlink taking the appropriate
|
||||||
action depending on update, overwrite, export_as_hardlink
|
action depending on update, overwrite, export_as_hardlink
|
||||||
Assumes destination is the right destination (e.g. UUID matches)
|
Assumes destination is the right destination (e.g. UUID matches)
|
||||||
@@ -1125,7 +1126,7 @@ def _write_exif_data(
|
|||||||
description_template=None,
|
description_template=None,
|
||||||
ignore_date_modified=False,
|
ignore_date_modified=False,
|
||||||
):
|
):
|
||||||
""" write exif data to image file at filepath
|
"""write exif data to image file at filepath
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filepath: full path to the image file
|
filepath: full path to the image file
|
||||||
@@ -1146,9 +1147,7 @@ def _write_exif_data(
|
|||||||
|
|
||||||
with ExifTool(filepath) as exiftool:
|
with ExifTool(filepath) as exiftool:
|
||||||
for exiftag, val in exif_info.items():
|
for exiftag, val in exif_info.items():
|
||||||
if exiftag == "_CreatedBy":
|
if type(val) == list:
|
||||||
continue
|
|
||||||
elif type(val) == list:
|
|
||||||
for v in val:
|
for v in val:
|
||||||
exiftool.setvalue(exiftag, v)
|
exiftool.setvalue(exiftag, v)
|
||||||
else:
|
else:
|
||||||
@@ -1163,7 +1162,7 @@ def _exiftool_dict(
|
|||||||
description_template=None,
|
description_template=None,
|
||||||
ignore_date_modified=False,
|
ignore_date_modified=False,
|
||||||
):
|
):
|
||||||
""" Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
||||||
Does not include all the EXIF fields as those are likely already in the image.
|
Does not include all the EXIF fields as those are likely already in the image.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1176,12 +1175,12 @@ def _exiftool_dict(
|
|||||||
Returns: dict with exiftool tags / values
|
Returns: dict with exiftool tags / values
|
||||||
|
|
||||||
Exports the following:
|
Exports the following:
|
||||||
EXIF:ImageDescription
|
EXIF:ImageDescription (may include template)
|
||||||
XMP:Description (may include template)
|
XMP:Description (may include template)
|
||||||
XMP:Title
|
XMP:Title
|
||||||
XMP:TagsList
|
XMP:TagsList (may include album name, person name, or template)
|
||||||
IPTC:Keywords (may include album name, person name, or template)
|
IPTC:Keywords (may include album name, person name, or template)
|
||||||
XMP:Subject
|
XMP:Subject (set to keywords + persons)
|
||||||
XMP:PersonInImage
|
XMP:PersonInImage
|
||||||
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
|
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
|
||||||
EXIF:GPSLatitude, EXIF:GPSLongitude
|
EXIF:GPSLatitude, EXIF:GPSLongitude
|
||||||
@@ -1191,10 +1190,13 @@ def _exiftool_dict(
|
|||||||
EXIF:ModifyDate
|
EXIF:ModifyDate
|
||||||
IPTC:DateCreated
|
IPTC:DateCreated
|
||||||
IPTC:TimeCreated
|
IPTC:TimeCreated
|
||||||
|
QuickTime:CreationDate (UTC)
|
||||||
|
QuickTime:ModifyDate (UTC)
|
||||||
|
QuickTime:GPSCoordinates
|
||||||
|
UserData:GPSCoordinates
|
||||||
"""
|
"""
|
||||||
|
|
||||||
exif = {}
|
exif = {}
|
||||||
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
|
|
||||||
if description_template is not None:
|
if description_template is not None:
|
||||||
description = self.render_template(
|
description = self.render_template(
|
||||||
description_template, expand_inplace=True, inplace_sep=", "
|
description_template, expand_inplace=True, inplace_sep=", "
|
||||||
@@ -1272,12 +1274,16 @@ def _exiftool_dict(
|
|||||||
|
|
||||||
(lat, lon) = self.location
|
(lat, lon) = self.location
|
||||||
if lat is not None and lon is not None:
|
if lat is not None and lon is not None:
|
||||||
exif["EXIF:GPSLatitude"] = lat
|
if self.isphoto:
|
||||||
exif["EXIF:GPSLongitude"] = lon
|
exif["EXIF:GPSLatitude"] = lat
|
||||||
lat_ref = "N" if lat >= 0 else "S"
|
exif["EXIF:GPSLongitude"] = lon
|
||||||
lon_ref = "E" if lon >= 0 else "W"
|
lat_ref = "N" if lat >= 0 else "S"
|
||||||
exif["EXIF:GPSLatitudeRef"] = lat_ref
|
lon_ref = "E" if lon >= 0 else "W"
|
||||||
exif["EXIF:GPSLongitudeRef"] = lon_ref
|
exif["EXIF:GPSLatitudeRef"] = lat_ref
|
||||||
|
exif["EXIF:GPSLongitudeRef"] = lon_ref
|
||||||
|
elif self.ismovie:
|
||||||
|
exif["Keys:GPSCoordinates"] = f"{lat} {lon}"
|
||||||
|
exif["UserData:GPSCoordinates"] = f"{lat} {lon}"
|
||||||
|
|
||||||
# process date/time and timezone offset
|
# process date/time and timezone offset
|
||||||
# Photos exports the following fields and sets modify date to creation date
|
# Photos exports the following fields and sets modify date to creation date
|
||||||
@@ -1289,30 +1295,45 @@ def _exiftool_dict(
|
|||||||
#
|
#
|
||||||
# This code deviates from Photos in one regard:
|
# This code deviates from Photos in one regard:
|
||||||
# if photo has modification date, use it otherwise use creation date
|
# if photo has modification date, use it otherwise use creation date
|
||||||
date = self.date
|
|
||||||
|
|
||||||
# exiftool expects format to "2015:01:18 12:00:00"
|
if self.isphoto:
|
||||||
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
|
date = self.date
|
||||||
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
|
# exiftool expects format to "2015:01:18 12:00:00"
|
||||||
exif["EXIF:CreateDate"] = datetimeoriginal
|
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
|
||||||
|
|
||||||
offsettime = date.strftime("%z")
|
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
|
||||||
# find timezone offset in format "-04:00"
|
exif["EXIF:CreateDate"] = datetimeoriginal
|
||||||
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["EXIF:OffsetTimeOriginal"] = offsettime
|
|
||||||
|
|
||||||
dateoriginal = date.strftime("%Y:%m:%d")
|
offsettime = date.strftime("%z")
|
||||||
exif["IPTC:DateCreated"] = dateoriginal
|
# 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["EXIF:OffsetTimeOriginal"] = offsettime
|
||||||
|
|
||||||
timeoriginal = date.strftime(f"%H:%M:%S{offsettime}")
|
dateoriginal = date.strftime("%Y:%m:%d")
|
||||||
exif["IPTC:TimeCreated"] = timeoriginal
|
exif["IPTC:DateCreated"] = dateoriginal
|
||||||
|
|
||||||
if self.date_modified is not None and not ignore_date_modified:
|
timeoriginal = date.strftime(f"%H:%M:%S{offsettime}")
|
||||||
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
|
exif["IPTC:TimeCreated"] = timeoriginal
|
||||||
else:
|
|
||||||
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
|
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")
|
||||||
|
else:
|
||||||
|
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
|
||||||
|
elif self.ismovie:
|
||||||
|
# QuickTime spec specifies times in UTC
|
||||||
|
# reference: https://exiftool.org/TagNames/QuickTime.html#Keys
|
||||||
|
date_utc = datetime_tz_to_utc(self.date)
|
||||||
|
creationdate = date_utc.strftime("%Y:%m:%d %H:%M:%S")
|
||||||
|
exif["QuickTime:CreationDate"] = creationdate
|
||||||
|
exif["QuickTime:CreateDate"] = creationdate
|
||||||
|
if self.date_modified is not None and not ignore_date_modified:
|
||||||
|
exif["QuickTime:ModifyDate"] = datetime_tz_to_utc(
|
||||||
|
self.date_modified
|
||||||
|
).strftime("%Y:%m:%d %H:%M:%S")
|
||||||
|
else:
|
||||||
|
exif["QuickTime:ModifyDate"] = creationdate
|
||||||
|
|
||||||
return exif
|
return exif
|
||||||
|
|
||||||
@@ -1325,7 +1346,7 @@ def _exiftool_json_sidecar(
|
|||||||
description_template=None,
|
description_template=None,
|
||||||
ignore_date_modified=False,
|
ignore_date_modified=False,
|
||||||
):
|
):
|
||||||
""" Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
||||||
Does not include all the EXIF fields as those are likely already in the image.
|
Does not include all the EXIF fields as those are likely already in the image.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1343,7 +1364,7 @@ def _exiftool_json_sidecar(
|
|||||||
XMP:Title
|
XMP:Title
|
||||||
XMP:TagsList
|
XMP:TagsList
|
||||||
IPTC:Keywords (may include album name, person name, or template)
|
IPTC:Keywords (may include album name, person name, or template)
|
||||||
XMP:Subject
|
XMP:Subject (set to keywords + person)
|
||||||
XMP:PersonInImage
|
XMP:PersonInImage
|
||||||
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
|
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
|
||||||
EXIF:GPSLatitude, EXIF:GPSLongitude
|
EXIF:GPSLatitude, EXIF:GPSLongitude
|
||||||
@@ -1353,6 +1374,10 @@ def _exiftool_json_sidecar(
|
|||||||
EXIF:ModifyDate
|
EXIF:ModifyDate
|
||||||
IPTC:DigitalCreationDate
|
IPTC:DigitalCreationDate
|
||||||
IPTC:DateCreated
|
IPTC:DateCreated
|
||||||
|
QuickTime:CreationDate (UTC)
|
||||||
|
QuickTime:ModifyDate (UTC)
|
||||||
|
QuickTime:GPSCoordinates
|
||||||
|
UserData:GPSCoordinates
|
||||||
"""
|
"""
|
||||||
exif = self._exiftool_dict(
|
exif = self._exiftool_dict(
|
||||||
use_albums_as_keywords=use_albums_as_keywords,
|
use_albums_as_keywords=use_albums_as_keywords,
|
||||||
@@ -1372,11 +1397,11 @@ def _xmp_sidecar(
|
|||||||
description_template=None,
|
description_template=None,
|
||||||
extension=None,
|
extension=None,
|
||||||
):
|
):
|
||||||
""" returns string for XMP sidecar
|
"""returns string for XMP sidecar
|
||||||
use_albums_as_keywords: treat album names as keywords
|
use_albums_as_keywords: treat album names as keywords
|
||||||
use_persons_as_keywords: treat person names as keywords
|
use_persons_as_keywords: treat person names as keywords
|
||||||
keyword_template: (list of strings); list of template strings to render as keywords
|
keyword_template: (list of strings); list of template strings to render as keywords
|
||||||
description_template: string; optional template string that will be rendered for use as photo description """
|
description_template: string; optional template string that will be rendered for use as photo description"""
|
||||||
|
|
||||||
xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME))
|
xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME))
|
||||||
|
|
||||||
@@ -1461,8 +1486,8 @@ def _xmp_sidecar(
|
|||||||
|
|
||||||
|
|
||||||
def _write_sidecar(self, filename, sidecar_str):
|
def _write_sidecar(self, filename, sidecar_str):
|
||||||
""" write sidecar_str to filename
|
"""write sidecar_str to filename
|
||||||
used for exporting sidecar info """
|
used for exporting sidecar info"""
|
||||||
if not (filename or sidecar_str):
|
if not (filename or sidecar_str):
|
||||||
raise (
|
raise (
|
||||||
ValueError(
|
ValueError(
|
||||||
|
|||||||
@@ -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>1797</integer>
|
<integer>464</integer>
|
||||||
<key>processname</key>
|
<key>processname</key>
|
||||||
<string>photolibraryd</string>
|
<string>photolibraryd</string>
|
||||||
<key>uid</key>
|
<key>uid</key>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 74 KiB |
1133
tests/test_catalina_10_15_7.py
Normal file
@@ -336,6 +336,31 @@ CLI_EXIFTOOL = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CLI_EXIFTOOL_QUICKTIME = {
|
||||||
|
"35329C57-B963-48D6-BB75-6AFF9370CBBC": {
|
||||||
|
"File:FileName": "Jellyfish.MOV",
|
||||||
|
"XMP:Description": "Jellyfish Video",
|
||||||
|
"XMP:Title": "Jellyfish",
|
||||||
|
"XMP:TagsList": "Travel",
|
||||||
|
"XMP:Subject": "Travel",
|
||||||
|
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
|
||||||
|
"QuickTime:CreationDate": "2020:01:05 22:13:13",
|
||||||
|
"QuickTime:CreateDate": "2020:01:05 22:13:13",
|
||||||
|
"QuickTime:ModifyDate": "2020:01:05 22:13:13",
|
||||||
|
},
|
||||||
|
"2CE332F2-D578-4769-AEFA-7631BB77AA41": {
|
||||||
|
"File:FileName": "Jellyfish.mp4",
|
||||||
|
"XMP:Description": "Jellyfish Video",
|
||||||
|
"XMP:Title": "Jellyfish",
|
||||||
|
"XMP:TagsList": "Travel",
|
||||||
|
"XMP:Subject": "Travel",
|
||||||
|
"QuickTime:GPSCoordinates": "34.053345 -118.242349",
|
||||||
|
"QuickTime:CreationDate": "2020:12:05 05:21:52",
|
||||||
|
"QuickTime:CreateDate": "2020:12:05 05:21:52",
|
||||||
|
"QuickTime:ModifyDate": "2020:12:05 05:21:52",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = {
|
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = {
|
||||||
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": {
|
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": {
|
||||||
"File:FileName": "wedding.jpg",
|
"File:FileName": "wedding.jpg",
|
||||||
@@ -995,6 +1020,46 @@ def test_export_exiftool_ignore_date_modified():
|
|||||||
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
|
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
||||||
|
def test_export_exiftool_quicktime():
|
||||||
|
""" test --exiftol correctly writes QuickTime tags """
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
from osxphotos.__main__ import export
|
||||||
|
from osxphotos.exiftool import ExifTool
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
cwd = os.getcwd()
|
||||||
|
# pylint: disable=not-context-manager
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
for uuid in CLI_EXIFTOOL_QUICKTIME:
|
||||||
|
result = runner.invoke(
|
||||||
|
export,
|
||||||
|
[
|
||||||
|
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||||
|
".",
|
||||||
|
"-V",
|
||||||
|
"--exiftool",
|
||||||
|
"--uuid",
|
||||||
|
f"{uuid}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
files = glob.glob("*")
|
||||||
|
assert sorted(files) == sorted(
|
||||||
|
[CLI_EXIFTOOL_QUICKTIME[uuid]["File:FileName"]]
|
||||||
|
)
|
||||||
|
|
||||||
|
exif = ExifTool(CLI_EXIFTOOL_QUICKTIME[uuid]["File:FileName"]).asdict()
|
||||||
|
for key in CLI_EXIFTOOL_QUICKTIME[uuid]:
|
||||||
|
assert exif[key] == CLI_EXIFTOOL_QUICKTIME[uuid][key]
|
||||||
|
|
||||||
|
# clean up exported files to avoid name conflicts
|
||||||
|
for filename in files:
|
||||||
|
os.unlink(filename)
|
||||||
|
|
||||||
|
|
||||||
def test_export_edited_suffix():
|
def test_export_edited_suffix():
|
||||||
""" test export with --edited-suffix """
|
""" test export with --edited-suffix """
|
||||||
import glob
|
import glob
|
||||||
@@ -2859,8 +2924,7 @@ def test_export_sidecar_keyword_template():
|
|||||||
|
|
||||||
json_expected = json.loads(
|
json_expected = json.loads(
|
||||||
"""
|
"""
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Girl holding pumpkin",
|
||||||
"EXIF:ImageDescription": "Girl holding pumpkin",
|
|
||||||
"XMP:Description": "Girl holding pumpkin",
|
"XMP:Description": "Girl holding pumpkin",
|
||||||
"XMP:Title": "I found one!",
|
"XMP:Title": "I found one!",
|
||||||
"XMP:TagsList": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
|
"XMP:TagsList": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
|
||||||
|
|||||||
@@ -1,90 +1,96 @@
|
|||||||
""" test datetime_utils """
|
from datetime import date, timezone
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from osxphotos.datetime_utils import *
|
||||||
|
|
||||||
|
|
||||||
def test_get_local_tz():
|
def test_get_local_tz():
|
||||||
""" test get_local_tz during time with no DST """
|
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
|
|
||||||
from osxphotos.datetime_utils import get_local_tz
|
|
||||||
|
|
||||||
os.environ["TZ"] = "US/Pacific"
|
os.environ["TZ"] = "US/Pacific"
|
||||||
time.tzset()
|
|
||||||
|
|
||||||
dt = datetime.datetime(2018, 12, 31, 0, 0, 0)
|
dt = datetime.datetime(2020, 9, 1, 21, 10, 00)
|
||||||
local_tz = get_local_tz(dt)
|
tz = get_local_tz(dt)
|
||||||
assert local_tz == datetime.timezone(
|
assert tz == datetime.timezone(offset=datetime.timedelta(seconds=-25200))
|
||||||
datetime.timedelta(days=-1, seconds=57600), "PST"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
dt = datetime.datetime(2020, 12, 1, 21, 10, 00)
|
||||||
def test_get_local_tz_dst():
|
tz = get_local_tz(dt)
|
||||||
""" test get_local_tz during time with DST """
|
assert tz == datetime.timezone(offset=datetime.timedelta(seconds=-28800))
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
from osxphotos.datetime_utils import get_local_tz
|
|
||||||
|
|
||||||
os.environ["TZ"] = "US/Pacific"
|
|
||||||
time.tzset()
|
|
||||||
|
|
||||||
dt = datetime.datetime(2018, 6, 30, 0, 0, 0)
|
|
||||||
local_tz = get_local_tz(dt)
|
|
||||||
assert local_tz == datetime.timezone(
|
|
||||||
datetime.timedelta(days=-1, seconds=61200), "PDT"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_datetime_remove_tz():
|
|
||||||
""" test datetime_remove_tz """
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from osxphotos.datetime_utils import datetime_remove_tz
|
|
||||||
|
|
||||||
dt = datetime.datetime(
|
|
||||||
2018,
|
|
||||||
12,
|
|
||||||
31,
|
|
||||||
tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=57600), "PST"),
|
|
||||||
)
|
|
||||||
dt_no_tz = datetime_remove_tz(dt)
|
|
||||||
assert dt_no_tz.tzinfo is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_datetime_has_tz():
|
def test_datetime_has_tz():
|
||||||
""" test datetime_has_tz """
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from osxphotos.datetime_utils import datetime_has_tz
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=-28800))
|
||||||
|
dt = datetime.datetime(2020, 9, 1, 21, 10, 00, tzinfo=tz)
|
||||||
dt = datetime.datetime(
|
|
||||||
2018,
|
|
||||||
12,
|
|
||||||
31,
|
|
||||||
tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=57600), "PST"),
|
|
||||||
)
|
|
||||||
assert datetime_has_tz(dt)
|
assert datetime_has_tz(dt)
|
||||||
|
|
||||||
dt_notz = datetime.datetime(2018, 12, 31)
|
dt = datetime.datetime(2020, 9, 1, 21, 10, 00)
|
||||||
assert not datetime_has_tz(dt_notz)
|
assert not datetime_has_tz(dt)
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_tz_to_utc():
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
|
||||||
|
dt = datetime.datetime(2020, 9, 1, 22, 6, 0, tzinfo=tz)
|
||||||
|
utc = datetime_tz_to_utc(dt)
|
||||||
|
assert utc == datetime.datetime(2020, 9, 2, 5, 6, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_remove_tz():
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["TZ"] = "US/Pacific"
|
||||||
|
|
||||||
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
|
||||||
|
dt = datetime.datetime(2020, 9, 1, 22, 6, 0, tzinfo=tz)
|
||||||
|
dt = datetime_remove_tz(dt)
|
||||||
|
assert dt == datetime.datetime(2020, 9, 1, 22, 6, 0)
|
||||||
|
assert not datetime_has_tz(dt)
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_naive_to_utc():
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
dt = datetime.datetime(2020, 9, 1, 12, 0, 0)
|
||||||
|
utc = datetime_naive_to_utc(dt)
|
||||||
|
assert utc == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def test_datetime_naive_to_local():
|
def test_datetime_naive_to_local():
|
||||||
""" test datetime_naive_to_local """
|
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
|
|
||||||
from osxphotos.datetime_utils import datetime_naive_to_local
|
|
||||||
|
|
||||||
os.environ["TZ"] = "US/Pacific"
|
os.environ["TZ"] = "US/Pacific"
|
||||||
time.tzset()
|
|
||||||
|
|
||||||
dt = datetime.datetime(2018, 6, 30, 0, 0, 0)
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
|
||||||
dt_local = datetime_naive_to_local(dt)
|
dt = datetime.datetime(2020, 9, 1, 12, 0, 0)
|
||||||
assert dt_local.tzinfo == datetime.timezone(
|
utc = datetime_naive_to_local(dt)
|
||||||
datetime.timedelta(days=-1, seconds=61200), "PDT"
|
assert utc == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=tz)
|
||||||
)
|
|
||||||
|
|
||||||
|
def test_datetime_utc_to_local():
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["TZ"] = "US/Pacific"
|
||||||
|
|
||||||
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
|
||||||
|
utc = datetime.datetime(2020, 9, 1, 19, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
dt = datetime_utc_to_local(utc)
|
||||||
|
assert dt == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=tz)
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_utc_to_local_2():
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["TZ"] = "CEST"
|
||||||
|
|
||||||
|
tz = datetime.timezone(offset=datetime.timedelta(seconds=7200))
|
||||||
|
utc = datetime.datetime(2020, 9, 1, 19, 0, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
dt = datetime_utc_to_local(utc)
|
||||||
|
assert dt == datetime.datetime(2020, 9, 1, 21, 0, 0, tzinfo=tz)
|
||||||
@@ -68,8 +68,7 @@ XMP_JPG_FILENAME = "Pumkins1.jpg"
|
|||||||
|
|
||||||
EXIF_JSON_UUID = UUID_DICT["has_adjustments"]
|
EXIF_JSON_UUID = UUID_DICT["has_adjustments"]
|
||||||
EXIF_JSON_EXPECTED = """
|
EXIF_JSON_EXPECTED = """
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||||
"EXIF:ImageDescription": "Bride Wedding day",
|
|
||||||
"XMP:Description": "Bride Wedding day",
|
"XMP:Description": "Bride Wedding day",
|
||||||
"XMP:TagsList": ["wedding"],
|
"XMP:TagsList": ["wedding"],
|
||||||
"IPTC:Keywords": ["wedding"],
|
"IPTC:Keywords": ["wedding"],
|
||||||
@@ -84,8 +83,7 @@ EXIF_JSON_EXPECTED = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
|
EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||||
"EXIF:ImageDescription": "Bride Wedding day",
|
|
||||||
"XMP:Description": "Bride Wedding day",
|
"XMP:Description": "Bride Wedding day",
|
||||||
"XMP:TagsList": ["wedding"],
|
"XMP:TagsList": ["wedding"],
|
||||||
"IPTC:Keywords": ["wedding"],
|
"IPTC:Keywords": ["wedding"],
|
||||||
@@ -544,8 +542,7 @@ def test_exiftool_json_sidecar_keyword_template_long(caplog):
|
|||||||
|
|
||||||
json_expected = json.loads(
|
json_expected = json.loads(
|
||||||
"""
|
"""
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||||
"EXIF:ImageDescription": "Bride Wedding day",
|
|
||||||
"XMP:Description": "Bride Wedding day",
|
"XMP:Description": "Bride Wedding day",
|
||||||
"XMP:TagsList": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
"XMP:TagsList": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
||||||
"IPTC:Keywords": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
"IPTC:Keywords": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"],
|
||||||
@@ -594,8 +591,7 @@ def test_exiftool_json_sidecar_keyword_template():
|
|||||||
|
|
||||||
json_expected = json.loads(
|
json_expected = json.loads(
|
||||||
"""
|
"""
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Bride Wedding day",
|
||||||
"EXIF:ImageDescription": "Bride Wedding day",
|
|
||||||
"XMP:Description": "Bride Wedding day",
|
"XMP:Description": "Bride Wedding day",
|
||||||
"XMP:TagsList": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
"XMP:TagsList": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
||||||
"IPTC:Keywords": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
"IPTC:Keywords": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
|
||||||
@@ -655,8 +651,7 @@ def test_exiftool_json_sidecar_use_persons_keyword():
|
|||||||
|
|
||||||
json_expected = json.loads(
|
json_expected = json.loads(
|
||||||
"""
|
"""
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Girls with pumpkins",
|
||||||
"EXIF:ImageDescription": "Girls with pumpkins",
|
|
||||||
"XMP:Description": "Girls with pumpkins",
|
"XMP:Description": "Girls with pumpkins",
|
||||||
"XMP:Title": "Can we carry this?",
|
"XMP:Title": "Can we carry this?",
|
||||||
"XMP:TagsList": ["Kids", "Suzy", "Katie"],
|
"XMP:TagsList": ["Kids", "Suzy", "Katie"],
|
||||||
@@ -698,8 +693,7 @@ def test_exiftool_json_sidecar_use_albums_keyword():
|
|||||||
|
|
||||||
json_expected = json.loads(
|
json_expected = json.loads(
|
||||||
"""
|
"""
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"EXIF:ImageDescription": "Girls with pumpkins",
|
||||||
"EXIF:ImageDescription": "Girls with pumpkins",
|
|
||||||
"XMP:Description": "Girls with pumpkins",
|
"XMP:Description": "Girls with pumpkins",
|
||||||
"XMP:Title": "Can we carry this?",
|
"XMP:Title": "Can we carry this?",
|
||||||
"XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"],
|
"XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"],
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ UUID_DICT = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EXIF_JSON_EXPECTED = """
|
EXIF_JSON_EXPECTED = """
|
||||||
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
|
[{"XMP:Title": "St. James\'s Park",
|
||||||
"XMP:Title": "St. James\'s Park",
|
|
||||||
"XMP:TagsList": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
"XMP:TagsList": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
||||||
"IPTC:Keywords": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
"IPTC:Keywords": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
||||||
"XMP:Subject": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
"XMP:Subject": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
|
||||||
|
|||||||