diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 7ea4cd6b..791c7a03 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -59,13 +59,13 @@ def normalize_unicode(value): def get_photos_db(*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 - the following order: - - last library opened - - system library - - ~/Pictures/Photos Library.photoslibrary - - failing above, returns None + """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 + the following order: + - last library opened + - system library + - ~/Pictures/Photos Library.photoslibrary + - failing above, returns None """ if 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): - """ Print list of Photos libraries found on the system. - If json_ == True, print output as JSON (default = False) """ + """Print list of Photos libraries found on the system. + If json_ == True, print output as JSON (default = False)""" photo_libs = osxphotos.utils.list_photo_libraries() sys_lib = osxphotos.utils.get_system_library_path() @@ -1053,9 +1053,9 @@ def query( has_likes, no_likes, ): - """ Query the Photos database using 1 or more search options; - if more than one option is provided, they are treated as "AND" - (e.g. search for photos matching all options). + """Query the Photos database using 1 or more search options; + if more than one option is provided, they are treated as "AND" + (e.g. search for photos matching all options). """ # 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 " "(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( "--person-keyword", is_flag=True, @@ -1303,67 +1376,6 @@ def query( '--description-template "{descr} exported with osxphotos on {today.date}" ' "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( "--directory", metavar="DIRECTORY", @@ -1519,16 +1531,16 @@ def export( use_photokit, report, ): - """ Export photos from the Photos database. - Export path DEST is required. - Optionally, query the Photos database using 1 or more search options; - if more than one option is provided, they are treated as "AND" - (e.g. search for photos matching all options). - If no query options are provided, all photos will be exported. - By default, all versions of all photos will be exported including edited - versions, live photo movies, burst photos, and associated raw images. - See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options - to modify this behavior. + """Export photos from the Photos database. + Export path DEST is required. + Optionally, query the Photos database using 1 or more search options; + if more than one option is provided, they are treated as "AND" + (e.g. search for photos matching all options). + If no query options are provided, all photos will be exported. + By default, all versions of all photos will be exported including edited + versions, live photo movies, burst photos, and associated raw images. + See --skip-edited, --skip-live, --skip-bursts, and --skip-raw options + to modify this behavior. """ global VERBOSE @@ -2107,10 +2119,10 @@ def _query( has_likes=False, no_likes=False, ): - """ run a query against PhotosDB to extract the photos based on user supply criteria - used by query and export commands - arguments must be passed in same order as query and export - if either is modified, need to ensure all three functions are updated """ + """run a query against PhotosDB to extract the photos based on user supply criteria + used by query and export commands + arguments must be passed in same order as query and export + if either is modified, need to ensure all three functions are updated""" photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose) if deleted or deleted_only: @@ -2342,7 +2354,7 @@ def get_photos_by_attribute(photos, attribute, values, ignore_case): """Search for photos based on values being in PhotoInfo.attribute Args: - photos: a list of PhotoInfo objects + photos: a list of PhotoInfo objects attribute: str, name of PhotoInfo attribute to search (e.g. keywords, persons, etc) values: list of values to search in property ignore_case: ignore case when searching @@ -2401,7 +2413,7 @@ def export_photo( ignore_date_modified=False, use_photokit=False, ): - """ Helper function for export that does the actual export + """Helper function for export that does the actual export Args: photo: PhotoInfo object @@ -2437,7 +2449,7 @@ def export_photo( Returns: list of path(s) of exported photo or None if photo was missing - + Raises: ValueError on invalid filename_template """ @@ -2708,16 +2720,16 @@ def export_photo( 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: photo: a PhotoInfo instance filename_template: a PhotoTemplate template string, may be None original_name: boolean; if True, use photo's original filename instead of current filename - + Returns: list of filenames - + Raises: click.BadOptionUsage if template is invalid """ @@ -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): - """ 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: photo: a PhotoInstance object @@ -2792,8 +2804,8 @@ def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run): def find_files_in_branch(pathname, filename): - """ Search a directory branch to find file(s) named filename - The branch searched includes all folders below pathname and + """Search a directory branch to find file(s) named filename + The branch searched includes all folders below pathname and the parent tree of pathname but not pathname itself. e.g. find filename in children folders and parent folders @@ -2801,7 +2813,7 @@ def find_files_in_branch(pathname, filename): Args: pathname: str, full path of directory to search filename: str, filename to search for - + Returns: list of full paths to any matching files """ @@ -2829,16 +2841,16 @@ def find_files_in_branch(pathname, 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. Whitespace is stripped. Arguments: filename: file name of the file containing UUIDs - + Returns: list of UUIDs or empty list of no UUIDs in file - + Raises: FileNotFoundError if file does not exist """ diff --git a/osxphotos/datetime_utils.py b/osxphotos/datetime_utils.py index f43fa59d..e5608475 100644 --- a/osxphotos/datetime_utils.py +++ b/osxphotos/datetime_utils.py @@ -1,10 +1,10 @@ -""" datetime utilities """ +""" datetime.datetime helper functions for converting to/from UTC """ import datetime def get_local_tz(dt): - """ return local timezone as datetime.timezone tzinfo for dt + """ Return local timezone as datetime.timezone tzinfo for dt Args: dt: datetime.datetime @@ -21,21 +21,18 @@ def get_local_tz(dt): 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): - """ return True if datetime dt has tzinfo else False + """ Return True if datetime dt has tzinfo else False + + Args: 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: 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 -def datetime_naive_to_local(dt): - """ convert naive (timezone unaware) datetime.datetime - to aware timezone in local timezone +def datetime_tz_to_utc(dt): + """ Convert datetime.datetime object with timezone to UTC 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 - 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: 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)) + + +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) diff --git a/osxphotos/photoinfo/_photoinfo_export.py b/osxphotos/photoinfo/_photoinfo_export.py index d1aa359f..232df492 100644 --- a/osxphotos/photoinfo/_photoinfo_export.py +++ b/osxphotos/photoinfo/_photoinfo_export.py @@ -33,6 +33,7 @@ from .._constants import ( _UNKNOWN_PERSON, _XMP_TEMPLATE_NAME, ) +from ..datetime_utils import datetime_tz_to_utc from ..exiftool import ExifTool from ..export_db import ExportDBNoOp from ..fileutil import FileUtil @@ -82,27 +83,27 @@ def _export_photo_uuid_applescript( burst=False, dry_run=False, ): - """ Export photo to dest path using applescript to control Photos - If photo is a live photo, exports both the photo and associated .mov file - uuid: UUID of photo to export - dest: destination path to export to - 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) - If not provided, file will be named with whatever name Photos uses - If filestem.ext exists, it wil be overwritten - original: (boolean) if True, export original image; default = True - edited: (boolean) if True, export edited photo; default = False - If photo not edited and edited=True, will still export the original image - caller must verify image has been edited - *Note*: must be called with either edited or original but not both, - will raise error if called with both edited and original = True - 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 - 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 - 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 - has not been edited. This is due to how Photos Applescript interface works. + """Export photo to dest path using applescript to control Photos + If photo is a live photo, exports both the photo and associated .mov file + uuid: UUID of photo to export + dest: destination path to export to + 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) + If not provided, file will be named with whatever name Photos uses + If filestem.ext exists, it wil be overwritten + original: (boolean) if True, export original image; default = True + edited: (boolean) if True, export edited photo; default = False + If photo not edited and edited=True, will still export the original image + caller must verify image has been edited + *Note*: must be called with either edited or original but not both, + will raise error if called with both edited and original = True + 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 + 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 + 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 + has not been edited. This is due to how Photos Applescript interface works. """ # setup the applescript to do the export @@ -191,10 +192,10 @@ def _export_photo_uuid_applescript( # _check_export_suffix is not a class method, don't import this into PhotoInfo def _check_export_suffix(src, dest, edited): """Helper function for exporting photos to check file extensions of destination path. - + Checks that dst file extension is appropriate for the src. If edited=True, will use src file extension of ".jpeg" if None provided for src. - + Args: src: path to source file or None. dest: path to destination file. @@ -249,43 +250,43 @@ def export( keyword_template=None, description_template=None, ): - """ export photo - dest: must be valid destination path (or exception raised) - filename: (optional): name of exported picture; if not provided, will use current filename - **NOTE**: if provided, user must ensure file extension (suffix) is correct. - For example, if photo is .CR2 file, edited image may be .jpeg. - If you provide an extension different than what the actual file is, - 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 - use the extension provided by Photos upon export; in this case, an incorrect extension is - silently ignored). - e.g. to get the extension of the edited photo, - reference PhotoInfo.path_edited - edited: (boolean, default=False); if True will export the edited version of the photo - (or raise exception if no edited version) - 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 - 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 - 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 - 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_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data - 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 - 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 - no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes - returns list of full paths to the exported files - use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords - when exporting metadata with exiftool or sidecar - use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords - when exporting metadata with exiftool or sidecar - 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 - returns: list of photos exported - """ + """export photo + dest: must be valid destination path (or exception raised) + filename: (optional): name of exported picture; if not provided, will use current filename + **NOTE**: if provided, user must ensure file extension (suffix) is correct. + For example, if photo is .CR2 file, edited image may be .jpeg. + If you provide an extension different than what the actual file is, + 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 + use the extension provided by Photos upon export; in this case, an incorrect extension is + silently ignored). + e.g. to get the extension of the edited photo, + reference PhotoInfo.path_edited + edited: (boolean, default=False); if True will export the edited version of the photo + (or raise exception if no edited version) + 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 + 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 + 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 + 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_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data + 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 + 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 + no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes + returns list of full paths to the exported files + use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords + when exporting metadata with exiftool or sidecar + use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords + when exporting metadata with exiftool or sidecar + 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 + returns: list of photos exported + """ # Implementation note: calls export2 to actually do the work @@ -344,56 +345,56 @@ def export2( use_photokit=False, verbose=None, ): - """ export photo, like export but with update and dry_run options - dest: must be valid destination path or exception raised - filename: (optional): name of exported picture; if not provided, will use current filename - **NOTE**: if provided, user must ensure file extension (suffix) is correct. - For example, if photo is .CR2 file, edited image may be .jpeg. - 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, - in which case export will use the extension provided by Photos upon export. - e.g. to get the extension of the edited photo, - reference PhotoInfo.path_edited - edited: (boolean, default=False); if True will export the edited version of the photo - (or raise exception if no edited version) - 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 - 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 - 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 - 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_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data - 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 - 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 - 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 - when exporting metadata with exiftool or sidecar - use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords - when exporting metadata with exiftool or sidecar - 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 - 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 - 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 - 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 - 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 - 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 - verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output. + """export photo, like export but with update and dry_run options + dest: must be valid destination path or exception raised + filename: (optional): name of exported picture; if not provided, will use current filename + **NOTE**: if provided, user must ensure file extension (suffix) is correct. + For example, if photo is .CR2 file, edited image may be .jpeg. + 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, + in which case export will use the extension provided by Photos upon export. + e.g. to get the extension of the edited photo, + reference PhotoInfo.path_edited + edited: (boolean, default=False); if True will export the edited version of the photo + (or raise exception if no edited version) + 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 + 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 + 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 + 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_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data + 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 + 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 + 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 + when exporting metadata with exiftool or sidecar + use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords + when exporting metadata with exiftool or sidecar + 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 + 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 + 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 + 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 + 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 + 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 + 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 - 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, - and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp) - """ + Returns: ExportResults namedtuple with fields: exported, new, updated, skipped + 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, + and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp) + """ # 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. @@ -956,12 +957,12 @@ def _export_photo( edited=False, jpeg_quality=1.0, ): - """ Helper function for export() - Does the actual copy or hardlink taking the appropriate + """Helper function for export() + Does the actual copy or hardlink taking the appropriate action depending on update, overwrite, export_as_hardlink Assumes destination is the right destination (e.g. UUID matches) sets UUID and JSON info foo exported file using set_uuid_for_file, set_inf_for_uuido - + Args: src: src path (string) dest: dest path (pathlib.Path) @@ -1125,10 +1126,10 @@ def _write_exif_data( description_template=None, ignore_date_modified=False, ): - """ write exif data to image file at filepath + """write exif data to image file at filepath Args: - filepath: full path to the image file + filepath: full path to the image file use_albums_as_keywords: treat album 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 @@ -1146,9 +1147,7 @@ def _write_exif_data( with ExifTool(filepath) as exiftool: for exiftag, val in exif_info.items(): - if exiftag == "_CreatedBy": - continue - elif type(val) == list: + if type(val) == list: for v in val: exiftool.setvalue(exiftag, v) else: @@ -1163,7 +1162,7 @@ def _exiftool_dict( description_template=None, 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. Args: @@ -1176,12 +1175,12 @@ def _exiftool_dict( Returns: dict with exiftool tags / values Exports the following: - EXIF:ImageDescription + EXIF:ImageDescription (may include template) XMP:Description (may include template) XMP:Title - XMP:TagsList + XMP:TagsList (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 EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef EXIF:GPSLatitude, EXIF:GPSLongitude @@ -1191,10 +1190,13 @@ def _exiftool_dict( EXIF:ModifyDate IPTC:DateCreated IPTC:TimeCreated + QuickTime:CreationDate (UTC) + QuickTime:ModifyDate (UTC) + QuickTime:GPSCoordinates + UserData:GPSCoordinates """ exif = {} - exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos" if description_template is not None: description = self.render_template( description_template, expand_inplace=True, inplace_sep=", " @@ -1272,12 +1274,16 @@ def _exiftool_dict( (lat, lon) = self.location if lat is not None and lon is not None: - exif["EXIF:GPSLatitude"] = lat - exif["EXIF:GPSLongitude"] = lon - lat_ref = "N" if lat >= 0 else "S" - lon_ref = "E" if lon >= 0 else "W" - exif["EXIF:GPSLatitudeRef"] = lat_ref - exif["EXIF:GPSLongitudeRef"] = lon_ref + if self.isphoto: + exif["EXIF:GPSLatitude"] = lat + exif["EXIF:GPSLongitude"] = lon + lat_ref = "N" if lat >= 0 else "S" + lon_ref = "E" if lon >= 0 else "W" + 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 # 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: # if photo has modification date, use it otherwise use creation date - date = self.date - # exiftool expects format to "2015:01:18 12:00:00" - datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S") - exif["EXIF:DateTimeOriginal"] = datetimeoriginal - exif["EXIF:CreateDate"] = datetimeoriginal + if self.isphoto: + 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["EXIF:OffsetTimeOriginal"] = offsettime + exif["EXIF:DateTimeOriginal"] = datetimeoriginal + exif["EXIF:CreateDate"] = datetimeoriginal - dateoriginal = date.strftime("%Y:%m:%d") - exif["IPTC:DateCreated"] = dateoriginal + 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["EXIF:OffsetTimeOriginal"] = offsettime - timeoriginal = date.strftime(f"%H:%M:%S{offsettime}") - exif["IPTC:TimeCreated"] = timeoriginal + dateoriginal = date.strftime("%Y:%m:%d") + exif["IPTC:DateCreated"] = dateoriginal - 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") + timeoriginal = date.strftime(f"%H:%M:%S{offsettime}") + exif["IPTC:TimeCreated"] = timeoriginal + + 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 @@ -1325,7 +1346,7 @@ def _exiftool_json_sidecar( description_template=None, 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. Args: @@ -1343,7 +1364,7 @@ def _exiftool_json_sidecar( XMP:Title XMP:TagsList IPTC:Keywords (may include album name, person name, or template) - XMP:Subject + XMP:Subject (set to keywords + person) XMP:PersonInImage EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef EXIF:GPSLatitude, EXIF:GPSLongitude @@ -1353,6 +1374,10 @@ def _exiftool_json_sidecar( EXIF:ModifyDate IPTC:DigitalCreationDate IPTC:DateCreated + QuickTime:CreationDate (UTC) + QuickTime:ModifyDate (UTC) + QuickTime:GPSCoordinates + UserData:GPSCoordinates """ exif = self._exiftool_dict( use_albums_as_keywords=use_albums_as_keywords, @@ -1372,11 +1397,11 @@ def _xmp_sidecar( description_template=None, extension=None, ): - """ returns string for XMP sidecar - use_albums_as_keywords: treat album 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 - description_template: string; optional template string that will be rendered for use as photo description """ + """returns string for XMP sidecar + use_albums_as_keywords: treat album 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 + 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)) @@ -1461,8 +1486,8 @@ def _xmp_sidecar( def _write_sidecar(self, filename, sidecar_str): - """ write sidecar_str to filename - used for exporting sidecar info """ + """write sidecar_str to filename + used for exporting sidecar info""" if not (filename or sidecar_str): raise ( ValueError( diff --git a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-shm b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-shm index 8de93b6d..289453e7 100644 Binary files a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-shm and b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-shm differ diff --git a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-wal b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-wal index 5546247b..7e30fe57 100644 Binary files a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-wal and b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-wal differ diff --git a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite.lock b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite.lock index 541e5308..f36aa599 100644 --- a/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite.lock +++ b/tests/Test-10.15.7.photoslibrary/database/Photos.sqlite.lock @@ -7,7 +7,7 @@ hostuuid 9575E48B-8D5F-5654-ABAC-4431B1167324 pid - 1797 + 464 processname photolibraryd uid diff --git a/tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite b/tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite index fda31610..9d06d36a 100644 Binary files a/tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite and b/tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite differ diff --git a/tests/Test-10.15.7.photoslibrary/database/search/synonymsProcess.plist b/tests/Test-10.15.7.photoslibrary/database/search/synonymsProcess.plist index 58e48cc1..fa046867 100644 Binary files a/tests/Test-10.15.7.photoslibrary/database/search/synonymsProcess.plist and b/tests/Test-10.15.7.photoslibrary/database/search/synonymsProcess.plist differ diff --git a/tests/Test-10.15.7.photoslibrary/database/search/zeroKeywords.data b/tests/Test-10.15.7.photoslibrary/database/search/zeroKeywords.data index 5d03620c..f22dd681 100644 Binary files a/tests/Test-10.15.7.photoslibrary/database/search/zeroKeywords.data and b/tests/Test-10.15.7.photoslibrary/database/search/zeroKeywords.data differ diff --git a/tests/Test-10.15.7.photoslibrary/originals/2/2CE332F2-D578-4769-AEFA-7631BB77AA41.mp4 b/tests/Test-10.15.7.photoslibrary/originals/2/2CE332F2-D578-4769-AEFA-7631BB77AA41.mp4 new file mode 100644 index 00000000..08fa5f3f Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/originals/2/2CE332F2-D578-4769-AEFA-7631BB77AA41.mp4 differ diff --git a/tests/Test-10.15.7.photoslibrary/originals/3/35329C57-B963-48D6-BB75-6AFF9370CBBC.mov b/tests/Test-10.15.7.photoslibrary/originals/3/35329C57-B963-48D6-BB75-6AFF9370CBBC.mov new file mode 100644 index 00000000..0b5c6303 Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/originals/3/35329C57-B963-48D6-BB75-6AFF9370CBBC.mov differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm index 1a2ded58..0faab6dd 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-shm differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal index 85f527ee..2c0c5ec7 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.POI.sqlite-wal differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-shm b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-shm index 4c902e39..7a377b0e 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-shm and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-shm differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-wal b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-wal index b8781a01..3509622b 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-wal and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSBusinessCategoryCache.ROI.sqlite-wal differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-shm b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-shm index 445a19fb..14dd2830 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-shm and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-shm differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-wal b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-wal index 134e803f..9865ff5b 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-wal and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSLocationCache.sqlite-wal differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-shm b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-shm index ee2bbd34..591ba328 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-shm and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-shm differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-wal b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-wal index 5691e3be..c0fbc8f1 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-wal and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/CLSPublicEventCache.sqlite-wal differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm index a097d319..11a25db2 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-shm differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal index af04ea60..88684e24 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGCurationCache.sqlite.sqlite-wal differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGSearchComputationCache.plist b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGSearchComputationCache.plist index 9dfd418a..a908128c 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGSearchComputationCache.plist and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PGSearchComputationCache.plist differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb index 54901267..10a7c95f 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/PhotosGraph/photosgraph.kgdb differ diff --git a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist index 7eb12914..13b3a1e5 100644 Binary files a/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist and b/tests/Test-10.15.7.photoslibrary/private/com.apple.photoanalysisd/caches/graph/changetoken.plist differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/2/2CE332F2-D578-4769-AEFA-7631BB77AA41.THM b/tests/Test-10.15.7.photoslibrary/resources/derivatives/2/2CE332F2-D578-4769-AEFA-7631BB77AA41.THM new file mode 100644 index 00000000..e7336712 Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/2/2CE332F2-D578-4769-AEFA-7631BB77AA41.THM differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/2/2CE332F2-D578-4769-AEFA-7631BB77AA41_1_102_o.jpeg b/tests/Test-10.15.7.photoslibrary/resources/derivatives/2/2CE332F2-D578-4769-AEFA-7631BB77AA41_1_102_o.jpeg new file mode 100644 index 00000000..ea651179 Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/2/2CE332F2-D578-4769-AEFA-7631BB77AA41_1_102_o.jpeg differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/2/2CE332F2-D578-4769-AEFA-7631BB77AA41_1_105_c.jpeg b/tests/Test-10.15.7.photoslibrary/resources/derivatives/2/2CE332F2-D578-4769-AEFA-7631BB77AA41_1_105_c.jpeg new file mode 100644 index 00000000..3464db83 Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/2/2CE332F2-D578-4769-AEFA-7631BB77AA41_1_105_c.jpeg differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/3/35329C57-B963-48D6-BB75-6AFF9370CBBC.THM b/tests/Test-10.15.7.photoslibrary/resources/derivatives/3/35329C57-B963-48D6-BB75-6AFF9370CBBC.THM new file mode 100644 index 00000000..00839588 Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/3/35329C57-B963-48D6-BB75-6AFF9370CBBC.THM differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/3/35329C57-B963-48D6-BB75-6AFF9370CBBC_1_102_o.jpeg b/tests/Test-10.15.7.photoslibrary/resources/derivatives/3/35329C57-B963-48D6-BB75-6AFF9370CBBC_1_102_o.jpeg new file mode 100644 index 00000000..fe22778e Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/3/35329C57-B963-48D6-BB75-6AFF9370CBBC_1_102_o.jpeg differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/3/35329C57-B963-48D6-BB75-6AFF9370CBBC_1_105_c.jpeg b/tests/Test-10.15.7.photoslibrary/resources/derivatives/3/35329C57-B963-48D6-BB75-6AFF9370CBBC_1_105_c.jpeg new file mode 100644 index 00000000..5458be1f Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/3/35329C57-B963-48D6-BB75-6AFF9370CBBC_1_105_c.jpeg differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/masters/2/2CE332F2-D578-4769-AEFA-7631BB77AA41_4_5005_c.jpeg b/tests/Test-10.15.7.photoslibrary/resources/derivatives/masters/2/2CE332F2-D578-4769-AEFA-7631BB77AA41_4_5005_c.jpeg new file mode 100644 index 00000000..5bc59d86 Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/masters/2/2CE332F2-D578-4769-AEFA-7631BB77AA41_4_5005_c.jpeg differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/masters/3/35329C57-B963-48D6-BB75-6AFF9370CBBC_4_5005_c.jpeg b/tests/Test-10.15.7.photoslibrary/resources/derivatives/masters/3/35329C57-B963-48D6-BB75-6AFF9370CBBC_4_5005_c.jpeg new file mode 100644 index 00000000..8ed37b79 Binary files /dev/null and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/masters/3/35329C57-B963-48D6-BB75-6AFF9370CBBC_4_5005_c.jpeg differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/3305.ithmb b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/3305.ithmb index acd4974d..43bde3fa 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/3305.ithmb and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/3305.ithmb differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4031.ithmb b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4031.ithmb index 47c751f8..5fd64a61 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4031.ithmb and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4031.ithmb differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4132.ithmb b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4132.ithmb index eeef704f..9756ac08 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4132.ithmb and b/tests/Test-10.15.7.photoslibrary/resources/derivatives/thumbs/4132.ithmb differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/journals/Asset-change.plj b/tests/Test-10.15.7.photoslibrary/resources/journals/Asset-change.plj index 2e41a433..22ddaffe 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/journals/Asset-change.plj and b/tests/Test-10.15.7.photoslibrary/resources/journals/Asset-change.plj differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/journals/HistoryToken.plist b/tests/Test-10.15.7.photoslibrary/resources/journals/HistoryToken.plist index 47943276..ea0ac84b 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/journals/HistoryToken.plist and b/tests/Test-10.15.7.photoslibrary/resources/journals/HistoryToken.plist differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/journals/ImportSession-change.plj b/tests/Test-10.15.7.photoslibrary/resources/journals/ImportSession-change.plj index 20fe3a9c..94fce56a 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/journals/ImportSession-change.plj and b/tests/Test-10.15.7.photoslibrary/resources/journals/ImportSession-change.plj differ diff --git a/tests/Test-10.15.7.photoslibrary/resources/journals/Keyword-change.plj b/tests/Test-10.15.7.photoslibrary/resources/journals/Keyword-change.plj index c605f9de..356190ed 100644 Binary files a/tests/Test-10.15.7.photoslibrary/resources/journals/Keyword-change.plj and b/tests/Test-10.15.7.photoslibrary/resources/journals/Keyword-change.plj differ diff --git a/tests/test_catalina_10_15_7.py b/tests/test_catalina_10_15_7.py new file mode 100644 index 00000000..c75ac4db --- /dev/null +++ b/tests/test_catalina_10_15_7.py @@ -0,0 +1,1133 @@ +""" Basic tests for Photos 5 on MacOS 10.15.7 """ + +import datetime +import os +import os.path +import pathlib +import sqlite3 +import tempfile +import time +from collections import Counter, namedtuple + +import pytest + +import osxphotos +from osxphotos._constants import _UNKNOWN_PERSON + +PHOTOS_DB = "tests/Test-10.15.7.photoslibrary/database/photos.db" +PHOTOS_DB_PATH = "/Test-10.15.7.photoslibrary/database/photos.db" +PHOTOS_LIBRARY_PATH = "/Test-10.15.7.photoslibrary" + +PHOTOS_DB_LEN = 18 +PHOTOS_NOT_IN_TRASH_LEN = 16 +PHOTOS_IN_TRASH_LEN = 2 +PHOTOS_DB_IMPORT_SESSIONS = 12 + +KEYWORDS = [ + "Kids", + "wedding", + "flowers", + "England", + "London", + "London 2018", + "St. James's Park", + "UK", + "United Kingdom", + "foo/bar", + "Travel", +] +# Photos 5 includes blank person for detected face +PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON] +ALBUMS = [ + "Pumpkin Farm", + "Test Album", # there are 2 albums named "Test Album" for testing duplicate album names + "AlbumInFolder", + "Raw", + "I have a deleted twin", # there's an empty album with same name that has been deleted + "EmptyAlbum", + "2018-10 - Sponsion, Museum, Frühstück, Römermuseum", + "2019-10/11 Paris Clermont", +] +KEYWORDS_DICT = { + "Kids": 4, + "wedding": 3, + "flowers": 1, + "England": 1, + "London": 1, + "London 2018": 1, + "St. James's Park": 1, + "UK": 1, + "United Kingdom": 1, + "foo/bar": 1, + "Travel": 2, +} +PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 2, _UNKNOWN_PERSON: 1} +ALBUM_DICT = { + "Pumpkin Farm": 3, + "Test Album": 2, + "AlbumInFolder": 2, + "Raw": 4, + "I have a deleted twin": 1, + "EmptyAlbum": 0, + "2018-10 - Sponsion, Museum, Frühstück, Römermuseum": 1, + "2019-10/11 Paris Clermont": 1, +} # Note: there are 2 albums named "Test Album" for testing duplicate album names + +UUID_DICT = { + "missing": "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C", + "favorite": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", + "not_favorite": "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C", + "hidden": "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C", + "not_hidden": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", + "has_adjustments": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", + "no_adjustments": "D05A5FE3-15FB-49A1-A15D-AB3DA6F8B068", + "location": "DC99FBDD-7A52-4100-A5BB-344131646C30", + "no_location": "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4", + "external_edit": "DC99FBDD-7A52-4100-A5BB-344131646C30", + "no_external_edit": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", + "export": "D79B8D77-BFFC-460B-9312-034F2877D35B", # "Pumkins2.jpg" + "export_tif": "8846E3E6-8AC8-4857-8448-E3D025784410", + "in_album": "D79B8D77-BFFC-460B-9312-034F2877D35B", # "Pumkins2.jpg" + "date_invalid": "8846E3E6-8AC8-4857-8448-E3D025784410", + "intrash": "71E3E212-00EB-430D-8A63-5E294B268554", + "not_intrash": "DC99FBDD-7A52-4100-A5BB-344131646C30", + "intrash_person_keywords": "6FD38366-3BF2-407D-81FE-7153EB6125B6", + "import_session": "8846E3E6-8AC8-4857-8448-E3D025784410", + "movie": "2CE332F2-D578-4769-AEFA-7631BB77AA41", +} + +UUID_PUMPKIN_FARM = [ + "F12384F6-CD17-4151-ACBA-AE0E3688539E", + "D79B8D77-BFFC-460B-9312-034F2877D35B", + "1EB2B765-0765-43BA-A90C-0D0580E6172C", +] + +ALBUM_SORT_ORDER = [ + "1EB2B765-0765-43BA-A90C-0D0580E6172C", + "F12384F6-CD17-4151-ACBA-AE0E3688539E", + "D79B8D77-BFFC-460B-9312-034F2877D35B", +] +ALBUM_KEY_PHOTO = "D79B8D77-BFFC-460B-9312-034F2877D35B" + +UTI_DICT = { + "8846E3E6-8AC8-4857-8448-E3D025784410": "public.tiff", + "7783E8E6-9CAC-40F3-BE22-81FB7051C266": "public.jpeg", + "1EB2B765-0765-43BA-A90C-0D0580E6172C": "public.jpeg", +} + + +UTI_ORIGINAL_DICT = { + "8846E3E6-8AC8-4857-8448-E3D025784410": "public.tiff", + "7783E8E6-9CAC-40F3-BE22-81FB7051C266": "public.heic", + "1EB2B765-0765-43BA-A90C-0D0580E6172C": "public.jpeg", +} + + +RawInfo = namedtuple( + "RawInfo", + [ + "comment", + "original_filename", + "has_raw", + "israw", + "raw_original", + "uti", + "uti_original", + "uti_raw", + ], +) + +RAW_DICT = { + "D05A5FE3-15FB-49A1-A15D-AB3DA6F8B068": RawInfo( + "raw image, no jpeg pair", + "DSC03584.dng", + False, + True, + False, + "com.adobe.raw-image", + "com.adobe.raw-image", + None, + ), + "A92D9C26-3A50-4197-9388-CB5F7DB9FA91": RawInfo( + "raw+jpeg, jpeg original", + "IMG_1994.JPG", + True, + False, + False, + "public.jpeg", + "public.jpeg", + "com.canon.cr2-raw-image", + ), + "4D521201-92AC-43E5-8F7C-59BC41C37A96": RawInfo( + "raw+jpeg, raw original", + "IMG_1997.JPG", + True, + False, + True, + "public.jpeg", + "public.jpeg", + "com.canon.cr2-raw-image", + ), + "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": RawInfo( + "jpeg, no raw", + "wedding.jpg", + False, + False, + False, + "public.jpeg", + "public.jpeg", + None, + ), +} + +ORIGINAL_FILENAME_DICT = { + "uuid": "D79B8D77-BFFC-460B-9312-034F2877D35B", + "filename": "D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg", + "original_filename": "Pumkins2.jpg", +} + + +@pytest.fixture(scope="module") +def photosdb(): + return osxphotos.PhotosDB(dbfile=PHOTOS_DB) + + +def test_init1(): + # test named argument + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + assert isinstance(photosdb, osxphotos.PhotosDB) + + +def test_init2(): + # test positional argument + + photosdb = osxphotos.PhotosDB(PHOTOS_DB) + assert isinstance(photosdb, osxphotos.PhotosDB) + + +def test_init3(): + # test positional and named argument (raises exception) + + with pytest.raises(Exception): + assert osxphotos.PhotosDB(PHOTOS_DB, dbfile=PHOTOS_DB) + + +def test_init4(): + # test invalid db + + (bad_db, bad_db_name) = tempfile.mkstemp(suffix=".db", prefix="osxphotos-") + os.close(bad_db) + + with pytest.raises(Exception): + assert osxphotos.PhotosDB(bad_db_name) + + with pytest.raises(Exception): + assert osxphotos.PhotosDB(dbfile=bad_db_name) + + try: + os.remove(bad_db_name) + except: + pass + + +def test_init5(mocker): + # test failed get_last_library_path + + def bad_library(): + return None + + # get_last_library actually in utils but need to patch it in photosdb because it's imported into photosdb + # because of the layout of photosdb/ need to patch it this way...don't really understand why, but it works + mocker.patch("osxphotos.photosdb.photosdb.get_last_library_path", new=bad_library) + + with pytest.raises(Exception): + assert osxphotos.PhotosDB() + + +def test_db_len(photosdb): + + # assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS + assert len(photosdb) == PHOTOS_DB_LEN + + +def test_db_version(photosdb): + + # assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS + assert photosdb.db_version == "6000" + + +def test_persons(photosdb): + + assert "Katie" in photosdb.persons + assert Counter(PERSONS) == Counter(photosdb.persons) + + +def test_keywords(photosdb): + + assert "wedding" in photosdb.keywords + assert Counter(KEYWORDS) == Counter(photosdb.keywords) + + +def test_album_names(photosdb): + + assert "Pumpkin Farm" in photosdb.albums + assert Counter(ALBUMS) == Counter(photosdb.albums) + + +def test_keywords_dict(photosdb): + + keywords = photosdb.keywords_as_dict + assert keywords["wedding"] == 3 + assert keywords == KEYWORDS_DICT + + +def test_persons_as_dict(photosdb): + + persons = photosdb.persons_as_dict + assert persons["Maria"] == 2 + assert persons == PERSONS_DICT + + +def test_albums_as_dict(photosdb): + + albums = photosdb.albums_as_dict + assert albums["Pumpkin Farm"] == 3 + assert albums == ALBUM_DICT + + +def test_album_sort_order(photosdb): + + album = [a for a in photosdb.album_info if a.title == "Pumpkin Farm"][0] + photos = album.photos + + uuids = [p.uuid for p in photos] + assert uuids == ALBUM_SORT_ORDER + + +def test_album_empty_album(photosdb): + + album = [a for a in photosdb.album_info if a.title == "EmptyAlbum"][0] + photos = album.photos + assert photos == [] + + +def test_attributes(photosdb): + + photos = photosdb.photos(uuid=["D79B8D77-BFFC-460B-9312-034F2877D35B"]) + assert len(photos) == 1 + p = photos[0] + assert p.keywords == ["Kids"] + assert p.original_filename == "Pumkins2.jpg" + assert p.filename == "D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg" + assert p.date == datetime.datetime( + 2018, 9, 28, 16, 7, 7, 0, datetime.timezone(datetime.timedelta(seconds=-14400)) + ) + assert p.description == "Girl holding pumpkin" + assert p.title == "I found one!" + assert sorted(p.albums) == ["Pumpkin Farm", "Test Album"] + assert p.persons == ["Katie"] + assert p.path.endswith( + "tests/Test-10.15.7.photoslibrary/originals/D/D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg" + ) + assert p.ismissing == False + + +def test_attributes_2(photosdb): + """ Test attributes including height, width, etc """ + + photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]]) + assert len(photos) == 1 + p = photos[0] + assert p.keywords == ["wedding"] + assert p.original_filename == "wedding.jpg" + assert p.filename == "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg" + assert p.date == datetime.datetime( + 2019, + 4, + 15, + 14, + 40, + 24, + 86000, + datetime.timezone(datetime.timedelta(seconds=-14400)), + ) + assert p.description == "Bride Wedding day" + assert p.title is None + assert sorted(p.albums) == ["AlbumInFolder", "I have a deleted twin"] + assert p.persons == ["Maria"] + assert p.path.endswith( + "tests/Test-10.15.7.photoslibrary/originals/E/E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg" + ) + assert not p.ismissing + assert p.hasadjustments + assert p.height == 1325 + assert p.width == 1526 + assert p.original_height == 1367 + assert p.original_width == 2048 + assert p.orientation == 1 + assert p.original_orientation == 1 + assert p.original_filesize == 460483 + + +def test_missing(photosdb): + + photos = photosdb.photos(uuid=[UUID_DICT["missing"]]) + assert len(photos) == 1 + p = photos[0] + assert p.path is None + assert p.ismissing == True + + +def test_favorite(photosdb): + + photos = photosdb.photos(uuid=[UUID_DICT["favorite"]]) + assert len(photos) == 1 + p = photos[0] + assert p.favorite == True + + +def test_not_favorite(photosdb): + + photos = photosdb.photos(uuid=[UUID_DICT["not_favorite"]]) + assert len(photos) == 1 + p = photos[0] + assert p.favorite == False + + +def test_hidden(photosdb): + + photos = photosdb.photos(uuid=[UUID_DICT["hidden"]]) + assert len(photos) == 1 + p = photos[0] + assert p.hidden == True + + +def test_not_hidden(photosdb): + + photos = photosdb.photos(uuid=[UUID_DICT["not_hidden"]]) + assert len(photos) == 1 + p = photos[0] + assert p.hidden == False + + +def test_location_1(photosdb): + # test photo with lat/lon info + + photos = photosdb.photos(uuid=[UUID_DICT["location"]]) + assert len(photos) == 1 + p = photos[0] + lat, lon = p.location + assert lat == pytest.approx(51.50357167) + assert lon == pytest.approx(-0.1318055) + + +def test_location_2(photosdb): + # test photo with no location info + + photos = photosdb.photos(uuid=[UUID_DICT["no_location"]]) + assert len(photos) == 1 + p = photos[0] + lat, lon = p.location + assert lat is None + assert lon is None + + +def test_hasadjustments1(photosdb): + # test hasadjustments == True + + photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]]) + assert len(photos) == 1 + p = photos[0] + assert p.hasadjustments == True + + +def test_hasadjustments2(photosdb): + # test hasadjustments == False + + photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]]) + assert len(photos) == 1 + p = photos[0] + assert p.hasadjustments == False + + +def test_external_edit1(photosdb): + # test image has been edited in external editor + + photos = photosdb.photos(uuid=[UUID_DICT["external_edit"]]) + assert len(photos) == 1 + p = photos[0] + + assert p.external_edit == True + + +def test_external_edit2(photosdb): + # test image has not been edited in external editor + + photos = photosdb.photos(uuid=[UUID_DICT["no_external_edit"]]) + assert len(photos) == 1 + p = photos[0] + + assert p.external_edit == False + + +def test_path_edited1(photosdb): + # test a valid edited path + + photos = photosdb.photos(uuid=["E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51"]) + assert len(photos) == 1 + p = photos[0] + path = p.path_edited + assert path.endswith( + "resources/renders/E/E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51_1_201_a.jpeg" + ) + assert os.path.exists(path) + + +def test_path_edited2(photosdb): + # test an invalid edited path + + photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]]) + assert len(photos) == 1 + p = photos[0] + path = p.path_edited + assert path is None + + +def test_ismovie(photosdb): + # test ismovie == True + + photos = photosdb.photos(uuid=[UUID_DICT["movie"]]) + p = photos[0] + assert p.ismovie + + +def test_not_ismovie(photosdb): + # test ismovie == False + + photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]]) + p = photos[0] + assert not p.ismovie + + +def test_count(photosdb): + + photos = photosdb.photos() + assert len(photos) == PHOTOS_NOT_IN_TRASH_LEN + + +def test_photos_intrash_1(photosdb): + """ test PhotosDB.photos(intrash=True) """ + + photos = photosdb.photos(intrash=True) + assert len(photos) == PHOTOS_IN_TRASH_LEN + + +def test_photos_intrash_2(photosdb): + """ test PhotosDB.photos(intrash=True) """ + + photos = photosdb.photos(intrash=True) + for p in photos: + assert p.intrash + + +def test_photos_intrash_3(photosdb): + """ test PhotosDB.photos(intrash=False) """ + + photos = photosdb.photos(intrash=False) + for p in photos: + assert not p.intrash + + +def test_photoinfo_intrash_1(photosdb): + """ Test PhotoInfo.intrash """ + + p = photosdb.photos(uuid=[UUID_DICT["intrash"]], intrash=True)[0] + assert p.intrash + + +def test_photoinfo_intrash_2(photosdb): + """ Test PhotoInfo.intrash and intrash=default""" + + p = photosdb.photos(uuid=[UUID_DICT["intrash"]]) + assert not p + + +def test_photoinfo_intrash_3(photosdb): + """ Test PhotoInfo.intrash and photo has keyword and person """ + + p = photosdb.photos(uuid=[UUID_DICT["intrash_person_keywords"]], intrash=True)[0] + assert p.intrash + assert "Maria" in p.persons + assert "wedding" in p.keywords + + +def test_photoinfo_intrash_4(photosdb): + """ Test PhotoInfo.intrash and photo has keyword and person """ + + p = photosdb.photos(persons=["Maria"], intrash=True)[0] + assert p.intrash + assert "Maria" in p.persons + assert "wedding" in p.keywords + + +def test_photoinfo_intrash_5(photosdb): + """ Test PhotoInfo.intrash and photo has keyword and person """ + + p = photosdb.photos(keywords=["wedding"], intrash=True)[0] + assert p.intrash + assert "Maria" in p.persons + assert "wedding" in p.keywords + + +def test_photoinfo_not_intrash(photosdb): + """ Test PhotoInfo.intrash """ + + p = photosdb.photos(uuid=[UUID_DICT["not_intrash"]])[0] + assert not p.intrash + + +def test_keyword_2(photosdb): + + photos = photosdb.photos(keywords=["wedding"]) + assert len(photos) == 2 # won't show the one in the trash + + +def test_keyword_not_in_album(photosdb): + + # find all photos with keyword "Kids" not in the album "Pumpkin Farm" + photos1 = photosdb.photos(albums=["Pumpkin Farm"]) + photos2 = photosdb.photos(keywords=["Kids"]) + photos3 = [p for p in photos2 if p not in photos1] + assert len(photos3) == 1 + assert photos3[0].uuid == "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C" + + +def test_album_folder_name(photosdb): + """Test query with album name same as a folder name """ + + photos = photosdb.photos(albums=["Pumpkin Farm"]) + assert sorted(p.uuid for p in photos) == sorted(UUID_PUMPKIN_FARM) + + +def test_multi_person(photosdb): + + photos = photosdb.photos(persons=["Katie", "Suzy"]) + + assert len(photos) == 3 + + +def test_get_db_path(photosdb): + + db_path = photosdb.db_path + assert db_path.endswith(PHOTOS_DB_PATH) + + +def test_get_library_path(photosdb): + + lib_path = photosdb.library_path + assert lib_path.endswith(PHOTOS_LIBRARY_PATH) + + +def test_get_db_connection(photosdb): + """ Test PhotosDB.get_db_connection """ + + conn, cursor = photosdb.get_db_connection() + + assert isinstance(conn, sqlite3.Connection) + assert isinstance(cursor, sqlite3.Cursor) + + results = conn.execute( + "SELECT ZUUID FROM ZGENERICASSET WHERE ZFAVORITE = 1;" + ).fetchall() + assert len(results) == 1 + assert results[0][0] == "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51" # uuid + + conn.close() + + +def test_export_1(photosdb): + # test basic export + # get an unedited image and export it using default filename + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["export"]]) + + filename = photos[0].filename + expected_dest = os.path.join(dest, filename) + got_dest = photos[0].export(dest)[0] + + assert got_dest == expected_dest + assert os.path.isfile(got_dest) + + +def test_export_2(photosdb): + # test export with user provided filename + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["export"]]) + + timestamp = time.time() + filename = f"osxphotos-export-2-test-{timestamp}.jpg" + expected_dest = os.path.join(dest, filename) + got_dest = photos[0].export(dest, filename)[0] + + assert got_dest == expected_dest + assert os.path.isfile(got_dest) + + +def test_export_3(photosdb): + # test file already exists and test increment=True (default) + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["export"]]) + + filename = photos[0].filename + filename2 = pathlib.Path(filename) + filename2 = f"{filename2.stem} (1){filename2.suffix}" + expected_dest_2 = os.path.join(dest, filename2) + + got_dest = photos[0].export(dest)[0] + got_dest_2 = photos[0].export(dest)[0] + + assert got_dest_2 == expected_dest_2 + assert os.path.isfile(got_dest_2) + + +def test_export_4(photosdb): + # test user supplied file already exists and test increment=True (default) + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["export"]]) + + timestamp = time.time() + filename = f"osxphotos-export-2-test-{timestamp}.jpg" + filename2 = f"osxphotos-export-2-test-{timestamp} (1).jpg" + expected_dest_2 = os.path.join(dest, filename2) + + got_dest = photos[0].export(dest, filename)[0] + got_dest_2 = photos[0].export(dest, filename)[0] + + assert got_dest_2 == expected_dest_2 + assert os.path.isfile(got_dest_2) + + +def test_export_5(photosdb): + # test file already exists and test increment=True (default) + # and overwrite = True + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["export"]]) + + filename = photos[0].filename + expected_dest = os.path.join(dest, filename) + + got_dest = photos[0].export(dest)[0] + got_dest_2 = photos[0].export(dest, overwrite=True)[0] + + assert got_dest_2 == got_dest + assert got_dest_2 == expected_dest + assert os.path.isfile(got_dest_2) + + +def test_export_6(photosdb): + # test user supplied file already exists and test increment=True (default) + # and overwrite = True + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["export"]]) + + timestamp = time.time() + filename = f"osxphotos-export-test-{timestamp}.jpg" + expected_dest = os.path.join(dest, filename) + + got_dest = photos[0].export(dest, filename)[0] + got_dest_2 = photos[0].export(dest, filename, overwrite=True)[0] + + assert got_dest_2 == got_dest + assert got_dest_2 == expected_dest + assert os.path.isfile(got_dest_2) + + +def test_export_7(photosdb): + # test file already exists and test increment=False (not default), overwrite=False (default) + # should raise exception + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["export"]]) + + filename = photos[0].filename + + got_dest = photos[0].export(dest)[0] + with pytest.raises(Exception) as e: + # try to export again with increment = False + assert photos[0].export(dest, increment=False) + assert e.type == type(FileExistsError()) + + +def test_export_8(photosdb): + # try to export missing file + # should raise exception + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["missing"]]) + + filename = photos[0].filename + + with pytest.raises(Exception) as e: + assert photos[0].export(dest)[0] + assert e.type == type(FileNotFoundError()) + + +def test_export_9(photosdb): + # try to export edited file that's not edited + # should raise exception + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]]) + + filename = photos[0].filename + + with pytest.raises(Exception) as e: + assert photos[0].export(dest, edited=True) + assert e.type == ValueError + + +def test_export_10(photosdb): + # try to export edited file that's not edited and name provided + # should raise exception + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]]) + + timestamp = time.time() + filename = f"osxphotos-export-test-{timestamp}.jpg" + + with pytest.raises(Exception) as e: + assert photos[0].export(dest, filename, edited=True) + assert e.type == ValueError + + +def test_export_11(photosdb): + # export edited file with name provided + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]]) + + timestamp = time.time() + filename = f"osxphotos-export-test-{timestamp}.jpg" + expected_dest = os.path.join(dest, filename) + + got_dest = photos[0].export(dest, filename, edited=True)[0] + assert got_dest == expected_dest + + +def test_export_12(photosdb): + # export edited file with default name + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]]) + + edited_name = pathlib.Path(photos[0].path_edited).name + edited_suffix = pathlib.Path(edited_name).suffix + filename = pathlib.Path(photos[0].filename).stem + "_edited" + edited_suffix + expected_dest = os.path.join(dest, filename) + + got_dest = photos[0].export(dest, edited=True)[0] + assert got_dest == expected_dest + + +def test_export_13(photosdb): + # export to invalid destination + # should raise exception + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + + # create a folder that doesn't exist + i = 0 + while os.path.isdir(dest): + dest = os.path.join(dest, str(i)) + i += 1 + + photos = photosdb.photos(uuid=[UUID_DICT["export"]]) + + filename = photos[0].filename + + with pytest.raises(Exception) as e: + assert photos[0].export(dest) + assert e.type == type(FileNotFoundError()) + + +def test_export_14(photosdb, caplog): + # test export with user provided filename with different (but valid) extension than source + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["export_tif"]]) + + timestamp = time.time() + filename = f"osxphotos-export-2-test-{timestamp}.tif" + expected_dest = os.path.join(dest, filename) + got_dest = photos[0].export(dest, filename)[0] + + assert got_dest == expected_dest + assert os.path.isfile(got_dest) + + assert "Invalid destination suffix" not in caplog.text + + +def test_export_no_original_filename(photosdb): + # test export OK if original filename is null + # issue #267 + + tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_") + dest = tempdir.name + photos = photosdb.photos(uuid=[UUID_DICT["export"]]) + + # monkey patch original_filename for testing + original_filename = photos[0]._info["originalFilename"] + photos[0]._info["originalFilename"] = None + filename = f"{photos[0].uuid}.jpeg" + expected_dest = os.path.join(dest, filename) + got_dest = photos[0].export(dest)[0] + + assert got_dest == expected_dest + assert os.path.isfile(got_dest) + + photos[0]._info["originalFilename"] = original_filename + + +def test_eq(): + """ Test equality of two PhotoInfo objects """ + + photosdb1 = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photosdb2 = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photos1 = photosdb1.photos(uuid=[UUID_DICT["export"]]) + photos2 = photosdb2.photos(uuid=[UUID_DICT["export"]]) + assert photos1[0] == photos2[0] + + +def test_eq_2(): + """ Test equality of two PhotoInfo objects when one has memoized property """ + + photosdb1 = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photosdb2 = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photos1 = photosdb1.photos(uuid=[UUID_DICT["in_album"]]) + photos2 = photosdb2.photos(uuid=[UUID_DICT["in_album"]]) + + # memoize a value + albums = photos1[0].albums + assert albums + + assert photos1[0] == photos2[0] + + +def test_not_eq(photosdb): + + photos1 = photosdb.photos(uuid=[UUID_DICT["export"]]) + photos2 = photosdb.photos(uuid=[UUID_DICT["missing"]]) + assert photos1[0] != photos2[0] + + +def test_photosdb_repr(): + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photosdb2 = eval(repr(photosdb)) + + ignore_keys = ["_tmp_db", "_tempdir", "_tempdir_name"] + assert {k: v for k, v in photosdb.__dict__.items() if k not in ignore_keys} == { + k: v for k, v in photosdb2.__dict__.items() if k not in ignore_keys + } + + +def test_photosinfo_repr(photosdb): + + photos = photosdb.photos(uuid=[UUID_DICT["favorite"]]) + photo = photos[0] + photo2 = eval(repr(photo)) + + assert {k: str(v).encode("utf-8") for k, v in photo.__dict__.items()} == { + k: str(v).encode("utf-8") for k, v in photo2.__dict__.items() + } + + +def test_from_to_date(photosdb): + """ test from_date / to_date """ + + os.environ["TZ"] = "US/Pacific" + time.tzset() + + photos = photosdb.photos(from_date=datetime.datetime(2018, 10, 28)) + assert len(photos) == 9 + + photos = photosdb.photos(to_date=datetime.datetime(2018, 10, 28)) + assert len(photos) == 7 + + photos = photosdb.photos( + from_date=datetime.datetime(2018, 9, 28), to_date=datetime.datetime(2018, 9, 29) + ) + assert len(photos) == 4 + + +def test_from_to_date_tz(photosdb): + """ Test from_date / to_date with and without timezone """ + + os.environ["TZ"] = "US/Pacific" + time.tzset() + + photos = photosdb.photos( + from_date=datetime.datetime(2018, 9, 28, 13, 7, 0), + to_date=datetime.datetime(2018, 9, 28, 13, 9, 0), + ) + assert len(photos) == 1 + assert photos[0].uuid == "D79B8D77-BFFC-460B-9312-034F2877D35B" + + photos = photosdb.photos( + from_date=datetime.datetime( + 2018, + 9, + 28, + 16, + 7, + 0, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=72000)), + ), + to_date=datetime.datetime( + 2018, + 9, + 28, + 16, + 9, + 0, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=72000)), + ), + ) + assert len(photos) == 1 + assert photos[0].uuid == "D79B8D77-BFFC-460B-9312-034F2877D35B" + + +def test_date_invalid(): + """ Test date is invalid """ + # doesn't run correctly with the module-level fixture + from datetime import datetime, timedelta, timezone + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photos = photosdb.photos(uuid=[UUID_DICT["date_invalid"]]) + assert len(photos) == 1 + p = photos[0] + delta = timedelta(seconds=p.tzoffset) + tz = timezone(delta) + assert p.date == datetime(1970, 1, 1).astimezone(tz=tz) + + +def test_date_modified_invalid(photosdb): + """ Test date modified is invalid """ + + photos = photosdb.photos(uuid=[UUID_DICT["date_invalid"]]) + assert len(photos) == 1 + p = photos[0] + assert p.date_modified is None + + +def test_import_session_count(photosdb): + """ Test PhotosDB.import_session """ + + import_sessions = photosdb.import_info + assert len(import_sessions) == PHOTOS_DB_IMPORT_SESSIONS + + +def test_import_session_photo(photosdb): + """ Test photo.import_session """ + + photo = photosdb.get_photo(UUID_DICT["import_session"]) + import_session = photo.import_info + assert import_session.creation_date == datetime.datetime( + 2020, + 6, + 6, + 7, + 15, + 24, + 729811, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200), "PDT"), + ) + assert import_session.start_date == datetime.datetime( + 2020, + 6, + 6, + 7, + 15, + 24, + 725564, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200), "PDT"), + ) + assert import_session.end_date == datetime.datetime( + 2020, + 6, + 6, + 7, + 15, + 24, + 725564, + tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200), "PDT"), + ) + assert len(import_session.photos) == 1 + + +def test_uti(photosdb): + """ test uti """ + + for uuid, uti in UTI_DICT.items(): + photo = photosdb.get_photo(uuid) + assert photo.uti == uti + assert photo.uti_original == UTI_ORIGINAL_DICT[uuid] + + +def test_raw(photosdb): + """ Test various raw properties """ + + for uuid, rawinfo in RAW_DICT.items(): + photo = photosdb.get_photo(uuid) + assert photo.original_filename == rawinfo.original_filename + assert photo.has_raw == rawinfo.has_raw + assert photo.israw == rawinfo.israw + assert photo.uti == rawinfo.uti + assert photo.uti_original == rawinfo.uti_original + assert photo.uti_raw == rawinfo.uti_raw + + +def test_verbose(capsys): + """ test verbose output in PhotosDB() """ + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB, verbose=print) + captured = capsys.readouterr() + assert "Processing database" in captured.out + + +def test_original_filename(photosdb): + """ test original filename """ + uuid = ORIGINAL_FILENAME_DICT["uuid"] + photo = photosdb.get_photo(uuid) + assert photo.original_filename == ORIGINAL_FILENAME_DICT["original_filename"] + assert photo.filename == ORIGINAL_FILENAME_DICT["filename"] + + # monkey patch + original_filename = photo._info["originalFilename"] + photo._info["originalFilename"] = None + assert photo.original_filename == ORIGINAL_FILENAME_DICT["filename"] + photo._info["originalFilename"] = original_filename + diff --git a/tests/test_cli.py b/tests/test_cli.py index 2ce267da..10bb27fd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 = { "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": { "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] +@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(): """ test export with --edited-suffix """ import glob @@ -2859,8 +2924,7 @@ def test_export_sidecar_keyword_template(): 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:Title": "I found one!", "XMP:TagsList": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"], diff --git a/tests/test_datetime_utils.py b/tests/test_datetime_utils.py index a2ec21d7..d31182f7 100644 --- a/tests/test_datetime_utils.py +++ b/tests/test_datetime_utils.py @@ -1,90 +1,96 @@ -""" test datetime_utils """ +from datetime import date, timezone import pytest +from osxphotos.datetime_utils import * + def test_get_local_tz(): - """ test get_local_tz during time with no DST """ 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, 12, 31, 0, 0, 0) - local_tz = get_local_tz(dt) - assert local_tz == datetime.timezone( - datetime.timedelta(days=-1, seconds=57600), "PST" - ) + dt = datetime.datetime(2020, 9, 1, 21, 10, 00) + tz = get_local_tz(dt) + assert tz == datetime.timezone(offset=datetime.timedelta(seconds=-25200)) - -def test_get_local_tz_dst(): - """ test get_local_tz during time with DST """ - 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 + dt = datetime.datetime(2020, 12, 1, 21, 10, 00) + tz = get_local_tz(dt) + assert tz == datetime.timezone(offset=datetime.timedelta(seconds=-28800)) def test_datetime_has_tz(): - """ test datetime_has_tz """ import datetime - from osxphotos.datetime_utils import datetime_has_tz - - dt = datetime.datetime( - 2018, - 12, - 31, - tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=57600), "PST"), - ) + tz = datetime.timezone(offset=datetime.timedelta(seconds=-28800)) + dt = datetime.datetime(2020, 9, 1, 21, 10, 00, tzinfo=tz) assert datetime_has_tz(dt) - dt_notz = datetime.datetime(2018, 12, 31) - assert not datetime_has_tz(dt_notz) + dt = datetime.datetime(2020, 9, 1, 21, 10, 00) + 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(): - """ test datetime_naive_to_local """ import datetime import os - import time - - from osxphotos.datetime_utils import datetime_naive_to_local os.environ["TZ"] = "US/Pacific" - time.tzset() - dt = datetime.datetime(2018, 6, 30, 0, 0, 0) - dt_local = datetime_naive_to_local(dt) - assert dt_local.tzinfo == datetime.timezone( - datetime.timedelta(days=-1, seconds=61200), "PDT" - ) + tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200)) + dt = datetime.datetime(2020, 9, 1, 12, 0, 0) + utc = datetime_naive_to_local(dt) + 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) \ No newline at end of file diff --git a/tests/test_export_catalina_10_15_7.py b/tests/test_export_catalina_10_15_7.py index 30593bad..e0c77ea7 100644 --- a/tests/test_export_catalina_10_15_7.py +++ b/tests/test_export_catalina_10_15_7.py @@ -68,8 +68,7 @@ XMP_JPG_FILENAME = "Pumkins1.jpg" EXIF_JSON_UUID = UUID_DICT["has_adjustments"] 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:TagsList": ["wedding"], "IPTC:Keywords": ["wedding"], @@ -84,8 +83,7 @@ EXIF_JSON_EXPECTED = """ """ 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:TagsList": ["wedding"], "IPTC:Keywords": ["wedding"], @@ -544,8 +542,7 @@ def test_exiftool_json_sidecar_keyword_template_long(caplog): 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:TagsList": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"], "IPTC:Keywords": ["wedding", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"], @@ -594,8 +591,7 @@ def test_exiftool_json_sidecar_keyword_template(): 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:TagsList": ["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( """ - [{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", - "EXIF:ImageDescription": "Girls with pumpkins", + [{"EXIF:ImageDescription": "Girls with pumpkins", "XMP:Description": "Girls with pumpkins", "XMP:Title": "Can we carry this?", "XMP:TagsList": ["Kids", "Suzy", "Katie"], @@ -698,8 +693,7 @@ def test_exiftool_json_sidecar_use_albums_keyword(): 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:Title": "Can we carry this?", "XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"], diff --git a/tests/test_export_mojave_10_14_6.py b/tests/test_export_mojave_10_14_6.py index b965654e..37c74f78 100644 --- a/tests/test_export_mojave_10_14_6.py +++ b/tests/test_export_mojave_10_14_6.py @@ -46,8 +46,7 @@ UUID_DICT = { } 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"], "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"],